botmux 3.0.0 → 3.1.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 (220) hide show
  1. package/dist/adapters/backend/herdr-backend.d.ts +9 -7
  2. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  3. package/dist/adapters/backend/herdr-backend.js +63 -53
  4. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  5. package/dist/adapters/backend/riff-backend.d.ts +6 -1
  6. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  7. package/dist/adapters/backend/riff-backend.js +7 -2
  8. package/dist/adapters/backend/riff-backend.js.map +1 -1
  9. package/dist/adapters/backend/sandbox.d.ts +20 -16
  10. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  11. package/dist/adapters/backend/sandbox.js +193 -68
  12. package/dist/adapters/backend/sandbox.js.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.d.ts +39 -2
  14. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/tmux-backend.js +66 -6
  16. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  17. package/dist/adapters/backend/tmux-pipe-backend.js +2 -2
  18. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  19. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  20. package/dist/adapters/backend/zellij-backend.js +9 -3
  21. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  22. package/dist/adapters/cli/claude-code.d.ts +25 -0
  23. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  24. package/dist/adapters/cli/claude-code.js +79 -9
  25. package/dist/adapters/cli/claude-code.js.map +1 -1
  26. package/dist/adapters/cli/read-isolation.d.ts +2 -1
  27. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  28. package/dist/adapters/cli/read-isolation.js.map +1 -1
  29. package/dist/adapters/cli/types.d.ts +2 -2
  30. package/dist/adapters/hook-installer.d.ts +11 -0
  31. package/dist/adapters/hook-installer.d.ts.map +1 -1
  32. package/dist/adapters/hook-installer.js +72 -11
  33. package/dist/adapters/hook-installer.js.map +1 -1
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +15 -4
  36. package/dist/cli.js.map +1 -1
  37. package/dist/config.d.ts +6 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +18 -0
  40. package/dist/config.js.map +1 -1
  41. package/dist/core/command-handler.d.ts.map +1 -1
  42. package/dist/core/command-handler.js +354 -143
  43. package/dist/core/command-handler.js.map +1 -1
  44. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  45. package/dist/core/dashboard-ipc-server.js +12 -5
  46. package/dist/core/dashboard-ipc-server.js.map +1 -1
  47. package/dist/core/dashboard-rows.d.ts +5 -0
  48. package/dist/core/dashboard-rows.d.ts.map +1 -1
  49. package/dist/core/dashboard-rows.js +2 -0
  50. package/dist/core/dashboard-rows.js.map +1 -1
  51. package/dist/core/doc-comment-prompt.d.ts +2 -0
  52. package/dist/core/doc-comment-prompt.d.ts.map +1 -1
  53. package/dist/core/doc-comment-prompt.js +30 -0
  54. package/dist/core/doc-comment-prompt.js.map +1 -1
  55. package/dist/core/github-auth.d.ts +8 -0
  56. package/dist/core/github-auth.d.ts.map +1 -0
  57. package/dist/core/github-auth.js +44 -0
  58. package/dist/core/github-auth.js.map +1 -0
  59. package/dist/core/managed-origin-capability.d.ts +7 -0
  60. package/dist/core/managed-origin-capability.d.ts.map +1 -1
  61. package/dist/core/managed-origin-capability.js +12 -0
  62. package/dist/core/managed-origin-capability.js.map +1 -1
  63. package/dist/core/passthrough-commands.d.ts.map +1 -1
  64. package/dist/core/passthrough-commands.js +1 -1
  65. package/dist/core/passthrough-commands.js.map +1 -1
  66. package/dist/core/restart-report.d.ts +7 -1
  67. package/dist/core/restart-report.d.ts.map +1 -1
  68. package/dist/core/restart-report.js +19 -5
  69. package/dist/core/restart-report.js.map +1 -1
  70. package/dist/core/session-activity.d.ts +8 -0
  71. package/dist/core/session-activity.d.ts.map +1 -1
  72. package/dist/core/session-activity.js +16 -0
  73. package/dist/core/session-activity.js.map +1 -1
  74. package/dist/core/session-discovery.d.ts.map +1 -1
  75. package/dist/core/session-discovery.js +4 -2
  76. package/dist/core/session-discovery.js.map +1 -1
  77. package/dist/core/session-marker.d.ts.map +1 -1
  78. package/dist/core/session-marker.js +5 -2
  79. package/dist/core/session-marker.js.map +1 -1
  80. package/dist/core/types.d.ts +48 -0
  81. package/dist/core/types.d.ts.map +1 -1
  82. package/dist/core/types.js +55 -0
  83. package/dist/core/types.js.map +1 -1
  84. package/dist/core/update-check.d.ts +11 -0
  85. package/dist/core/update-check.d.ts.map +1 -1
  86. package/dist/core/update-check.js +6 -1
  87. package/dist/core/update-check.js.map +1 -1
  88. package/dist/core/vc-meeting-prepare-command.d.ts +22 -0
  89. package/dist/core/vc-meeting-prepare-command.d.ts.map +1 -0
  90. package/dist/core/vc-meeting-prepare-command.js +50 -0
  91. package/dist/core/vc-meeting-prepare-command.js.map +1 -0
  92. package/dist/core/worker-pool.d.ts.map +1 -1
  93. package/dist/core/worker-pool.js +21 -10
  94. package/dist/core/worker-pool.js.map +1 -1
  95. package/dist/daemon.d.ts +15 -5
  96. package/dist/daemon.d.ts.map +1 -1
  97. package/dist/daemon.js +645 -50
  98. package/dist/daemon.js.map +1 -1
  99. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  100. package/dist/dashboard/web/bot-defaults-page.js +6 -1
  101. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  102. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  103. package/dist/dashboard/web/i18n.js +34 -0
  104. package/dist/dashboard/web/i18n.js.map +1 -1
  105. package/dist/dashboard/web/preferences.d.ts +1 -1
  106. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  107. package/dist/dashboard/web/preferences.js +1 -1
  108. package/dist/dashboard/web/preferences.js.map +1 -1
  109. package/dist/dashboard/web/sessions-page.d.ts +19 -0
  110. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  111. package/dist/dashboard/web/sessions-page.js +66 -7
  112. package/dist/dashboard/web/sessions-page.js.map +1 -1
  113. package/dist/dashboard/web/sessions.d.ts +31 -0
  114. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  115. package/dist/dashboard/web/sessions.js +82 -0
  116. package/dist/dashboard/web/sessions.js.map +1 -1
  117. package/dist/dashboard-web/app.js +1 -1
  118. package/dist/dashboard-web/chunks/{bot-defaults-page-MBORWWP7.js → bot-defaults-page-KLDCZNLP.js} +5 -5
  119. package/dist/dashboard-web/chunks/{chunk-G43RUPUN.js → chunk-EZAW2EPM.js} +1 -1
  120. package/dist/dashboard-web/chunks/{chunk-EIO5AWDA.js → chunk-FMJNOATE.js} +1 -1
  121. package/dist/dashboard-web/chunks/{chunk-4QTBR7UF.js → chunk-KEAOTUL3.js} +7 -7
  122. package/dist/dashboard-web/chunks/{chunk-7STGZ5BX.js → chunk-MLNI5BKE.js} +1 -1
  123. package/dist/dashboard-web/chunks/{connectors-page-LKDT4WBG.js → connectors-page-O32LFAL5.js} +1 -1
  124. package/dist/dashboard-web/chunks/{groups-page-XKIWJ2TP.js → groups-page-PSAFIZ6M.js} +1 -1
  125. package/dist/dashboard-web/chunks/{insights-page-CBEZCYKV.js → insights-page-V23LLATX.js} +1 -1
  126. package/dist/dashboard-web/chunks/{monitor-room-AQTHAWXA.js → monitor-room-7X5AKYOF.js} +1 -1
  127. package/dist/dashboard-web/chunks/{monitoring-page-2R4NCNBQ.js → monitoring-page-CBA3CQYL.js} +1 -1
  128. package/dist/dashboard-web/chunks/{office-page-5M6MQIIL.js → office-page-RL6QSEM7.js} +1 -1
  129. package/dist/dashboard-web/chunks/{overview-page-N22HDZ2V.js → overview-page-XQIPO6KH.js} +1 -1
  130. package/dist/dashboard-web/chunks/{roles-page-J6K2HWFX.js → roles-page-ACIWV7QS.js} +1 -1
  131. package/dist/dashboard-web/chunks/{schedules-page-CGAQCB4J.js → schedules-page-KJDUKOJV.js} +1 -1
  132. package/dist/dashboard-web/chunks/sessions-page-2WDWHSP4.js +3 -0
  133. package/dist/dashboard-web/chunks/{settings-page-GLRWV5KU.js → settings-page-6VX4OUGG.js} +1 -1
  134. package/dist/dashboard-web/chunks/{skills-page-KQVW42QU.js → skills-page-WXE3UYLT.js} +1 -1
  135. package/dist/dashboard-web/chunks/{team-federation-page-5PYUOV5M.js → team-federation-page-PYTF4FLM.js} +1 -1
  136. package/dist/dashboard-web/chunks/{v3-page-DXPFILRZ.js → v3-page-G6CD6O3I.js} +1 -1
  137. package/dist/dashboard-web/chunks/{whiteboards-page-NUH6VZ4I.js → whiteboards-page-6SG33GP4.js} +1 -1
  138. package/dist/dashboard-web/style.css +167 -2
  139. package/dist/i18n/en.d.ts.map +1 -1
  140. package/dist/i18n/en.js +19 -3
  141. package/dist/i18n/en.js.map +1 -1
  142. package/dist/i18n/zh.d.ts.map +1 -1
  143. package/dist/i18n/zh.js +19 -3
  144. package/dist/i18n/zh.js.map +1 -1
  145. package/dist/im/lark/card-builder.d.ts.map +1 -1
  146. package/dist/im/lark/card-builder.js +11 -2
  147. package/dist/im/lark/card-builder.js.map +1 -1
  148. package/dist/im/lark/card-handler.d.ts +2 -0
  149. package/dist/im/lark/card-handler.d.ts.map +1 -1
  150. package/dist/im/lark/card-handler.js +219 -139
  151. package/dist/im/lark/card-handler.js.map +1 -1
  152. package/dist/im/lark/event-dispatcher.d.ts +4 -0
  153. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  154. package/dist/im/lark/event-dispatcher.js +249 -5
  155. package/dist/im/lark/event-dispatcher.js.map +1 -1
  156. package/dist/im/lark/forward-followup-buffer.d.ts +26 -0
  157. package/dist/im/lark/forward-followup-buffer.d.ts.map +1 -0
  158. package/dist/im/lark/forward-followup-buffer.js +48 -0
  159. package/dist/im/lark/forward-followup-buffer.js.map +1 -0
  160. package/dist/im/lark/forward-followup-content.d.ts +6 -0
  161. package/dist/im/lark/forward-followup-content.d.ts.map +1 -0
  162. package/dist/im/lark/forward-followup-content.js +46 -0
  163. package/dist/im/lark/forward-followup-content.js.map +1 -0
  164. package/dist/im/lark/forward-followup-store.d.ts +9 -0
  165. package/dist/im/lark/forward-followup-store.d.ts.map +1 -0
  166. package/dist/im/lark/forward-followup-store.js +48 -0
  167. package/dist/im/lark/forward-followup-store.js.map +1 -0
  168. package/dist/im/lark/message-parser.d.ts +1 -1
  169. package/dist/im/lark/message-parser.d.ts.map +1 -1
  170. package/dist/im/lark/message-parser.js +1 -2
  171. package/dist/im/lark/message-parser.js.map +1 -1
  172. package/dist/platform/tunnel-client.js +3 -3
  173. package/dist/platform/tunnel-client.js.map +1 -1
  174. package/dist/services/local-cli-opener.d.ts +7 -0
  175. package/dist/services/local-cli-opener.d.ts.map +1 -1
  176. package/dist/services/local-cli-opener.js +111 -3
  177. package/dist/services/local-cli-opener.js.map +1 -1
  178. package/dist/services/skill-registry-store.d.ts.map +1 -1
  179. package/dist/services/skill-registry-store.js +15 -2
  180. package/dist/services/skill-registry-store.js.map +1 -1
  181. package/dist/services/vc-meeting-consumer-isolation.d.ts +7 -5
  182. package/dist/services/vc-meeting-consumer-isolation.d.ts.map +1 -1
  183. package/dist/services/vc-meeting-consumer-isolation.js +7 -5
  184. package/dist/services/vc-meeting-consumer-isolation.js.map +1 -1
  185. package/dist/services/vc-meeting-preparations-store.d.ts +22 -0
  186. package/dist/services/vc-meeting-preparations-store.d.ts.map +1 -0
  187. package/dist/services/vc-meeting-preparations-store.js +131 -0
  188. package/dist/services/vc-meeting-preparations-store.js.map +1 -0
  189. package/dist/services/vc-meeting-runtime-store.d.ts +9 -0
  190. package/dist/services/vc-meeting-runtime-store.d.ts.map +1 -1
  191. package/dist/services/vc-meeting-runtime-store.js +16 -0
  192. package/dist/services/vc-meeting-runtime-store.js.map +1 -1
  193. package/dist/setup/ensure-tmux.d.ts.map +1 -1
  194. package/dist/setup/ensure-tmux.js +9 -11
  195. package/dist/setup/ensure-tmux.js.map +1 -1
  196. package/dist/types.d.ts +2 -0
  197. package/dist/types.d.ts.map +1 -1
  198. package/dist/utils/child-env.d.ts +24 -8
  199. package/dist/utils/child-env.d.ts.map +1 -1
  200. package/dist/utils/child-env.js +49 -8
  201. package/dist/utils/child-env.js.map +1 -1
  202. package/dist/utils/daemon-discovery.d.ts +8 -0
  203. package/dist/utils/daemon-discovery.d.ts.map +1 -1
  204. package/dist/utils/daemon-discovery.js +19 -0
  205. package/dist/utils/daemon-discovery.js.map +1 -1
  206. package/dist/utils/ready-gate.d.ts +9 -3
  207. package/dist/utils/ready-gate.d.ts.map +1 -1
  208. package/dist/utils/ready-gate.js +12 -4
  209. package/dist/utils/ready-gate.js.map +1 -1
  210. package/dist/worker.js +141 -29
  211. package/dist/worker.js.map +1 -1
  212. package/dist/workflows/ops-projection.d.ts.map +1 -1
  213. package/dist/workflows/ops-projection.js +27 -5
  214. package/dist/workflows/ops-projection.js.map +1 -1
  215. package/dist/workflows/v3/distillation-runner.d.ts +4 -0
  216. package/dist/workflows/v3/distillation-runner.d.ts.map +1 -1
  217. package/dist/workflows/v3/distillation-runner.js +4 -2
  218. package/dist/workflows/v3/distillation-runner.js.map +1 -1
  219. package/package.json +1 -1
  220. package/dist/dashboard-web/chunks/sessions-page-WJ2QYTIE.js +0 -3
