botmux 2.38.1 → 2.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/adapters/adopt-route.d.ts +63 -0
  2. package/dist/adapters/adopt-route.d.ts.map +1 -0
  3. package/dist/adapters/adopt-route.js +195 -0
  4. package/dist/adapters/adopt-route.js.map +1 -0
  5. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  6. package/dist/adapters/backend/tmux-backend.js +8 -0
  7. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  8. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  9. package/dist/adapters/cli/claude-code.js +14 -7
  10. package/dist/adapters/cli/claude-code.js.map +1 -1
  11. package/dist/adapters/cli/opencode.d.ts.map +1 -1
  12. package/dist/adapters/cli/opencode.js +7 -0
  13. package/dist/adapters/cli/opencode.js.map +1 -1
  14. package/dist/adapters/cli/types.d.ts +11 -0
  15. package/dist/adapters/cli/types.d.ts.map +1 -1
  16. package/dist/adapters/hook-command.d.ts +18 -0
  17. package/dist/adapters/hook-command.d.ts.map +1 -0
  18. package/dist/adapters/hook-command.js +38 -0
  19. package/dist/adapters/hook-command.js.map +1 -0
  20. package/dist/adapters/hook-installer.d.ts +14 -0
  21. package/dist/adapters/hook-installer.d.ts.map +1 -0
  22. package/dist/adapters/hook-installer.js +172 -0
  23. package/dist/adapters/hook-installer.js.map +1 -0
  24. package/dist/bot-registry.d.ts +17 -0
  25. package/dist/bot-registry.d.ts.map +1 -1
  26. package/dist/bot-registry.js +52 -0
  27. package/dist/bot-registry.js.map +1 -1
  28. package/dist/cli.d.ts +15 -1
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +369 -57
  31. package/dist/cli.js.map +1 -1
  32. package/dist/core/ask-api.d.ts +47 -0
  33. package/dist/core/ask-api.d.ts.map +1 -0
  34. package/dist/core/ask-api.js +139 -0
  35. package/dist/core/ask-api.js.map +1 -0
  36. package/dist/core/ask-args.d.ts +53 -0
  37. package/dist/core/ask-args.d.ts.map +1 -0
  38. package/dist/core/ask-args.js +122 -0
  39. package/dist/core/ask-args.js.map +1 -0
  40. package/dist/core/ask-broker.d.ts +98 -0
  41. package/dist/core/ask-broker.d.ts.map +1 -0
  42. package/dist/core/ask-broker.js +329 -0
  43. package/dist/core/ask-broker.js.map +1 -0
  44. package/dist/core/ask-hook/claude-code.d.ts +50 -0
  45. package/dist/core/ask-hook/claude-code.d.ts.map +1 -0
  46. package/dist/core/ask-hook/claude-code.js +145 -0
  47. package/dist/core/ask-hook/claude-code.js.map +1 -0
  48. package/dist/core/ask-hook/codex.d.ts +43 -0
  49. package/dist/core/ask-hook/codex.d.ts.map +1 -0
  50. package/dist/core/ask-hook/codex.js +69 -0
  51. package/dist/core/ask-hook/codex.js.map +1 -0
  52. package/dist/core/ask-hook/opencode.d.ts +41 -0
  53. package/dist/core/ask-hook/opencode.d.ts.map +1 -0
  54. package/dist/core/ask-hook/opencode.js +108 -0
  55. package/dist/core/ask-hook/opencode.js.map +1 -0
  56. package/dist/core/ask-hook/registry.d.ts +3 -0
  57. package/dist/core/ask-hook/registry.d.ts.map +1 -0
  58. package/dist/core/ask-hook/registry.js +12 -0
  59. package/dist/core/ask-hook/registry.js.map +1 -0
  60. package/dist/core/ask-hook/types.d.ts +26 -0
  61. package/dist/core/ask-hook/types.d.ts.map +1 -0
  62. package/dist/core/ask-hook/types.js +2 -0
  63. package/dist/core/ask-hook/types.js.map +1 -0
  64. package/dist/core/ask-types.d.ts +146 -0
  65. package/dist/core/ask-types.d.ts.map +1 -0
  66. package/dist/core/ask-types.js +18 -0
  67. package/dist/core/ask-types.js.map +1 -0
  68. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  69. package/dist/core/dashboard-ipc-server.js +21 -0
  70. package/dist/core/dashboard-ipc-server.js.map +1 -1
  71. package/dist/core/worker-pool.d.ts.map +1 -1
  72. package/dist/core/worker-pool.js +22 -3
  73. package/dist/core/worker-pool.js.map +1 -1
  74. package/dist/daemon.d.ts.map +1 -1
  75. package/dist/daemon.js +92 -1
  76. package/dist/daemon.js.map +1 -1
  77. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  78. package/dist/dashboard/web/bot-defaults.js +80 -0
  79. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  80. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  81. package/dist/dashboard/web/i18n.js +16 -0
  82. package/dist/dashboard/web/i18n.js.map +1 -1
  83. package/dist/dashboard-web/app.js +195 -180
  84. package/dist/dashboard.js +19 -0
  85. package/dist/dashboard.js.map +1 -1
  86. package/dist/im/lark/ask-card.d.ts +55 -0
  87. package/dist/im/lark/ask-card.d.ts.map +1 -0
  88. package/dist/im/lark/ask-card.js +328 -0
  89. package/dist/im/lark/ask-card.js.map +1 -0
  90. package/dist/im/lark/card-handler.d.ts.map +1 -1
  91. package/dist/im/lark/card-handler.js +4 -0
  92. package/dist/im/lark/card-handler.js.map +1 -1
  93. package/dist/im/lark/md-card.d.ts +20 -2
  94. package/dist/im/lark/md-card.d.ts.map +1 -1
  95. package/dist/im/lark/md-card.js +49 -17
  96. package/dist/im/lark/md-card.js.map +1 -1
  97. package/dist/im/lark/message-parser.js +12 -0
  98. package/dist/im/lark/message-parser.js.map +1 -1
  99. package/dist/services/brand-store.d.ts +15 -0
  100. package/dist/services/brand-store.d.ts.map +1 -0
  101. package/dist/services/brand-store.js +47 -0
  102. package/dist/services/brand-store.js.map +1 -0
  103. package/dist/skills/definitions.d.ts +2 -0
  104. package/dist/skills/definitions.d.ts.map +1 -1
  105. package/dist/skills/definitions.js +67 -0
  106. package/dist/skills/definitions.js.map +1 -1
  107. package/dist/skills/installer.d.ts +11 -0
  108. package/dist/skills/installer.d.ts.map +1 -1
  109. package/dist/skills/installer.js +35 -1
  110. package/dist/skills/installer.js.map +1 -1
  111. package/dist/utils/bot-routing.d.ts +19 -0
  112. package/dist/utils/bot-routing.d.ts.map +1 -1
  113. package/dist/utils/bot-routing.js +29 -0
  114. package/dist/utils/bot-routing.js.map +1 -1
  115. package/dist/worker.js +7 -0
  116. package/dist/worker.js.map +1 -1
  117. package/dist/workflows/events/payloads.d.ts +2 -2
  118. package/dist/workflows/events/schema.d.ts +8 -8
  119. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -33,7 +33,7 @@ import { logger } from './utils/logger.js';