@@ -19,13 +19,13 @@ import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCar
19
19
  import { computeSandboxDiff } from '../services/sandbox-land.js';
20
20
  import { handleDashboardCommand } from './dashboard-command/index.js';
21
21
  import { createCliAdapterSync } from '../adapters/cli/registry.js';
22
- import { deleteMessage, sendMessage, sendUserMessage, listChatBotMembers, resolveUserUnionId, getChatModeStrict, uploadFile, UserTokenMissingError } from '../im/lark/client.js';
22
+ import { deleteMessage, sendMessage, sendUserMessage, replyMessage, listChatBotMembers, resolveUserUnionId, getChatModeStrict, uploadFile, UserTokenMissingError } from '../im/lark/client.js';
23
23
  import { chatAppLink, normalizeBrand } from '../im/lark/lark-hosts.js';
24
24
  import { claimPairing } from '../services/pairing-store.js';
25
25
  import { logger } from '../utils/logger.js';
26
26
  import { scheduleTimeZone } from '../utils/timezone.js';
27
- import { killWorker, forkWorker, forkAdoptWorker, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo } from './worker-pool.js';
28
- import { expandHome, getSessionWorkingDir, getProjectScanDirs, rememberLastCliInput } from './session-manager.js';
27
+ import { killWorker, suspendWorker, forkWorker, forkAdoptWorker, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience, deliverEphemeralOrReply, deliverWritableTerminalCardTo } from './worker-pool.js';
28
+ import { expandHome, getSessionWorkingDir, getProjectScanDirs, rememberLastCliInput, buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots, } from './session-manager.js';
29
29
  import { discoverSlashCommandsForAdapter, listMcpServerNames, supportsFilesystemCommandDiscovery } from './command-discovery.js';
30
30
  import { validateWorkingDir } from './working-dir.js';
31
31
  import { repinSessionWorkingDir } from './session-cwd.js';
@@ -35,8 +35,10 @@ import { listCodexAppThreads } from '../services/codex-app-threads.js';
35
35
  import { generateAuthUrl, getTokenStatus, resolveUserToken, DOC_COMMENT_OAUTH_SCOPES } from '../utils/user-token.js';
36
36
  import { DocSubscriptionPermissionError, listDocComments, resolveDocFile, subscribeDocFile, unsubscribeDocFile } from '../im/lark/doc-comment.js';
37
37
  import { parseDocWatchCommand } from './doc-watch-command.js';
38
+ import { parseVcMeetingPrepareCommand } from './vc-meeting-prepare-command.js';
38
39
  import { latestDocCommentPollCursor } from './doc-comment-poller.js';
39
40
  import { putDocSubscription, removeDocSubscription, listDocSubscriptionsForSession, listAllDocSubscriptions, getDocSubscription, } from '../services/doc-subs-store.js';
41
+ import { findVcMeetingPreparationByChat, getVcMeetingPreparation, listVcMeetingPreparations, putVcMeetingPreparation, removeVcMeetingPreparation, removeVcMeetingPreparationsByChat, } from '../services/vc-meeting-preparations-store.js';
40
42
  import { bindOncall, unbindOncall, getOncallStatus } from '../services/oncall-store.js';
41
43
  import { CONFIG_FIELDS, findConfigField, settableFieldKeys, parseBooleanValue, applyConfigField, setBotAllowedUsers, getConfigSnapshot, getConfigCardData, coerceConfigValue, } from '../services/bot-config-store.js';
42
44
  import { resolveCliId, findInvalidAllowedUserEntries } from '../setup/bot-config-editor.js';
@@ -50,7 +52,7 @@ import { invalidWorkingDirs } from '../utils/working-dir.js';
50
52
  import { writeRoleFile, deleteRoleFile, resolveRole, resolveRoleFile, resolveTeamRoleFile, writeTeamRoleFile, deleteTeamRoleFile, MAX_ROLE_BYTES } from './role-resolver.js';
51
53
  import { getBotCapability, setBotCapability, clearBotCapability } from '../services/bot-profile-store.js';
52
54
  import { deleteRoleProfileEntry, deleteRoleProfileIfEmpty, isValidRoleProfileId, listRoleProfileEntries, listRoleProfiles, MAX_ROLE_PROFILE_ENTRY_BYTES, readRoleProfileEntry, writeRoleProfileEntry, } from '../services/role-profile-store.js';
53
- import { sessionKey, sessionAnchorId } from './types.js';
55
+ import { sessionKey, sessionAnchorId, markRepoCardConsumed, claimCurrentRepoCard } from './types.js';
54
56
  import { t, localeForBot } from '../i18n/index.js';
55
57
  import { runSkillsImCommand } from './skills/im-command.js';
56
58
  import { fetchDaemonIpc } from './daemon-ipc-auth.js';
@@ -1321,7 +1323,15 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1321
1323
  break;
1322
1324
  }
1323
1325
  const resolvedPath = validation.resolvedPath;
1324
- killWorker(ds);
1326
+ // Persistent backends can cold-resume without treating a cwd switch as
1327
+ // a real session close. This also preserves a sandbox upper long enough
1328
+ // for the next worker to relocate Claude's cwd-scoped transcript.
1329
+ // Adopted sessions are observers of a user-owned CLI and keep the old
1330
+ // detach/kill behavior; PTY falls back to killWorker, while its normal
1331
+ // on-disk transcript remains available for the resume-sync preflight.
1332
+ const suspended = !ds.adoptedFrom && suspendWorker(ds, 'working_dir_changed');
1333
+ if (!suspended)
1334
+ killWorker(ds);
1325
1335
  repinSessionWorkingDir(ds, resolvedPath);