33
33
  import { invalidWorkingDirs } from './utils/working-dir.js';
34
34
  import { firstPositional } from './cli/arg-utils.js';
35
35
  import { formatBotInfoEntriesForCli, formatChatBotsForCli, } from './cli/bots-list-output.js';
36
- import { buildFooterAddressing, hasKnownBotMention, knownBotOpenIdsFromCrossRef, } from './utils/bot-routing.js';
36
+ import { buildFooterAddressing, hasKnownBotMention, knownBotOpenIdsFromCrossRef, orderedFooterRecipients, } from './utils/bot-routing.js';
37
37
  import { isLocale, setDefaultLocale, SUPPORTED_LOCALES } from './i18n/index.js';
38
38
  import { readGlobalConfig, setGlobalLocale, globalConfigPath } from './global-config.js';
39
39
  // Resolve the CLI's UI locale once from the global config file, so subsequent
@@ -2464,7 +2464,8 @@ function argValues(args, ...flags) {
2464
2464
  // Card v2 body builder helpers — extracted to im/lark/md-card.ts so the
2465
2465
  // daemon's bridge fallback path can produce identical cards. cmdSend
2466
2466
  // keeps using `buildCardBodyElements` and `hasMarkdown` from there.
2467
- import { buildCardBodyElements, hasMarkdown } from './im/lark/md-card.js';
2467
+ import { buildCardBodyElements, hasMarkdown, brandFooterSegment } from './im/lark/md-card.js';
2468
+ import { resolveBrandLabel } from './bot-registry.js';
2468
2469
  import { config } from './config.js';
2469
2470
  import { resolveQuoteTarget, validateMentionDecision } from './services/send-policy.js';
2470
2471
  async function cmdSend(rest) {
@@ -2731,22 +2732,13 @@ async function cmdSend(rest) {
2731
2732
  // --no-mention 显式不 @ 任何人 → 连 footer 的"发送给/cc"寻址 <at> 也清空,
2732
2733
  // 否则 footer 仍会 @ 人,与 --no-mention 语义和"未@任何人"输出自相矛盾
2733
2734
  // (Codex review P2)。--top-level 同样无特定收件人。
2734
- const footerAddressingRaw = (sendTopLevel || noMention)
2735
+ const footerAddressing = (sendTopLevel || noMention)
2735
2736
  ? { sendTo: undefined, cc: [] }
2736
2737
  : buildFooterAddressing(s, {
2737
2738
  isOncall: !!oncallEntry,
2738
2739
  hasExplicitBotMention: explicitKnownBotMention,
2739
2740
  knownBotOpenIds,
2740
2741
  });
2741
- // De-dupe vs body @: if someone is already @'d in the body (典型是
2742
- // --mention-back @ 了触发者,而 footer 的"发送给"又指向同一个 owner/caller),
2743
- // 从 footer 去掉,避免一条消息里出现两个相同的 @。
2744
- const bodyMentionIds = new Set(mentions.map(m => m.open_id));
2745
- const footerAddressing = {
2746
- sendTo: footerAddressingRaw.sendTo && !bodyMentionIds.has(footerAddressingRaw.sendTo)
2747
- ? footerAddressingRaw.sendTo : undefined,
2748
- cc: footerAddressingRaw.cc.filter(id => !bodyMentionIds.has(id)),
2749
- };
2750
2742
  // Decide: interactive card (renders markdown) vs. post (plain text).
2751
2743
  // Explicit --card / --text wins; otherwise auto-detect markdown syntax.
2752
2744
  const useCard = forceCard || (!forceText && hasMarkdown(text));
@@ -2781,12 +2773,9 @@ async function cmdSend(rest) {
2781
2773
  return `<at id=${openId}></at>`;
2782
2774
  });
2783
2775
  }
2784
- const trailingAts = [];
2785
- for (const m of mentions)
2786
- if (!usedIds.has(m.open_id))
2787
- trailingAts.push(`<at id=${m.open_id}></at>`);
2788
- if (trailingAts.length > 0)
2789
- md = md ? `${md}\n\n${trailingAts.join(' ')}` : trailingAts.join(' ');
2776
+ // Non-inlined mentions are no longer dangled as a trailing @ block at the
2777
+ // body bottom they're consolidated onto the footer `发送给:` line below
2778
+ // (human addressee first, then explicit targets). See orderedFooterRecipients.
2790
2779
  // Inline images into the markdown via ![](img_key). If caller used an
2791
2780
  // `![alt](img:N)` placeholder, substitute by 0-based index; any remaining
2792
2781
  // images get appended at the end so they flow with the text.
@@ -2813,21 +2802,34 @@ async function cmdSend(rest) {
2813
2802
  // Oncall groups usually address whoever triggered this turn (may not be
2814
2803
  // the session owner). Bot recipients are filtered out so footer chrome
2815
2804
  // cannot accidentally wake a sibling bot.
2816
- const footerParts = ['[botmux](https://github.com/deepcoldy/botmux)'];
2817
- // Top-level publish has no specific recipient drop "发送给/cc" addressing
2818
- // so the message doesn't @ the session owner who isn't even in the target chat.
2819
- const addressing = footerAddressing;
2820
- if (addressing.sendTo)
2821
- footerParts.push(`发送给:<at id=${addressing.sendTo}></at>`);
2822
- if (addressing.cc.length > 0) {
2823
- footerParts.push(`cc:${addressing.cc.map(id => `<at id=${id}></at>`).join(' ')}`);
2824
- }
2825
- elements.push({ tag: 'hr' });
2826
- elements.push({
2827
- tag: 'markdown',
2828
- text_size: 'notation_small_v2',
2829
- content: `<font color='grey'>${footerParts.join(' · ')}</font>`,
2805
+ // Brand segment honours this bot's configured brandLabel (unset →
2806
+ // default botmux, '' suppressed, else custom). Same resolver/rule as
2807
+ // the daemon's card builders so both send paths render identically.
2808
+ const footerParts = [];
2809
+ const brandSeg = brandFooterSegment(resolveBrandLabel(appId));
2810
+ if (brandSeg)
2811
+ footerParts.push(brandSeg);
2812
+ // All real mentions land on one footer line: human addressee first, then
2813
+ // explicit @ targets (incl. handoff bots), then cc. Ids already inlined in
2814
+ // the body prose are skipped. Top-level publish keeps sendTo empty.
2815
+ const footerRecipients = orderedFooterRecipients({
2816
+ sendTo: footerAddressing.sendTo,
2817
+ mentionIds: mentions.map(m => m.open_id),
2818
+ cc: footerAddressing.cc,
2819
+ inlinedIds: usedIds,
2830
2820
  });
2821
+ if (footerRecipients.length > 0) {
2822
+ footerParts.push(`发送给:${footerRecipients.map(id => `<at id=${id}></at>`).join(' ')}`);
2823
+ }
2824
+ // Empty brand + no recipients → no footer at all (skip the orphan HR).
2825
+ if (footerParts.length > 0) {
2826
+ elements.push({ tag: 'hr' });
2827
+ elements.push({
2828
+ tag: 'markdown',
2829
+ text_size: 'notation_small_v2',
2830
+ content: `<font color='grey'>${footerParts.join(' · ')}</font>`,
2831
+ });
2832
+ }
2831
2833
  const cardJson = JSON.stringify({
2832
2834
  schema: '2.0',
2833
2835
  config: { update_multi: true },
@@ -2857,33 +2859,28 @@ async function cmdSend(rest) {
2857
2859
  }) : [];
2858
2860
  for (const key of imageKeys)
2859
2861
  postContent.push([{ tag: 'img', image_key: key }]);
2860
- if (mentions.length > 0) {
2861
- const usedIds = new Set();
2862
- for (const para of postContent)
2863
- for (const n of para)
2864
- if (n.tag === 'at')
2865
- usedIds.add(n.user_id);
2866
- const unused = mentions.filter(m => !usedIds.has(m.open_id));
2867
- if (unused.length > 0) {
2868
- if (postContent.length === 0)
2869
- postContent.push([]);
2870
- for (const m of unused)
2871
- postContent[postContent.length - 1].push({ tag: 'at', user_id: m.open_id });
2872
- }
2873
- }
2874
- // Footer: mirror the card layout — a blank paragraph separates the body
2875
- // from the addressing line(s). Top-level publish has no specific
2876
- // recipient; bot recipients are filtered out by footerAddressing.
2877
- const addressing = footerAddressing;
2878
- if (addressing.sendTo || addressing.cc.length > 0) {
2862
+ // Footer: mirror the card layout — all real mentions go on one
2863
+ // `发送给:` line (human addressee first, then explicit targets, then cc),
2864
+ // separated from the body by a blank paragraph. Ids already inlined in the
2865
+ // body prose are skipped. Top-level publish keeps sendTo empty.
2866
+ const inlinedIds = new Set();
2867
+ for (const para of postContent)
2868
+ for (const n of para)
2869
+ if (n.tag === 'at')
2870
+ inlinedIds.add(n.user_id);
2871
+ const footerRecipients = orderedFooterRecipients({
2872
+ sendTo: footerAddressing.sendTo,
2873
+ mentionIds: mentions.map(m => m.open_id),
2874
+ cc: footerAddressing.cc,
2875
+ inlinedIds,
2876
+ });
2877
+ if (footerRecipients.length > 0) {
2879
2878
  if (postContent.length > 0)
2880
2879
  postContent.push([{ tag: 'text', text: '' }]);
2881
- if (addressing.sendTo) {
2882
- postContent.push([{ tag: 'text', text: '发送给:' }, { tag: 'at', user_id: addressing.sendTo }]);
2883
- }
2884
- if (addressing.cc.length > 0) {
2885
- postContent.push([{ tag: 'text', text: 'cc:' }, ...addressing.cc.map(id => ({ tag: 'at', user_id: id }))]);
2886
- }
2880
+ postContent.push([
2881
+ { tag: 'text', text: '发送给:' },
2882
+ ...footerRecipients.map(id => ({ tag: 'at', user_id: id })),
2883
+ ]);
2887
2884
  }
2888
2885
  const postJson = JSON.stringify({ zh_cn: { title: '', content: postContent } });
2889
2886
  messageId = await dispatchPrimary(postJson, 'post');
@@ -3109,6 +3106,307 @@ botmux create-group — 用一组机器人新建飞书群
3109
3106
  }
3110
3107
  }
3111
3108
  // ─── Bots subcommand ─────────────────────────────────────────────────────────
3109
+ // ─── botmux ask v0.1.7 ───────────────────────────────────────────────────────
3110
+ //
3111
+ // CLI agent inside a botmux-spawned session calls `botmux ask buttons
3112
+ // --options "..." "<prompt>"`. Daemon sends a Lark card; user clicks; CLI
3113
+ // process unblocks with the selected key (or exit 124 on timeout, exit 3 if
3114
+ // the daemon dies). See /tmp/botmux-ask.md (or design memory).
3115
+ /**
3116
+ * postAsk: 找到 daemon → POST /api/asks → 返回 AskResult。
3117
+ * 连接失败 / HTTP 错误时抛出带 exitCode 属性的 Error:
3118
+ * - exitCode=3:daemon 不可达或 HTTP 非 400
3119
+ * - exitCode=2:400 + no_approvers
3120
+ */
3121
+ async function postAsk(body) {
3122
+ const larkAppId = body.larkAppId;
3123
+ const daemon = findDaemon(larkAppId);
3124
+ if (!daemon) {
3125
+ const err = new Error(`botmux ask: 找不到 daemon (larkAppId=${larkAppId})。daemon 已停?exit 3.`);
3126
+ err.exitCode = 3;
3127
+ throw err;
3128
+ }
3129
+ let res;
3130
+ try {
3131
+ res = await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/asks`, {
3132
+ method: 'POST',
3133
+ headers: { 'content-type': 'application/json' },
3134
+ body: JSON.stringify(body),
3135
+ // No client-side timeout — broker enforces `timeoutMs` and will respond
3136
+ // with `kind:'timedOut'` so this fetch always settles.
3137
+ });
3138
+ }
3139
+ catch (fetchErr) {
3140
+ const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
3141
+ const err = new Error(`botmux ask: 无法连接 daemon (port=${daemon.ipcPort}): ${msg}`);
3142
+ err.exitCode = 3;
3143
+ throw err;
3144
+ }
3145
+ if (!res.ok) {
3146
+ let errBody = '';
3147
+ try {
3148
+ errBody = (await res.text()).slice(0, 200);
3149
+ }
3150
+ catch { /* */ }
3151
+ if (res.status === 400 && /no_approvers/.test(errBody)) {
3152
+ const err = new Error('botmux ask: 当前会话没有可批准者(session.owner 不在 bot.allowedUsers 里,且 --approver 未指定)');
3153
+ err.exitCode = 2;
3154
+ throw err;
3155
+ }
3156
+ const err = new Error(`botmux ask: daemon HTTP ${res.status}: ${errBody}`);
3157
+ err.exitCode = 3;
3158
+ throw err;
3159
+ }
3160
+ try {
3161
+ return (await res.json());
3162
+ }
3163
+ catch (jsonErr) {
3164
+ const err = new Error(`botmux ask: daemon 返回非 JSON: ${jsonErr}`);
3165
+ err.exitCode = 3;
3166
+ throw err;
3167
+ }
3168
+ }
3169
+ async function cmdAsk(sub, rest) {
3170
+ // Workflow-subagent safety gate (same posture as cmdSend): a CLI running
3171
+ // inside a workflow subagent (Slice F) must not surface chat UI. Workflow
3172
+ // approvals belong in humanGate / decision nodes so the choice is part of
3173
+ // the run's event log; an ad-hoc `botmux ask` would bypass that audit
3174
+ // trail entirely.
3175
+ if (process.env.BOTMUX_WORKFLOW === '1') {
3176
+ const runId = process.env.BOTMUX_WORKFLOW_RUN_ID ?? '?';
3177
+ const nodeId = process.env.BOTMUX_WORKFLOW_NODE_ID ?? '?';
3178
+ console.error(`botmux ask refused inside workflow subagent (run=${runId} node=${nodeId}).\n` +
3179
+ `Workflow subagents must surface approvals via humanGate / decision nodes\n` +
3180
+ `so the resolution is recorded in the run's event log; ask would bypass it.`);
3181
+ process.exit(2);
3182
+ }
3183
+ // Only `buttons` shipped in v0.1.7. The bare alias (`botmux ask --options`)
3184
+ // routes here with sub='' — accept it and behave identically. `ask text` /
3185
+ // `ask confirm` are reserved for later versions.
3186
+ if (sub && sub !== 'buttons') {
3187
+ console.error(`botmux ask: 未知 subcommand "${sub}"(v0.1.7 仅支持 \`buttons\` 或省略)`);
3188
+ process.exit(2);
3189
+ }
3190
+ const { findMissingAskEnv, parseAskOptions, parseAskTimeoutSeconds, AskArgsError } = await import('./core/ask-args.js');
3191
+ const { toLegacySelected } = await import('./core/ask-types.js');
3192
+ const missing = findMissingAskEnv(process.env);
3193
+ if (missing) {
3194
+ console.error(`botmux ask: 缺少必需环境变量 ${missing}。` +
3195
+ ` 请在 botmux daemon spawn 的 CLI 会话内运行。`);
3196
+ process.exit(2);
3197
+ }
3198
+ const optionsRaw = argValue(rest, '--options');
3199
+ const timeoutRaw = argValue(rest, '--timeout');
3200
+ const useJson = rest.includes('--json');
3201
+ const approverArgs = argValues(rest, '--approver');
3202
+ const positionalArgs = positionals(rest, ['--json']);
3203
+ let options;
3204
+ let timeoutMs;
3205
+ try {
3206
+ options = parseAskOptions(optionsRaw);
3207
+ timeoutMs = parseAskTimeoutSeconds(timeoutRaw);
3208
+ }
3209
+ catch (err) {
3210
+ if (err instanceof AskArgsError) {
3211
+ console.error(`botmux ask: ${err.message}`);
3212
+ process.exit(2);
3213
+ }
3214
+ throw err;
3215
+ }
3216
+ const prompt = positionalArgs.join(' ').trim();
3217
+ if (!prompt) {
3218
+ console.error('botmux ask: 缺少 prompt。用法: botmux ask buttons --options "yes,no" "继续发版吗?"');
3219
+ process.exit(2);
3220
+ }
3221
+ const larkAppId = process.env.BOTMUX_LARK_APP_ID;
3222
+ const body = {
3223
+ sessionId: process.env.BOTMUX_SESSION_ID,
3224
+ chatId: process.env.BOTMUX_CHAT_ID,
3225
+ larkAppId,
3226
+ rootMessageId: process.env.BOTMUX_ROOT_MESSAGE_ID || null,
3227
+ options,
3228
+ prompt,
3229
+ timeoutMs,
3230
+ approvers: approverArgs,
3231
+ };
3232
+ let result;
3233
+ try {
3234
+ result = await postAsk(body);
3235
+ }
3236
+ catch (err) {
3237
+ const code = err.exitCode ?? 3;
3238
+ console.error(err.message);
3239
+ process.exit(code);
3240
+ }
3241
+ // result.kind==='answered' 时用 toLegacySelected 取回旧的 string(单问单选)
3242
+ const selected = toLegacySelected(result);
3243
+ if (useJson) {
3244
+ const out = {
3245
+ selected,
3246
+ answers: result.kind === 'answered' ? result.answers : null,
3247
+ by: result.kind === 'answered' ? result.by : null,
3248
+ comment: null,
3249
+ timedOut: result.kind === 'timedOut',
3250
+ };
3251
+ process.stdout.write(JSON.stringify(out) + '\n');
3252
+ }
3253
+ else if (result.kind === 'answered') {
3254
+ // 非 JSON 模式:输出 selected key(单问单选),多选/多问输出空字符串
3255
+ process.stdout.write((selected ?? '') + '\n');
3256
+ }
3257
+ switch (result.kind) {
3258
+ case 'answered':
3259
+ process.exit(0);
3260
+ case 'timedOut':
3261
+ console.error(`botmux ask: 超时(${timeoutMs / 1000}s),无回复`);
3262
+ process.exit(124);
3263
+ case 'invalidated':
3264
+ console.error(`botmux ask: 已失效 (${result.reason})`);
3265
+ process.exit(3);
3266
+ }
3267
+ }
3268
+ // ─── botmux hook <cliId> ──────────────────────────────────────────────────────
3269
+ //
3270
+ // hook 模式:各 CLI hook 配置调用 `botmux hook <cliId>`,stdin 注入 hook payload,
3271
+ // 本命令解析问题 → POST /api/asks → 等结果 → 写 directive 到 stdout。
3272
+ // 任何失败(daemon 不可达、env 缺失、解析错误)均输出 passthrough directive 并 exit 0,
3273
+ // 绝不挂死,保证 CLI 可以继续原生终端提问。
3274
+ /**
3275
+ * runHook: hook 命令的纯业务逻辑,接受已解析的 payload/env/postAskFn,
3276
+ * 返回应写到 stdout 的字符串。通过依赖注入使单元测试无需真实 daemon/env。
3277
+ *
3278
+ * @param payload 已经 JSON.parse 的 hook payload 对象
3279
+ * @param env 包含 BOTMUX_* 环境变量的字典
3280
+ * @param postAskFn 替代真实 postAsk 的可注入函数(测试用)
3281
+ * @param cliId CLI 适配器 ID
3282
+ * @param resolveAdoptRouteFn 可选:替代真实 adopt 路由解析的注入函数(测试用);
3283
+ * 缺省时使用真实 resolveAdoptRoute(查祖先 PID → daemon)
3284
+ * @returns { stdout: string } 应写到 stdout 的内容
3285
+ */
3286
+ export async function runHook(payload, env, postAskFn, cliId, resolveAdoptRouteFn) {
3287
+ const { getHookAdapter } = await import('./core/ask-hook/registry.js');
3288
+ // 未知 cliId → 无 adapter,输出空字符串静默放行
3289
+ const adapter = getHookAdapter(cliId);
3290
+ if (!adapter) {
3291
+ return { stdout: '' };
3292
+ }
3293
+ // Workflow-subagent 安全门:workflow 子 agent 内直接 passthrough
3294
+ if (env.BOTMUX_WORKFLOW === '1') {
3295
+ return { stdout: adapter.passthrough(payload) };
3296
+ }
3297
+ // 解析问题:非 askUserQuestion 类事件 → passthrough 放行
3298
+ const parsed = adapter.parseQuestions(payload);
3299
+ if (!parsed) {
3300
+ return { stdout: adapter.passthrough(payload) };
3301
+ }
3302
+ // 检查必需的 BOTMUX_* env
3303
+ const sessionId = env.BOTMUX_SESSION_ID;
3304
+ const chatId = env.BOTMUX_CHAT_ID;
3305
+ const larkAppId = env.BOTMUX_LARK_APP_ID;
3306
+ // 路由变量:优先用 env,env 缺失时尝试 adopt 路由
3307
+ let routeSessionId = sessionId;
3308
+ let routeChatId = chatId;
3309
+ let routeLarkAppId = larkAppId;
3310
+ let routeRoot = env.BOTMUX_ROOT_MESSAGE_ID || null;
3311
+ if (!sessionId || !chatId || !larkAppId) {
3312
+ // env 缺失 → 尝试通过祖先 PID 匹配在线 adopt 会话
3313
+ const resolver = resolveAdoptRouteFn ?? (() => {
3314
+ // 延迟 import 避免冷启动开销
3315
+ return import('./adapters/adopt-route.js').then(({ resolveAdoptRoute, queryAdoptSession }) => resolveAdoptRoute({
3316
+ startPid: process.pid,
3317
+ listDaemons: listOnlineDaemons,
3318
+ queryDaemon: queryAdoptSession,
3319
+ }));
3320
+ });
3321
+ let adopt = null;
3322
+ try {
3323
+ adopt = await resolver();
3324
+ }
3325
+ catch {
3326
+ // 解析失败 → 视作真非 botmux 会话,passthrough 放行
3327
+ }
3328
+ if (!adopt) {
3329
+ // 真非 botmux 会话 → passthrough 放行
3330
+ return { stdout: adapter.passthrough(payload) };
3331
+ }
3332
+ // adopt 命中 → 使用 adopt 路由信息
3333
+ routeSessionId = adopt.sessionId;
3334
+ routeChatId = adopt.chatId;
3335
+ routeLarkAppId = adopt.larkAppId;
3336
+ routeRoot = adopt.rootMessageId;
3337
+ }
3338
+ // 解析 timeoutMs:默认 1 小时,可由 BOTMUX_ASK_TIMEOUT_MS 覆盖
3339
+ const DEFAULT_TIMEOUT_MS = 3_600_000;
3340
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
3341
+ const timeoutEnv = env.BOTMUX_ASK_TIMEOUT_MS;
3342
+ if (timeoutEnv) {
3343
+ const parsed_timeout = parseInt(timeoutEnv, 10);
3344
+ if (Number.isInteger(parsed_timeout) && parsed_timeout > 0) {
3345
+ timeoutMs = parsed_timeout;
3346
+ }
3347
+ }
3348
+ const body = {
3349
+ sessionId: routeSessionId,
3350
+ chatId: routeChatId,
3351
+ larkAppId: routeLarkAppId,
3352
+ rootMessageId: routeRoot,
3353
+ questions: parsed.questions,
3354
+ timeoutMs,
3355
+ approvers: [],
3356
+ };
3357
+ let result;
3358
+ try {
3359
+ result = await postAskFn(body);
3360
+ }
3361
+ catch {
3362
+ // 任何失败(daemon 不可达、HTTP 错误等)→ passthrough 放行
3363
+ return { stdout: adapter.passthrough(payload) };
3364
+ }
3365
+ if (result.kind === 'answered') {
3366
+ return { stdout: adapter.formatAnswer(result.answers, parsed) };
3367
+ }
3368
+ // timedOut / invalidated → passthrough 放行
3369
+ return { stdout: adapter.passthrough(payload) };
3370
+ }
3371
+ /**
3372
+ * cmdHook: `botmux hook <cliId>` 入口。
3373
+ * 读取 stdin 全文 → JSON.parse → runHook → 写 stdout,exit 0。
3374
+ */
3375
+ async function cmdHook(cliId) {
3376
+ // 读取 stdin 全文
3377
+ let stdinText = '';
3378
+ try {
3379
+ const chunks = [];
3380
+ for await (const chunk of process.stdin) {
3381
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3382
+ }
3383
+ stdinText = Buffer.concat(chunks).toString('utf-8');
3384
+ }
3385
+ catch {
3386
+ // stdin 读取失败 → 无法处理,静默退出
3387
+ process.exit(0);
3388
+ }
3389
+ // JSON.parse 失败 → 输出空并退出(不挂死)
3390
+ let payload;
3391
+ try {
3392
+ payload = JSON.parse(stdinText);
3393
+ }
3394
+ catch {
3395
+ process.exit(0);
3396
+ }
3397
+ const { getHookAdapter } = await import('./core/ask-hook/registry.js');
3398
+ const adapter = getHookAdapter(cliId);
3399
+ // 未知 cliId → 静默放行
3400
+ if (!adapter) {
3401
+ process.exit(0);
3402
+ }
3403
+ const env = process.env;
3404
+ const result = await runHook(payload, env, postAsk, cliId);
3405
+ if (result.stdout) {
3406
+ console.log(result.stdout);
3407
+ }
3408
+ process.exit(0);
3409
+ }
3112
3410
  async function cmdBots(sub, rest) {
3113
3411
  process.env.SESSION_DATA_DIR ??= resolveDataDir();
3114
3412
  if (sub !== 'list' && sub !== 'ls' && sub !== '') {
@@ -3337,6 +3635,20 @@ switch (command) {
3337
3635
  case 'schedule':
3338
3636
  await cmdSchedule(process.argv[3] ?? '', process.argv.slice(4));
3339
3637
  break;
3638
+ case 'ask': {
3639
+ // `botmux ask buttons --options ...` → sub='buttons', rest=['--options', ...]
3640
+ // `botmux ask --options ...` → sub='', rest=['--options', ...] (bare alias)
3641
+ const { normalizeAskDispatch } = await import('./core/ask-args.js');
3642
+ const { sub, rest } = normalizeAskDispatch(process.argv.slice(3));
3643
+ await cmdAsk(sub, rest);
3644
+ break;
3645
+ }
3646
+ case 'hook': {
3647
+ // `botmux hook <cliId>` — hook 客户端,stdin 读 payload,stdout 写 directive
3648
+ const cliId = process.argv[3] ?? '';
3649
+ await cmdHook(cliId);
3650
+ break;
3651
+ }
3340
3652
  case 'workflow': {
3341
3653
  const { cmdWorkflow } = await import('./cli/workflow.js');
3342
3654
  await cmdWorkflow(process.argv[3] ?? '', process.argv.slice(4));