1326
1336
  if (validation.created) {
1327
1337
  await sessionReply(rootId, t('cmd.cd.created_switched', { path: resolvedPath }, loc));
@@ -1371,29 +1381,43 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1371
1381
  // CLI in whatever workingDir is currently set on the session. Shared by
1372
1382
  // `commitRepoSelection` (a repo was named) and the bare-`/repo` launch
1373
1383
  // (use the default workingDir) — both only run while `pendingRepo`.
1374
- const forkPendingCli = async (replyText) => {
1384
+ const forkPendingCli = async (replyText, claimAlready = false) => {
1385
+ if (!claimAlready) {
1386
+ if (ds.pendingRepoCommitInFlight) {
1387
+ await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1388
+ return false;
1389
+ }
1390
+ ds.pendingRepoCommitInFlight = true;
1391
+ }
1375
1392
  const selfBot = getBot(ds.larkAppId);
1376
1393
  const botCfg = selfBot.config;
1377
1394
  const commitGenSessionId = ds.session.sessionId;
1378
- ds.pendingRepo = false;
1379
- publishAttentionPatch(ds);
1380
- const pendingPrompt = ds.pendingPrompt ?? '';
1381
- const pendingRawInput = ds.pendingRawInput;
1382
- // Was there an actual buffered user message to deliver? A session
1383
- // launched *via* `/repo` (the command itself is the first message) has
1384
- // none so boot the CLI idle and let the user's NEXT message be the
1385
- // first prompt, instead of submitting an empty/boilerplate user_message.
1386
- const hasBufferedInput = pendingPrompt.trim().length > 0 ||
1387
- (ds.pendingAttachments?.length ?? 0) > 0 ||
1388
- (ds.pendingFollowUps?.length ?? 0) > 0;
1389
- if (pendingRawInput) {
1390
- // Messages buffered while the repo card was pending must not be
1391
- // dropped: wrap them now (full prompt-building context lives here)
1392
- // and stash for delivery right after the raw input on prompt_ready.
1393
- if (hasBufferedInput) {
1394
- const { buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots } = await import('./session-manager.js');
1395
+ let committed = false;
1396
+ try {
1397
+ // Keep pendingRepo=true while prompt context is awaited. Incoming
1398
+ // messages stay in the same buffer and card selections see the
1399
+ // shared in-flight claim instead of treating this as a repo switch.
1400
+ const initiallyBuffered = (ds.pendingPrompt?.trim().length ?? 0) > 0 ||
1401
+ (ds.pendingAttachments?.length ?? 0) > 0 ||
1402
+ (ds.pendingFollowUps?.length ?? 0) > 0;
1403
+ const availableBots = initiallyBuffered
1404
+ ? await getAvailableBots(ds.larkAppId, ds.chatId)
1405
+ : [];
1406
+ const stillActive = activeSessions.get(sessionKey(rootId, larkAppId)) === ds;
1407
+ if (!stillActive || ds.session.sessionId !== commitGenSessionId ||
1408
+ !ds.pendingRepo || (ds.worker && !ds.worker.killed)) {
1409
+ logger.warn(`[${logTag}] Session changed while preparing the pending CLI (${commitGenSessionId} ${ds.session.sessionId}, active=${stillActive}, pending=${!!ds.pendingRepo}, worker=${!!ds.worker})`);
1410
+ return false;
1411
+ }
1412
+ const pendingPrompt = ds.pendingPrompt ?? '';
1413
+ const pendingRawInput = ds.pendingRawInput;
1414
+ const hasBufferedInput = pendingPrompt.trim().length > 0 ||
1415
+ (ds.pendingAttachments?.length ?? 0) > 0 ||
1416
+ (ds.pendingFollowUps?.length ?? 0) > 0;
1417
+ if (hasBufferedInput)
1395
1418
  ensureSessionWhiteboard(ds);
1396
- const followUpInput = buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc, ds.pendingSender, {
1419
+ const wrappedInput = hasBufferedInput
1420
+ ? buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc, ds.pendingSender, {
1397
1421
  larkAppId,
1398
1422
  chatId: ds.chatId,
1399
1423
  whiteboardId: ds.session.whiteboardId,
@@ -1403,64 +1427,82 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1403
1427
  codexAppMessageContext: ds.pendingCodexAppMessageContext,
1404
1428
  codexAppFollowUps: ds.pendingCodexAppFollowUps,
1405
1429
  codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
1406
- });
1430
+ })
1431
+ : { content: '' };
1432
+ const pendingTurnId = ds.pendingTurnId;
1433
+ const previousPendingFollowUpInput = ds.pendingFollowUpInput;
1434
+ if (pendingRawInput && hasBufferedInput) {
1407
1435
  ds.pendingFollowUpInput = {
1408
1436
  userPrompt: ds.pendingCodexAppText !== undefined || ds.pendingCodexAppFollowUps
1409
1437
  ? [ds.pendingCodexAppText ?? '', ...(ds.pendingCodexAppFollowUps ?? [])].filter(Boolean).join('\n\n')
1410
1438
  : pendingPrompt || ds.pendingFollowUps?.join('\n\n') || '',
1411
- cliInput: followUpInput.content,
1412
- ...((ds.session.cliId ?? botCfg.cliId) === 'codex-app' && botCfg.codexAppCleanInput === true && followUpInput.codexAppInput
1413
- ? { codexAppInput: followUpInput.codexAppInput }
1439
+ cliInput: wrappedInput.content,
1440
+ ...(ds.pendingFollowUpTurnId ? { turnId: ds.pendingFollowUpTurnId } : {}),
1441
+ ...((ds.session.cliId ?? botCfg.cliId) === 'codex-app' && botCfg.codexAppCleanInput === true && wrappedInput.codexAppInput
1442
+ ? { codexAppInput: wrappedInput.codexAppInput }
1414
1443
  : {}),
1415
1444
  codexAppInputGateFrozen: true,
1416
1445
  };
1417
1446
  }
1418
- rememberLastCliInput(ds, pendingRawInput, pendingRawInput);
1419
- forkWorker(ds, '', false);
1420
- }
1421
- else if (hasBufferedInput) {
1422
- const { buildNewTopicCliInput, ensureSessionWhiteboard, getAvailableBots } = await import('./session-manager.js');
1423
- ensureSessionWhiteboard(ds);
1424
- const prompt = buildNewTopicCliInput(pendingPrompt, ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, await getAvailableBots(ds.larkAppId, ds.chatId), ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, loc, ds.pendingSender, {
1425
- larkAppId,
1426
- chatId: ds.chatId,
1427
- whiteboardId: ds.session.whiteboardId,
1428
- substituteTrigger: ds.pendingSubstituteTrigger,
1429
- codexAppText: ds.pendingCodexAppText,
1430
- codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
1431
- codexAppMessageContext: ds.pendingCodexAppMessageContext,
1432
- codexAppFollowUps: ds.pendingCodexAppFollowUps,
1433
- codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
1434
- });
1435
- // Last-line defence: prompt prep awaited above — if anything
1436
- // replaced OR closed the session in that window (`/close` deletes
1437
- // the active-map entry without touching sessionId), forking now
1438
- // would clobber it or resurrect a closed session.
1439
- const stillActive = activeSessions.get(sessionKey(rootId, larkAppId)) === ds;
1440
- if (!stillActive || ds.session.sessionId !== commitGenSessionId) {
1441
- logger.warn(`[${logTag}] Session replaced or closed while preparing the pending-CLI prompt (${commitGenSessionId} → ${ds.session.sessionId}, active=${stillActive}) — aborting this fork`);
1442
- return;
1447
+ ds.pendingRepo = false;
1448
+ publishAttentionPatch(ds);
1449
+ try {
1450
+ if (pendingRawInput)
1451
+ forkWorker(ds, '', false);
1452
+ else if (pendingTurnId && hasBufferedInput)
1453
+ forkWorker(ds, wrappedInput, { turnId: pendingTurnId });
1454
+ else if (hasBufferedInput)
1455
+ forkWorker(ds, wrappedInput);
1456
+ else
1457
+ forkWorker(ds, '', false);
1458
+ }
1459
+ catch (e) {
1460
+ ds.pendingRepo = true;
1461
+ ds.pendingFollowUpInput = previousPendingFollowUpInput;
1462
+ publishAttentionPatch(ds);
1463
+ throw e;
1464
+ }
1465
+ if (pendingRawInput || hasBufferedInput) {
1466
+ rememberLastCliInput(ds, pendingRawInput ?? pendingPrompt, pendingRawInput ?? wrappedInput);
1467
+ }
1468
+ ds.pendingPrompt = undefined;
1469
+ ds.pendingCodexAppText = undefined;
1470
+ ds.pendingCodexAppApplicationContext = undefined;
1471
+ ds.pendingCodexAppMessageContext = undefined;
1472
+ ds.pendingAttachments = undefined;
1473
+ ds.pendingMentions = undefined;
1474
+ ds.pendingSubstituteTrigger = undefined;
1475
+ ds.pendingSender = undefined;
1476
+ ds.pendingFollowUps = undefined;
1477
+ ds.pendingCodexAppFollowUps = undefined;
1478
+ ds.pendingCodexAppFollowUpContexts = undefined;
1479
+ ds.pendingFollowUpTurnId = undefined;
1480
+ ds.pendingTurnId = undefined;
1481
+ committed = true;
1482
+ // Local one-shot consume BEFORE network awaits. Feishu withdraw is
1483
+ // best-effort; stale card clicks must be rejected via this mark even
1484
+ // if sessionReply throws or deleteMessage lags/fails.
1485
+ const cardToWithdraw = ds.repoCardMessageId;
1486
+ markRepoCardConsumed(ds, cardToWithdraw);
1487
+ ds.repoCardMessageId = undefined;
1488
+ // Hold the claim through confirmation + best-effort card withdraw.
1489
+ try {
1490
+ await sessionReply(rootId, replyText);
1491
+ }
1492
+ catch (e) {
1493
+ logger.warn(`[${logTag}] Confirm reply after pending repo commit failed: ${e instanceof Error ? e.message : e}`);
1494
+ }
1495
+ if (cardToWithdraw) {
1496
+ try {
1497
+ await deleteMessage(ds.larkAppId, cardToWithdraw);
1498
+ }
1499
+ catch { /* best-effort */ }
1443
1500
  }
1444
- rememberLastCliInput(ds, pendingPrompt, prompt);
1445
- forkWorker(ds, prompt);
1446
1501
  }
1447
- else {
1448
- // Empty initial prompt → worker spawns the CLI without submitting
1449
- // anything (see worker.ts: the init prompt is only queued when truthy).
1450
- forkWorker(ds, '', false);
1502
+ finally {
1503
+ ds.pendingRepoCommitInFlight = false;
1451
1504
  }
1452
- ds.pendingPrompt = undefined;
1453
- ds.pendingCodexAppText = undefined;
1454
- ds.pendingCodexAppApplicationContext = undefined;
1455
- ds.pendingCodexAppMessageContext = undefined;
1456
- ds.pendingAttachments = undefined;
1457
- ds.pendingMentions = undefined;
1458
- ds.pendingSubstituteTrigger = undefined;
1459
- ds.pendingSender = undefined;
1460
- ds.pendingFollowUps = undefined;
1461
- ds.pendingCodexAppFollowUps = undefined;
1462
- ds.pendingCodexAppFollowUpContexts = undefined;
1463
- await sessionReply(rootId, replyText);
1505
+ return committed;
1464
1506
  };
1465
1507
  // Shared commit path for an already-resolved repo: update the session's
1466
1508
  // working dir, then either fork into the pending CLI (first spawn) or
@@ -1468,13 +1510,27 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1468
1510
  // numeric `/repo <N>` form and the `/repo <path|name>` form.
1469
1511
  const commitRepoSelection = async (selectedPath, displayName, how) => {
1470
1512
  if (ds.pendingRepo) {
1471
- // First spawn: pin the new cwd onto the CURRENT session, then fork.
1472
- ds.workingDir = selectedPath;
1473
- ds.session.workingDir = selectedPath;
1474
- // A single repo selection supersedes any stale multi-Riff stamp.
1475
- ds.session.riffRepoDirs = undefined;
1476
- sessionStore.updateSession(ds.session);
1477
- await forkPendingCli(t('cmd.repo.selected_in_pending', { name: displayName }, loc));
1513
+ if (ds.pendingRepoCommitInFlight) {
1514
+ await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1515
+ return false;
1516
+ }
1517
+ ds.pendingRepoCommitInFlight = true;
1518
+ try {
1519
+ // Claim before changing cwd so a card selection cannot overwrite
1520
+ // this choice while the pending prompt is prepared.
1521
+ ds.workingDir = selectedPath;
1522
+ ds.session.workingDir = selectedPath;
1523
+ ds.session.riffRepoDirs = undefined;
1524
+ sessionStore.updateSession(ds.session);
1525
+ // forkPendingCli owns claim release + confirm reply + card withdraw.
1526
+ if (!await forkPendingCli(t('cmd.repo.selected_in_pending', { name: displayName }, loc), true)) {
1527
+ return false;
1528
+ }
1529
+ }
1530
+ catch (e) {
1531
+ ds.pendingRepoCommitInFlight = false;
1532
+ throw e;
1533
+ }
1478
1534
  }
1479
1535
  else {
1480
1536
  // Safety net: a mid-session `/repo` switch closes the running
@@ -1488,11 +1544,15 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1488
1544
  // anchor — expected; `/close` the new one first, or use the
1489
1545
  // command.) Mirrors the `/close` case above.
1490
1546
  //
1547
+ // Claim any open repo card BEFORE killWorker / await so a concurrent
1548
+ // card click cannot double-switch while this text path runs.
1549
+ //
1491
1550
  // The new cwd is NOT written onto the old session here — it would
1492
1551
  // pollute the displaced session's stored workingDir (and the closed
1493
1552
  // card), so `claude --resume` later would reopen the old context in
1494
1553
  // the new repo's cwd. The new repo is pinned onto the fresh session
1495
1554
  // below instead.
1555
+ const claimedCard = claimCurrentRepoCard(ds, undefined);
1496
1556
  const closedCard = buildClosedSessionCard(ds, loc);
1497
1557
  killWorker(ds);
1498
1558
  sessionStore.closeSession(ds.session.sessionId);
@@ -1512,13 +1572,21 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1512
1572
  sessionStore.updateSession(ds.session);
1513
1573
  ds.hasHistory = false;
1514
1574
  forkWorker(ds, '', false);
1515
- await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc));
1516
- }
1517
- if (ds.repoCardMessageId) {
1518
- deleteMessage(ds.larkAppId, ds.repoCardMessageId);
1519
- ds.repoCardMessageId = undefined;
1575
+ try {
1576
+ await sessionReply(rootId, t('cmd.repo.switched_to', { name: displayName }, loc));
1577
+ }
1578
+ catch (e) {
1579
+ logger.warn(`[${logTag}] Confirm reply after mid-session repo switch failed: ${e instanceof Error ? e.message : e}`);
1580
+ }
1581
+ if (claimedCard) {
1582
+ try {
1583
+ await deleteMessage(ds.larkAppId, claimedCard);
1584
+ }
1585
+ catch { /* best-effort */ }
1586
+ }
1520
1587
  }
1521
1588
  logger.info(`[${logTag}] Repo selected via ${how}: ${selectedPath}`);
1589
+ return true;
1522
1590
  };
1523
1591
  // `/repo wt <N|name|path> [branch]` → create a worktree off the repo's
1524
1592
  // remote default branch and open THAT as the session repo. Without a
@@ -1553,7 +1621,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1553
1621
  }
1554
1622
  repoPath = resolved.path;
1555
1623
  }
1556
- if (ds.worktreeCreating) {
1624
+ if (ds.worktreeCreating || ds.pendingRepoCommitInFlight) {
1557
1625
  await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1558
1626
  break;
1559
1627
  }
@@ -1634,7 +1702,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1634
1702
  // would double-fork. One lock gates both kinds until the commit
1635
1703
  // settles. (Bare `/repo` without pending only posts the picker card —
1636
1704
  // harmless, so it stays open.)
1637
- if (ds?.worktreeCreating && (repoArg || ds.pendingRepo)) {
1705
+ if ((ds?.worktreeCreating || ds?.pendingRepoCommitInFlight) && (repoArg || ds?.pendingRepo)) {
1638
1706
  await sessionReply(rootId, t('cmd.repo.worktree_in_progress', undefined, loc));
1639
1707
  break;
1640
1708
  }
@@ -1680,11 +1748,10 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
1680
1748
  break;
1681
1749
  }
1682
1750
  const cwd = getSessionWorkingDir(ds);
1751
+ // bare /repo is the text twin of skip_repo: launch in the default
1752
+ // cwd without pinning it (forkPendingCli does not write workingDir).
1753
+ // Confirmation + card withdraw run under the claim inside forkPendingCli.
1683
1754
  await forkPendingCli(t('cmd.skip.opened', { cwd }, loc));
1684
- if (ds.repoCardMessageId) {
1685
- deleteMessage(ds.larkAppId, ds.repoCardMessageId);
1686
- ds.repoCardMessageId = undefined;
1687
- }
1688
1755
  logger.info(`[${logTag}] Bare /repo while pending → launch in workingDir ${cwd}`);
1689
1756
  break;
1690
1757
  }
@@ -2055,7 +2122,12 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2055
2122
  const mode = request.requestedMode
2056
2123
  ?? (botCfg.docSubscribeDefaultMode === 'all' ? 'all' : 'mention-only');
2057
2124
  const anchor = ds ? sessionAnchorId(ds) : `doc:${file.fileToken}`;
2058
- const effectiveDir = validatedDir ?? existing?.workingDir ?? botCfg.docRepoMap?.[file.fileToken];
2125
+ // Existing chat/thread sessions own their project binding. A watch
2126
+ // without an explicit --dir inherits that binding; session-less
2127
+ // document watches keep their own stored/mapped directory fallback.
2128
+ const effectiveDir = ds
2129
+ ? (validatedDir ?? ds.workingDir ?? ds.session.workingDir)
2130
+ : (validatedDir ?? existing?.workingDir ?? botCfg.docRepoMap?.[file.fileToken]);
2059
2131
  let pollCursorAt;
2060
2132
  let pollCursorReplyId;
2061
2133
  let pollBaselineReady;
@@ -2106,6 +2178,8 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2106
2178
  }, loc);
2107
2179
  if (effectiveDir)
2108
2180
  replyText += `\n📂 ${t('cmd.watch.working_dir', { dir: effectiveDir }, loc)}`;
2181
+ else
2182
+ replyText += `\n\n${t(ds ? 'cmd.watch.project_optional_session' : 'cmd.watch.project_optional_lazy', undefined, loc)}`;
2109
2183
  if (ds && deps.prewarmDocCommentSession) {
2110
2184
  try {
2111
2185
  await deps.prewarmDocCommentSession(ds, subscription);
@@ -2124,6 +2198,95 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2124
2198
  }
2125
2199
  break;
2126
2200
  }
2201
+ case '/vc': {
2202
+ if (!larkAppId) {
2203
+ await sessionReply(rootId, t('cmd.vc.no_session', undefined, loc));
2204
+ break;
2205
+ }
2206
+ const ownerOpenId = getOwnerOpenId(larkAppId);
2207
+ if (!ownerOpenId || message.senderId !== ownerOpenId) {
2208
+ await sessionReply(rootId, t('cmd.vc.owner_only', undefined, loc));
2209
+ break;
2210
+ }
2211
+ const request = parseVcMeetingPrepareCommand(message.content);
2212
+ const dataDir = config.session.dataDir;
2213
+ if (request.kind === 'usage' || request.kind === 'invalid') {
2214
+ const prefix = request.kind === 'invalid'
2215
+ ? `${t('cmd.vc.invalid', undefined, loc)}\n\n`
2216
+ : '';
2217
+ await sessionReply(rootId, prefix + t('cmd.vc.usage', undefined, loc));
2218
+ break;
2219
+ }
2220
+ if (request.kind === 'status') {
2221
+ const requestedRecord = request.meetingNo
2222
+ ? getVcMeetingPreparation(dataDir, larkAppId, request.meetingNo)
2223
+ : undefined;
2224
+ const records = request.meetingNo
2225
+ ? (requestedRecord ? [requestedRecord] : [])
2226
+ : listVcMeetingPreparations(dataDir, larkAppId);
2227
+ if (records.length === 0) {
2228
+ await sessionReply(rootId, t('cmd.vc.none', undefined, loc));
2229
+ break;
2230
+ }
2231
+ const lines = records.map(record => [
2232
+ `• ${record.topic || record.meetingNo}`,
2233
+ ` meetingNo: \`${record.meetingNo}\``,
2234
+ ` chat: \`${record.prepChatId}\``,
2235
+ ` agent: \`${record.agentAppId}\``,
2236
+ ` Q&A: ${record.qaMode}`,
2237
+ ].join('\n'));
2238
+ await sessionReply(rootId, [t('cmd.vc.status_title', undefined, loc), '', ...lines].join('\n'));
2239
+ break;
2240
+ }
2241
+ if (request.kind === 'off') {
2242
+ let count = 0;
2243
+ if (request.all) {
2244
+ for (const record of listVcMeetingPreparations(dataDir, larkAppId)) {
2245
+ if (removeVcMeetingPreparation(dataDir, larkAppId, record.meetingNo))
2246
+ count += 1;
2247
+ }
2248
+ }
2249
+ else if (request.meetingNo) {
2250
+ count = removeVcMeetingPreparation(dataDir, larkAppId, request.meetingNo) ? 1 : 0;
2251
+ }
2252
+ else if (ds) {
2253
+ count = removeVcMeetingPreparationsByChat(dataDir, larkAppId, ds.chatId);
2254
+ }
2255
+ await sessionReply(rootId, count > 0
2256
+ ? t('cmd.vc.stopped', { count }, loc)
2257
+ : t('cmd.vc.none', undefined, loc));
2258
+ break;
2259
+ }
2260
+ if (!ds || ds.chatType !== 'group' || ds.scope !== 'chat') {
2261
+ await sessionReply(rootId, t('cmd.vc.need_group_chat', undefined, loc));
2262
+ break;
2263
+ }
2264
+ const existingInChat = findVcMeetingPreparationByChat(dataDir, larkAppId, ds.chatId);
2265
+ const record = putVcMeetingPreparation(dataDir, {
2266
+ larkAppId,
2267
+ meetingNo: request.meetingNo,
2268
+ ...(request.meetingLink ? { meetingLink: request.meetingLink } : {}),
2269
+ prepChatId: ds.chatId,
2270
+ agentAppId: larkAppId,
2271
+ agentSessionId: ds.session.sessionId,
2272
+ ownerOpenId: message.senderId,
2273
+ qaMode: request.qaMode,
2274
+ });
2275
+ const replaced = existingInChat && existingInChat.meetingNo !== record.meetingNo
2276
+ ? `\n${t('cmd.vc.replaced', { meetingNo: existingInChat.meetingNo }, loc)}`
2277
+ : '';
2278
+ let preparedText = t('cmd.vc.prepared', {
2279
+ meetingNo: record.meetingNo,
2280
+ qaMode: record.qaMode,
2281
+ }, loc) + replaced;
2282
+ const meetingProjectDir = ds.workingDir ?? ds.session.workingDir;
2283
+ preparedText += meetingProjectDir
2284
+ ? `\n\n${t('cmd.vc.project_bound', { dir: meetingProjectDir }, loc)}`
2285
+ : `\n\n${t('cmd.vc.project_optional', undefined, loc)}`;
2286
+ await sessionReply(rootId, preparedText);
2287
+ logger.info(`[${logTag}] /vc prepare meetingNo=${record.meetingNo} chat=${record.prepChatId} agent=${record.agentAppId} qa=${record.qaMode}`);
2288
+ break;
2289
+ }
2127
2290
  case '/adopt': {
2128
2291
  const adoptArgs = message.content.replace(/^\/adopt\s*/i, '').trim();
2129
2292
  if (ds?.adoptedFrom) {
@@ -2470,6 +2633,11 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2470
2633
  * the @-mentioned bots, then migrate every bot's session in this
2471
2634
  * thread (including the leader's) into the new chat.
2472
2635
  *
2636
+ * p2p (私聊) variant: `/relay --create [群名]` with NO mentions — DMs
2637
+ * have no member roster so @-ing a bot is impossible there. The bot
2638
+ * itself is the sole participant and leader; the new group is user +
2639
+ * this bot, and the DM session migrates over (solo relay, no peers).
2640
+ *
2473
2641
  * Two-path command:
2474
2642
  * • `--create` (PR2) — implemented below; creates a new chat.
2475
2643
  * • no flag (PR3) — picker card listing user's relayable sessions
@@ -2552,6 +2720,29 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2552
2720
  });
2553
2721
  const targetScope = targetRouting.scope;
2554
2722
  const targetAnchor = targetRouting.anchor;
2723
+ // ── Reply WHERE the user typed /relay ─────────────────────────────
2724
+ // Not through sessionReply: in chat-scope groups (普通群扁平 /
2725
+ // chat-topic / shared) that path either leaks to the chat top level
2726
+ // (the /relay scratch has no turn state to fold back into) or lands
2727
+ // in the CURRENT turn's 话题 (a real chat-scope session) — both away
2728
+ // from the 话题 the user invoked in (申晗 live 反馈). The target
2729
+ // routing already encodes the invocation spot:
2730
+ // thread → reply_in_thread into that 话题 (for 话题群 / DM-thread
2731
+ // top-level this seeds the 话题 on the /relay message —
2732
+ // same place the relayed session will land);
2733
+ // chat → quote-reply the /relay message at the top level.
2734
+ // Fallback to sessionReply if the reply API refuses (e.g. the
2735
+ // command message was withdrawn mid-flight).
2736
+ const replyAtInvocation = async (content, msgType) => {
2737
+ try {
2738
+ await replyMessage(myAppId, targetScope === 'thread' ? targetAnchor : message.messageId, content, msgType ?? 'text',
2739
+ /*replyInThread*/ targetScope === 'thread');
2740
+ }
2741
+ catch (err) {
2742
+ logger.warn(`[${logTag}] /relay reply-at-invocation failed (${err instanceof Error ? err.message : err}); falling back to sessionReply`);
2743
+ await sessionReply(rootId, content, msgType);
2744
+ }
2745
+ };
2555
2746
  // ── Existing-session guard (anchor-based) ─────────────────────────
2556
2747
  // A real session already sitting AT the target anchor would collide
2557
2748
  // on sessionKey(targetAnchor, larkAppId) after transfer — Map.set
@@ -2566,7 +2757,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2566
2757
  && !!c.worker // real running session, not a placeholder
2567
2758
  );
2568
2759
  if (conflict) {
2569
- await sessionReply(rootId, t('cmd.relay.target_has_session', { title: conflict.session.title || conflict.session.sessionId.substring(0, 8) }, loc));
2760
+ await replyAtInvocation(t('cmd.relay.target_has_session', { title: conflict.session.title || conflict.session.sessionId.substring(0, 8) }, loc));
2570
2761
  break;
2571
2762
  }
2572
2763
  // Shared candidate-collection logic — used here at initial render
@@ -2578,7 +2769,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2578
2769
  const entries = await collectRelayPickerEntries(activeSessions, myAppId, targetAnchor, operatorOpenId);
2579
2770
  const { buildRelayPickerCard } = await import('../im/lark/card-builder.js');
2580
2771
  const card = buildRelayPickerCard(entries, targetChatId, targetAnchor, operatorOpenId, loc, undefined, targetScope, targetChatType);
2581
- await sessionReply(rootId, card, 'interactive');
2772
+ await replyAtInvocation(card, 'interactive');
2582
2773
  break;
2583
2774
  }
2584
2775
  const afterFlag = argsLine.replace(/^--create\s*/i, '').trim();
@@ -2618,30 +2809,38 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2618
2809
  await sessionReply(rootId, t('cmd.relay.not_started_yet', undefined, loc));
2619
2810
  break;
2620
2811
  }
2812
+ // ── p2p (私聊) solo relay: no mention gate, no leader election ──────
2813
+ // 飞书私聊里 @ 不到任何机器人(DM 没有成员列表),mention 门与 leader
2814
+ // 选举在这里没有意义 —— 本 bot 就是唯一参与者兼 leader,新群 = 用户 +
2815
+ // 本 bot,无 peer 协调(peerAppIds 自然为空)。群聊路径语义不变。
2816
+ // chatType 取自 ds(会话创建时从 Lark 事件记录,权威、不漂移)。
2817
+ const sourceIsP2p = ds.chatType === 'p2p';
2621
2818
  // ── Mention parsing & leader election (mirror of /group) ───────────
2622
2819
  const mentions = message.mentions ?? [];
2623
2820
  const knownBotNames = globalKnownBotNames();
2624
- if (knownBotNames.size === 0 && mentions.some(m => !!m.name)) {
2625
- logger.warn(`[${logTag}] /relay --create: global bot registry empty; cannot elect a creator`);
2626
- await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
2627
- break;
2628
- }
2629
- const botMentions = mentions.filter(m => m.name && knownBotNames.has(m.name.toLowerCase()));
2630
- if (botMentions.length === 0) {
2631
- await sessionReply(rootId, t('cmd.relay.no_mentions', undefined, loc));
2632
- break;
2633
- }
2634
- // Am I `mentions[0]`?
2635
- const firstBot = botMentions[0];
2636
- const myOpenId = getBotOpenId(creatorAppId);
2637
- const myName = getBot(creatorAppId).botName?.toLowerCase();
2638
- const myNameAmbiguous = !!myName
2639
- && botMentions.filter(m => m.name?.toLowerCase() === myName).length > 1;
2640
- const iAmFirstBot = (!!myOpenId && firstBot.openId === myOpenId) ||
2641
- (!myOpenId && !!myName && !myNameAmbiguous && firstBot.name?.toLowerCase() === myName);
2642
- if (!iAmFirstBot) {
2643
- logger.info(`[${logTag}] /relay --create: not the first @-mentioned bot, staying silent`);
2644
- break;
2821
+ const botMentions = sourceIsP2p ? [] : mentions.filter(m => m.name && knownBotNames.has(m.name.toLowerCase()));
2822
+ if (!sourceIsP2p) {
2823
+ if (knownBotNames.size === 0 && mentions.some(m => !!m.name)) {
2824
+ logger.warn(`[${logTag}] /relay --create: global bot registry empty; cannot elect a creator`);
2825
+ await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
2826
+ break;
2827
+ }
2828
+ if (botMentions.length === 0) {
2829
+ await sessionReply(rootId, t('cmd.relay.no_mentions', undefined, loc));
2830
+ break;
2831
+ }
2832
+ // Am I `mentions[0]`?
2833
+ const firstBot = botMentions[0];
2834
+ const myOpenId = getBotOpenId(creatorAppId);
2835
+ const myName = getBot(creatorAppId).botName?.toLowerCase();
2836
+ const myNameAmbiguous = !!myName
2837
+ && botMentions.filter(m => m.name?.toLowerCase() === myName).length > 1;
2838
+ const iAmFirstBot = (!!myOpenId && firstBot.openId === myOpenId) ||
2839
+ (!myOpenId && !!myName && !myNameAmbiguous && firstBot.name?.toLowerCase() === myName);
2840
+ if (!iAmFirstBot) {
2841
+ logger.info(`[${logTag}] /relay --create: not the first @-mentioned bot, staying silent`);
2842
+ break;
2843
+ }
2645
2844
  }
2646
2845
  // Owner-only — only the source session owner may relay this session.
2647
2846
  if (ds.session.ownerOpenId && ds.session.ownerOpenId !== senderOpenId) {
@@ -2649,38 +2848,45 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2649
2848
  break;
2650
2849
  }
2651
2850
  // ── Resolve @-bots to larkAppIds via the source chat's bot roster ──
2851
+ // p2p: 跳过成员表解析(DM 没有 bot roster,listChatBotMembers 会失败),
2852
+ // 参与者就是本 bot 自己;名字兜底走 botDisplayName(nameOf)。
2652
2853
  const sourceChatId = ds.chatId;
2653
- let members = [];
2654
- try {
2655
- members = await listChatBotMembers(creatorAppId, sourceChatId);
2656
- }
2657
- catch (e) {
2658
- logger.warn(`[${logTag}] /relay --create: failed to list source chat members: ${e?.message ?? e}`);
2659
- }
2660
- const memberByOpenId = new Map(members.map(m => [m.openId, m]));
2661
2854
  const appIdToName = new Map();
2662
- for (const m of members) {
2663
- if (m.larkAppId && m.displayName)
2664
- appIdToName.set(m.larkAppId, m.displayName);
2665
- }
2666
2855
  const mentionedBotAppIds = [];
2667
- const seenApp = new Set();
2668
- let unresolved;
2669
- for (const bm of botMentions) {
2670
- const mem = bm.openId ? memberByOpenId.get(bm.openId) : undefined;
2671
- if (!mem || !mem.larkAppId) {
2672
- unresolved = bm.name;
2673
- break;
2856
+ if (sourceIsP2p) {
2857
+ mentionedBotAppIds.push(creatorAppId);
2858
+ }
2859
+ else {
2860
+ let members = [];
2861
+ try {
2862
+ members = await listChatBotMembers(creatorAppId, sourceChatId);
2674
2863
  }
2675
- if (!seenApp.has(mem.larkAppId)) {
2676
- seenApp.add(mem.larkAppId);
2677
- mentionedBotAppIds.push(mem.larkAppId);
2864
+ catch (e) {
2865
+ logger.warn(`[${logTag}] /relay --create: failed to list source chat members: ${e?.message ?? e}`);
2866
+ }
2867
+ const memberByOpenId = new Map(members.map(m => [m.openId, m]));
2868
+ for (const m of members) {
2869
+ if (m.larkAppId && m.displayName)
2870
+ appIdToName.set(m.larkAppId, m.displayName);
2871
+ }
2872
+ const seenApp = new Set();
2873
+ let unresolved;
2874
+ for (const bm of botMentions) {
2875
+ const mem = bm.openId ? memberByOpenId.get(bm.openId) : undefined;
2876
+ if (!mem || !mem.larkAppId) {
2877
+ unresolved = bm.name;
2878
+ break;
2879
+ }
2880
+ if (!seenApp.has(mem.larkAppId)) {
2881
+ seenApp.add(mem.larkAppId);
2882
+ mentionedBotAppIds.push(mem.larkAppId);
2883
+ }
2884
+ }
2885
+ if (unresolved) {
2886
+ logger.warn(`[${logTag}] /relay --create: unresolved bot "${unresolved}"`);
2887
+ await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
2888
+ break;
2678
2889
  }
2679
- }
2680
- if (unresolved) {
2681
- logger.warn(`[${logTag}] /relay --create: unresolved bot "${unresolved}"`);
2682
- await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
2683
- break;
2684
2890
  }
2685
2891
  // ── Group name extraction (mirror of /group) ───────────────────────
2686
2892
  let rawArgs = afterFlag;
@@ -2742,10 +2948,14 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
2742
2948
  const placeholderRootMessageId = newChatId;
2743
2949
  // Resolve friendly source-chat label for the M1 body — falls back to
2744
2950
  // raw chatId if Lark can't return a name. Mirrors picker-path
2745
- // (card-handler.ts:341) so the message reads the same in both UX
2746
- // entry points.
2951
+ // (card-handler.ts relay_confirm) so the message reads the same in
2952
+ // both UX entry points; p2p source has no chat name (chat.get often
2953
+ // fails/returns empty for DMs) — use the locale-aware 单聊 label
2954
+ // instead of leaking a raw oc_ id into the M1.
2747
2955
  const { getChatName } = await import('../im/lark/client.js');
2748
- const sourceLabel = (await getChatName(creatorAppId, sourceChatId).catch(() => null)) ?? sourceChatId;
2956
+ const sourceLabel = sourceIsP2p
2957
+ ? t('card.relay.type_p2p', undefined, loc)
2958
+ : (await getChatName(creatorAppId, sourceChatId).catch(() => null)) ?? sourceChatId;
2749
2959
  // ── Step 1: leader transfers its own session (if any) ───────────────
2750
2960
  // Empty-leader handling: daemon auto-creates a placeholder ds for any
2751
2961
  // DAEMON_COMMAND (worker:null + hasHistory:false). If the user typed
@@ -3009,6 +3219,7 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
3009
3219
  t('help.land', undefined, loc),
3010
3220
  t('help.subscribe_doc', undefined, loc),
3011
3221
  t('help.watch_comment', undefined, loc),
3222
+ t('help.vc', undefined, loc),
3012
3223
  t('help.summary', undefined, loc),
3013
3224
  '',
3014
3225
  t('help.heading_passthrough', { cliName }, loc),