diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md
index d183bd1092..38f18eea4c 100644
--- a/docs-site/docs/en/bots-json.md
+++ b/docs-site/docs/en/bots-json.md
@@ -240,6 +240,31 @@ This option addresses one narrow gap: Codex running through Botmux's app-server
|-------|-------------|
| `senderTag` | Boolean, default `true` (on). Whether each turn forwarded to the CLI carries a `` tag naming who spoke. Only an explicit `false` is persisted and disables it; absent or `true` both keep injecting, leaving the prompt byte-for-byte identical to historical behavior |
| `thinkingCardToolResult` | Boolean, default `true` (on). Whether tool nodes in the native thinking bubble (bot-level master switch `thinkingCard`, default on) carry the command output / file content code block. `false` keeps only thinking paragraphs and tool node titles (tool · command / path) and degrades the result to a single `✓ Done` line (a tool node only leaves the “running” state once a result event arrives, so the event cannot simply be dropped), matching Claude Code's own UI; toggle via `/botconfig set thinkingCardToolResult off` or the dashboard card sub-switch, effective immediately |
+| `replyDelivery` | `"transcript"` or `"send"`; the default depends on the CLI: `claude-code` defaults to `transcript`, every other CLI to `send`. How the final reply reaches Feishu: `transcript` = the daemon takes the last assistant text of the turn from the CLI transcript and posts it as the final reply card, and the system prompt no longer mentions `botmux send`; `send` = the model must run `botmux send` itself (historical behavior). An explicit `"send"` is the only way to put claude-code back on the old behavior; both `send` and `transcript` are persisted, `unset` returns to the CLI default |
+
+### `replyDelivery: "transcript"`
+
+`claude-code` defaults to `transcript`; the other supported CLIs need it set explicitly. Once active it changes three things for that bot's sessions:
+
+1. **The system prompt never mentions `botmux send`**: the intro becomes "your final assistant message is automatically forwarded back to Lark by botmux — just answer directly"; the heredoc rule, the @ decision gate, the attachment usage and the `` rule "collaboration requires `botmux send --mention`" are all dropped, leaving only `botmux history` / `botmux bots list` and the `BOTMUX_NOTHING_TO_SEND` silence sentinel. For the cases that genuinely need `botmux send` (attachments, cross-bot @) the model can discover the built-in skill (`botmux-send` under `--plugin-dir`) on its own;
+2. **The per-turn `` is no longer injected** (one less reminder block per prompt);
+3. **Solo sessions are unwrapped**: in a DM, or a plain 1:1 group whose only participants are the owner and this bot, each turn drops the `` wrapper and the `` tag, so the model sees bare text. Topic groups, multi-member groups, and turns spoken by anyone other than the owner never count as solo; wrapper and tag stay as before.
+
+Supported CLIs: `claude-code`, plus the structured-transcript bridge CLIs `codex` / `traex` / `coco` / `hermes` / `mtr` / `pi` / `oh-my-pi` / `ebsd` / `grok`. Other CLIs (e.g. `cursor`, `gemini`) have no transcript capture, so both `/botconfig set` and the dashboard reject the value (`reply_delivery_unsupported`); if the field is already persisted and `cli` is later switched to an unsupported CLI, the runtime falls back to `send` (one warn in the log) rather than losing replies.
+
+Hot-updatable by the owner / `allowedUsers` via `/botconfig`:
+
+```text
+/botconfig set replyDelivery transcript # enable explicitly on the other supported CLIs
+/botconfig set replyDelivery send # put claude-code back on the old behavior (model runs botmux send itself)
+/botconfig unset replyDelivery # back to the CLI default
+```
+
+- **Two activation points**: the per-turn envelope (reminder / wrapper / ``) applies from the next turn; the system prompt is injected at spawn time, so a running session needs `/restart` to pick up the new value, while new sessions use it directly.
+- **Observability cost**: the bare-text shape of a solo session has no `` / `` structure, so `/adopt` no longer recognizes such sessions as botmux's own (the same class of cost as `senderTag: false`).
+- The dashboard "Reply Delivery → Transcript reply mode" toggle saves this field; it is disabled with an explanation when the current CLI does not support it.
+
+### `senderTag: false`
With it off the model cannot see speaker identity: in a multi-person chat it cannot tell participants apart or address them by name. Useful for a CLI whose model copies the tag into its reply body (e.g. cursor — see the `` anti-echo hint, which disappears together with the tag), or when you do not want per-message identity written into the CLI transcript.
diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md
index 268be98bb1..3321c7fbbd 100644
--- a/docs-site/docs/zh/bots-json.md
+++ b/docs-site/docs/zh/bots-json.md
@@ -243,6 +243,31 @@ Dashboard 的「Bot 配置 → 消息卡片 → 实时卡片按钮」提供同
|------|------|
| `senderTag` | 布尔,默认 `true`(开)。每轮转发给 CLI 的消息是否附带一个 `` 标签,告诉模型这句话是谁说的。只有显式 `false` 会写盘并关闭;缺省或 `true` 都保持注入,prompt 与历史行为逐字节一致 |
| `thinkingCardToolResult` | 布尔,默认 `true`(开)。思考气泡(bot 级总开关 `thinkingCard`,默认开)的工具节点是否附带命令输出 / 文件内容代码块。设为 `false` 后气泡只保留思考段落与工具节点标题(工具名 · 命令 / 路径),结果退化成一行 `✓ 已完成`(工具节点在飞书端要收到结果事件才会从「执行中」落定,所以不能干脆不发),与 Claude Code 自身界面一致;`/botconfig set thinkingCardToolResult off` 或 dashboard「卡片」子开关切换,立即生效 |
+| `replyDelivery` | `"transcript"` 或 `"send"`,缺省按 CLI:`claude-code` 默认 `transcript`,其它 CLI 默认 `send`。最终回复怎么送到飞书:`transcript` = daemon 从 CLI 转写自动取本轮最后的 assistant 文本发最终回复卡,系统提示不再提及 `botmux send`;`send` = 模型必须自己 `botmux send`(历史行为)。显式写 `"send"` 才让 claude-code 退回旧行为;`send` / `transcript` 都会写盘,`unset` 回各 CLI 默认 |
+
+### `replyDelivery: "transcript"`
+
+`claude-code` 缺省即 `transcript`;其它支持的 CLI 需显式设置。生效后对该 bot 的会话有三条效果:
+
+1. **系统提示不再提及 `botmux send`**:开场改为「最终 assistant message 由 botmux 自动转发回飞书,直接作答即可」,heredoc / @ 决策 / 附件用法、`` 里「协作必须 `botmux send --mention`」的规则一并去掉,只保留 `botmux history` / `botmux bots list` 与 `BOTMUX_NOTHING_TO_SEND` 沉默哨兵。附件、跨 bot @ 等确实需要 `botmux send` 的场景,模型可通过内置 skill(`--plugin-dir` 里的 `botmux-send`)按需自行发现;
+2. **不再逐轮注入 ``**(每轮 prompt 少一段提醒);
+3. **solo 会话去壳**:私聊、或只有 owner 和本 bot 两个参与者的 1v1 普通群,每轮消息去掉 `` 壳与 `` 标签,模型看到的就是裸文本。话题群、多人群、非 owner 发言的一律不算 solo,壳与标签照旧。
+
+支持的 CLI 白名单:`claude-code`,以及走结构化转写桥的 `codex` / `traex` / `coco` / `hermes` / `mtr` / `pi` / `oh-my-pi` / `ebsd` / `grok`。其它 CLI(如 `cursor`、`gemini`)没有转写采集通道,`/botconfig set` 与 dashboard 都会拒绝(`reply_delivery_unsupported`);已写盘后再把 `cli` 切到不支持的 CLI,运行时自动回落 `send`(日志 warn 一次),不会丢回复。
+
+可由 owner / `allowedUsers` 通过 `/botconfig` 热更新:
+
+```text
+/botconfig set replyDelivery transcript # 其它支持的 CLI 显式开启
+/botconfig set replyDelivery send # claude-code 退回旧行为(模型自己 botmux send)
+/botconfig unset replyDelivery # 回各 CLI 默认
+```
+
+- **生效时机分两段**:逐轮信封(reminder / 壳 / ``)从下一轮起生效;系统提示是 spawn 时注入的,已在跑的会话要 `/restart` 才换新值,新会话直接用新值。
+- **观测代价**:solo 会话的裸文本形态没有 `` / `` 结构,`/adopt` 不再把这类会话识别为 botmux 自产会话(与 `senderTag: false` 同类代价)。
+- dashboard「回复投递 → 转写回复模式」开关保存的就是这个字段;当前 CLI 不支持时开关禁用并说明。
+
+### `senderTag: false`
关掉后模型看不到发言人身份:多人会话里无法区分谁说的、也无法按人称呼。适合模型会把标签内容抄进回复正文的 CLI(如 cursor,见 `` 反抄写提示——标签关掉后该提示也一并消失),或不希望把每条消息的身份写进 CLI 记录的场景。
diff --git a/src/adapters/cli/claude-code.ts b/src/adapters/cli/claude-code.ts
index 2b95e08107..9d0af01559 100644
--- a/src/adapters/cli/claude-code.ts
+++ b/src/adapters/cli/claude-code.ts
@@ -20,7 +20,7 @@ import {
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
import { resolveCommand } from './registry.js';
-import { sessionReadyHookCommand, userPromptHookCommand } from '../hook-command.js';
+import { sessionReadyHookCommand, statuslineHookCommand, userPromptHookCommand } from '../hook-command.js';
import type { CliAdapter, CliId, PtyHandle } from './types.js';
import { findJsonlContainingFingerprint, jsonlContainsFingerprint, normaliseForFingerprint } from '../../services/claude-transcript.js';
import { CLAUDE_REASONING_EFFORTS } from '../../services/codex-reasoning-effort.js';
@@ -715,6 +715,56 @@ function resolveClaudeChatKeybindings(keybindingsPath: string): ClaudeChatKeybin
* across multiple adapter instances shares the warmup state. */
const claudeFirstWriteSeen = new WeakSet();
+/** 用户自己配置的 statusLine(被 botmux 进程级 --settings 遮蔽的那一条)。 */
+export interface ShadowedStatusLine {
+ command?: string;
+ padding?: number;
+ refreshInterval?: number;
+}
+
+/**
+ * 找回被 botmux 进程级 `--settings` 遮蔽的用户 statusLine。
+ *
+ * 背景:Claude 的 settings 里 `statusLine` 是**单值**(不像 hooks 按事件合并数组),
+ * 而 --settings 优先级最高,所以 botmux 一注入,用户在项目 / 用户 settings 里配的
+ * statusline 命令就再也不会被 Claude 调用。为了不吞掉它,worker 在 spawn 前按 Claude
+ * 自己的优先级找到那条命令,经 `BOTMUX_STATUSLINE_CHAIN` 交给 `botmux statusline`:
+ * 落盘之后把**原始 stdin 字节**转发给它并透传其 stdout / 退出码——对用户的终端来说
+ * 状态栏行为不变。
+ *
+ * 优先级(高 → 低,取第一个 `type === 'command'` 且 command 非空的):
+ * `/.claude/settings.local.json` > `/.claude/settings.json` > `userSettingsPath`
+ * (后者通常是 `~/.claude/settings.json`;read-isolation 下是 `/claude/settings.json`)。
+ * 不看 managed / enterprise 策略层:那一层 botmux 本来就无权覆盖,Claude 会自行处理。
+ *
+ * 纯函数、fail-open:任何读 / parse 失败视为该层无配置,继续向下找;全部没有 ⇒ `{}`。
+ * 不做全局 settings 兜底写入——全局只能有一个 statusLine,写进去就覆盖用户自己的。
+ * wrapperCli=aiden 会把 --settings 整个剥掉,此时 Claude 直接用用户自己的 statusLine,
+ * `botmux statusline` 不会被调用,worker 照常算出的 BOTMUX_STATUSLINE_CHAIN 只是闲置无害
+ * (cjadk / ccr / ttadk 会透传 --settings,沙盒开启时 wrapperCli 又被整体忽略,都需要链)。
+ */
+export function resolveShadowedStatusLine(opts: { workingDir: string; userSettingsPath?: string }): ShadowedStatusLine {
+ const candidates = [
+ join(opts.workingDir, '.claude', 'settings.local.json'),
+ join(opts.workingDir, '.claude', 'settings.json'),
+ ...(opts.userSettingsPath ? [opts.userSettingsPath] : []),
+ ];
+ for (const path of candidates) {
+ let parsed: unknown;
+ try { parsed = JSON.parse(readFileSync(path, 'utf-8')); } catch { continue; }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
+ const sl = (parsed as Record).statusLine;
+ if (!sl || typeof sl !== 'object' || Array.isArray(sl)) continue;
+ const o = sl as Record;
+ if (o.type !== 'command' || typeof o.command !== 'string' || o.command.trim() === '') continue;
+ const out: ShadowedStatusLine = { command: o.command };
+ if (typeof o.padding === 'number' && Number.isFinite(o.padding)) out.padding = o.padding;
+ if (typeof o.refreshInterval === 'number' && Number.isFinite(o.refreshInterval)) out.refreshInterval = o.refreshInterval;
+ return out;
+ }
+ return {};
+}
+
/** A member of the Claude-family CLIs: Claude Code itself and forks that share
* its on-disk session layout (per-project JSONL transcripts, `sessions/.json`
* pid-state, `tasks/` fd locks, keybindings.json, settings.json hooks) but
@@ -852,7 +902,7 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin:
return discoverClaudeFamilySessions(variant.dataDir, limit, exclude);
},
- buildArgs({ sessionId, resume, resumeSessionId, forkSession, botName, botOpenId, locale, model, reasoningEffort, disableCliBypass, skillPluginDir, noTransport, triggerUserAuth }) {
+ buildArgs({ sessionId, resume, resumeSessionId, forkSession, botName, botOpenId, locale, model, reasoningEffort, disableCliBypass, skillPluginDir, noTransport, triggerUserAuth, replyDelivery, solo }) {
const args: string[] = [];
if (resume) {
args.push('--resume', resumeSessionId ?? sessionId);
@@ -908,7 +958,22 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin:
inlineSettings.skipDangerousModePermissionPrompt = true;
inlineSettings.permissions = { defaultMode: 'bypassPermissions' };
}
- // 仅在有内容(bypass 键)时才传 --settings;disableCliBypass 下没东西可传就不传。
+ // statusLine(仅 claude-code):Claude 把 context_window / rate_limits 等 JSON 喂给
+ // 这条命令的 stdin,`botmux statusline` 落盘到 `/statusline//`,
+ // 卡片用量段据此渲染 `ctx 23% · 5h 18% · 7d 5%`。它**必须**走进程级 --settings 而
+ // 不能像就绪 hook 那样写全局:settings 里 statusLine 只能有一个(不是 hooks 那样按
+ // 事件合并的数组),写全局会覆盖用户自己的 statusline。进程级这份优先级最高,会
+ // **遮蔽**用户在项目 / 用户 settings 里的 statusLine——worker 用
+ // resolveShadowedStatusLine 找回它并经 BOTMUX_STATUSLINE_CHAIN 交给 `botmux
+ // statusline` 转发,用户终端里的状态栏不受影响。wrapperCli=aiden 会剥掉本
+ // --settings ⇒ 无数据 ⇒ 卡片省略配额段(fail-open),不做全局兜底。
+ // refreshInterval=60:实测冷启动 0.24–0.34s,每分钟一次可承受,且能在无消息时
+ // 跟上 5h/7d 窗口滚动;快照 10 min 陈旧自动失效(STATUSLINE_STALE_MS)。
+ if (variant.id === 'claude-code') {
+ inlineSettings.statusLine = { type: 'command', command: statuslineHookCommand(), refreshInterval: 60 };
+ }
+ // claude-code 恒传 --settings(statusLine 总在);其它 variant 仅在有内容(bypass 键)
+ // 时才传,disableCliBypass 下没东西可传就不传。
// (读隔离由 worker 的整进程 Seatbelt wrapper 强制,这里不注入任何 sandbox 设置——
// 注入内置 sandbox 会嵌套沙箱且 permissions deny>allow 会挡掉 memory carve-out。)
if (Object.keys(inlineSettings).length > 0) {
@@ -924,7 +989,10 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin:
// `claude` never surfaces/mis-fires `botmux send` etc.
args.push('--plugin-dir', CLAUDE_PLUGIN_DIR);
if (skillPluginDir) args.push('--plugin-dir', skillPluginDir);
- args.push('--append-system-prompt', buildBotmuxSystemPromptText({ locale, botName, botOpenId, noTransport, triggerUserAuth }));
+ // replyDelivery=transcript:系统提示改口为「最终回复由 botmux 自动转发」。v3 workflow
+ // 子会话(GOAL_ENV.V3_MARKER)的收口靠 botmux send,强制保持 send 措辞。
+ const effectiveReplyDelivery = process.env[GOAL_ENV.V3_MARKER] === '1' ? 'send' : replyDelivery;
+ args.push('--append-system-prompt', buildBotmuxSystemPromptText({ locale, botName, botOpenId, noTransport, triggerUserAuth, replyDelivery: effectiveReplyDelivery, solo }));
return args;
},
diff --git a/src/adapters/cli/fs-policy.ts b/src/adapters/cli/fs-policy.ts
index 0c741e60e4..bce60ab721 100644
--- a/src/adapters/cli/fs-policy.ts
+++ b/src/adapters/cli/fs-policy.ts
@@ -901,6 +901,13 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// worker PRE-CREATES this file before spawn so it survives the existence
// filter and bwrap can bind it (bwrap cannot bind a nonexistent source).
if (ctx.sessionId) push([`${sd}/turn-sends/${ctx.sessionId}.jsonl`], 'readWrite', 'internal');
+ // statusline: `botmux statusline` (Claude's statusLine.command, run INSIDE the
+ // sandbox) atomically writes `statusline//latest.json`. Atomic
+ // write = tmp + rename in the parent dir, so a single-file grant (as for
+ // turn-sends) cannot work — grant the per-session DIRECTORY instead. Still
+ // session-scoped: sibling sessions' dirs are not exposed. The worker
+ // pre-creates the dir so bwrap has a bind source.
+ if (ctx.sessionId) push([`${sd}/statusline/${ctx.sessionId}`], 'readWrite', 'internal');
// (schedules: stored PER BOT inside each BOT_HOME — the owner's dir is
// already readWrite above and siblings' stores are denied by construction,
// so the old shared data/schedules.json grant (and the cross-bot task-prompt
@@ -1013,6 +1020,8 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
`${ctx.sessionDataDir}/bin`,
], 'readOnly', 'internal');
if (ctx.sessionId) push([`${ctx.sessionDataDir}/turn-sends/${ctx.sessionId}.jsonl`], 'readWrite', 'internal');
+ // statusline snapshot dir (see the larkTransport branch for why a dir, not a file).
+ if (ctx.sessionId) push([`${ctx.sessionDataDir}/statusline/${ctx.sessionId}`], 'readWrite', 'internal');
// NOTE: dashboard-daemons (sibling IPC port table) and .dashboard-secret/-token
// are deliberately NOT re-allowed — a no-transport turn has no business
// reaching sibling daemons, and the secret is the escalation vector.
diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts
index 1264405c64..aefc22e236 100644
--- a/src/adapters/cli/shared-hints.ts
+++ b/src/adapters/cli/shared-hints.ts
@@ -18,6 +18,7 @@ import { isWorkflowFeatureEnabled } from '../../global-config.js';
import { config } from '../../config.js';
import { escapeXmlTagLikeTokens, escapeXmlText } from '../../utils/xml.js';
import { resolveConditionalLine } from '../../skills/effective-builtins.js';
+import type { ReplyDelivery } from '../../core/reply-delivery.js';
/** The gated "no visible output is OK" hint reads `config.noVisibleOutputHint`
* by default, but a user customization can force it on/off. Keyed by the i18n
@@ -74,7 +75,7 @@ function hiddenContextDefense(locale?: Locale): string {
return escapeXmlText(text);
}
-export function buildBotmuxShellHints(locale?: Locale, noTransport?: boolean): string[] {
+export function buildBotmuxShellHints(locale?: Locale, noTransport?: boolean, replyDelivery?: ReplyDelivery): string[] {
// No-transport session (apiOnly core-only bot OR HTTP virtual chat): drop the
// whole send/@/helpers/silence collaboration block — same rationale as the
// system-prompt path in buildBotmuxSystemPromptText. `ai.shell.when_to_send`
@@ -86,27 +87,44 @@ export function buildBotmuxShellHints(locale?: Locale, noTransport?: boolean): s
if (noTransport) {
return [hiddenContextDefense(locale)].map(escapeXmlTagLikeTokens);
}
+ // replyDelivery=transcript(core/reply-delivery.ts):最终回复由 daemon 从转写自动
+ // 转发,提示里彻底不提 `botmux send`——只留 intro / helpers / when_to_send 的改口
+ // 版、workflow 与防注入;围绕 send 的 commands_are_shell / how_to_send / heredoc /
+ // mention_gate / feedback / 反重发提示全部不注入(附件、跨 bot @ 等场景模型可经
+ // 内置 botmux-send skill 自行发现)。缺省 / 'send' 时下面每一行与改动前逐字相同。
+ const transcript = replyDelivery === 'transcript';
const workflowHint = workflowDiscoveryHint(locale);
- const hints = [
- t('ai.shell.intro', undefined, locale),
- t('ai.shell.commands_are_shell', undefined, locale),
- t('ai.shell.how_to_send', undefined, locale),
- ...multilineHeredocLines(locale),
- t('ai.shell.helpers', undefined, locale),
- t('ai.shell.when_to_send', undefined, locale),
- feedbackResponseKindHint(locale),
- // Experimental anti-resend guidance — opt-in via dashboard Settings
- // (dashboard.noVisibleOutputHint). Default OFF, so the rendered hints match
- // the pre-feature baseline unless an operator flips it on. Live-read here so
- // a toggle takes effect on the next session without a daemon restart.
- ...(noVisibleOutputHintOn() ? [t('ai.shell.no_visible_output_ok', undefined, locale)] : []),
- t('ai.shell.mention_gate', undefined, locale),
- // Workflow discovery — omitted when the machine-wide workflow switch is off.
- ...(workflowHint ? [workflowHint] : []),
- hiddenContextDefense(locale),
- ].map(escapeXmlTagLikeTokens);
+ const hints = (transcript
+ ? [
+ t('ai.shell.intro_transcript', undefined, locale),
+ t('ai.shell.helpers', undefined, locale),
+ t('ai.shell.when_to_send_transcript', undefined, locale),
+ // Workflow discovery — omitted when the machine-wide workflow switch is off.
+ ...(workflowHint ? [workflowHint] : []),
+ hiddenContextDefense(locale),
+ ]
+ : [
+ t('ai.shell.intro', undefined, locale),
+ t('ai.shell.commands_are_shell', undefined, locale),
+ t('ai.shell.how_to_send', undefined, locale),
+ ...multilineHeredocLines(locale),
+ t('ai.shell.helpers', undefined, locale),
+ t('ai.shell.when_to_send', undefined, locale),
+ feedbackResponseKindHint(locale),
+ // Experimental anti-resend guidance — opt-in via dashboard Settings
+ // (dashboard.noVisibleOutputHint). Default OFF, so the rendered hints match
+ // the pre-feature baseline unless an operator flips it on. Live-read here so
+ // a toggle takes effect on the next session without a daemon restart.
+ ...(noVisibleOutputHintOn() ? [t('ai.shell.no_visible_output_ok', undefined, locale)] : []),
+ t('ai.shell.mention_gate', undefined, locale),
+ // Workflow discovery — omitted when the machine-wide workflow switch is off.
+ ...(workflowHint ? [workflowHint] : []),
+ hiddenContextDefense(locale),
+ ]).map(escapeXmlTagLikeTokens);
if (whiteboardEnabled()) {
- hints.push(escapeXmlTagLikeTokens('出现 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论仍用 `botmux send`;不要写密钥/隐私;更新默认用中文。'));
+ hints.push(escapeXmlTagLikeTokens(transcript
+ ? '出现 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论写进最终回复即可;不要写密钥/隐私;更新默认用中文。'
+ : '出现 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论仍用 `botmux send`;不要写密钥/隐私;更新默认用中文。'));
}
return hints;
}
@@ -197,8 +215,23 @@ export function buildBotmuxSystemPromptText(opts: {
* gets no extra prompt text.
*/
triggerUserAuth?: boolean;
+ /** Per-bot replyDelivery frozen for this session (core/reply-delivery.ts).
+ * 'transcript': the daemon forwards the final assistant message from the CLI
+ * transcript, so the routing block never mentions `botmux send` at all —
+ * only intro_transcript, helpers, the BOTMUX_NOTHING_TO_SEND silence sentinel
+ * (still the fallback's suppression rule), workflow, hidden-context defense
+ * and whiteboard survive; usage_send / heredoc / mention gate / attachments /
+ * feedback / anti-resend are dropped, and keeps its routing_rules
+ * minus mention_must (the model can discover the built-in botmux-send skill
+ * on its own for attachments / cross-bot @). `noTransport` wins over it.
+ * Omitted/'send' = today. */
+ replyDelivery?: ReplyDelivery;
+ /** transcript-only: solo chat (owner + this bot). The identity block keeps
+ * name/open_id but drops routing_rules — there is no other bot to route to. */
+ solo?: boolean;
}): string {
- const { locale, botName, botOpenId, builtinSkillBlock, noTransport, triggerUserAuth } = opts;
+ const { locale, botName, botOpenId, builtinSkillBlock, noTransport, triggerUserAuth, replyDelivery, solo } = opts;
+ const transcript = !noTransport && replyDelivery === 'transcript';
const unknown = t('ai.identity.unknown', undefined, locale);
const workflowHint = workflowDiscoveryHint(locale);
const prose = (key: string): string =>
@@ -213,6 +246,9 @@ export function buildBotmuxSystemPromptText(opts: {
// HTTP task (R1) — so this block IS injected there; gating only routingInner
// would leave the same @-rules leaking via identity. Mirrors the session-manager
// non-injects path (short_routing) which is gated the same way.
+ // transcript + solo(只有 owner 和本 bot)同样只留 name/open_id:多 bot 归属规则
+ // 在 solo 会话里没有对象。transcript 非 solo 保留归属四条,但去掉 mention_must
+ // ——它整句围绕 `botmux send --mention`,transcript 模式的提示不再提 send。
const identityBlock =
botName || botOpenId
? [
@@ -220,7 +256,7 @@ export function buildBotmuxSystemPromptText(opts: {
'',
` ${botName ?? unknown}`,
` ${botOpenId ?? unknown}`,
- ...(noTransport
+ ...(noTransport || (transcript && solo === true)
? []
: [
' ',
@@ -228,7 +264,7 @@ export function buildBotmuxSystemPromptText(opts: {
` ${prose('ai.identity.rule_own_part')}`,
` ${prose('ai.identity.rule_silent_when_other')}`,
` ${prose('ai.identity.rule_no_proactive_pull')}`,
- ` ${prose('ai.identity.mention_must')}`,
+ ...(transcript ? [] : [` ${prose('ai.identity.mention_must')}`]),
' ',
]),
'',
@@ -237,7 +273,9 @@ export function buildBotmuxSystemPromptText(opts: {
const whiteboardRouting = whiteboardEnabled()
? [
'',
- escapeXmlTagLikeTokens('出现 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论仍必须`botmux send`。'),
+ escapeXmlTagLikeTokens(transcript
+ ? '出现 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论写进最终回复即可。'
+ : '出现 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论仍必须`botmux send`。'),
]
: [];
// The multiline rule reads as a peer bullet of usage_send here (the
@@ -250,8 +288,22 @@ export function buildBotmuxSystemPromptText(opts: {
// The identity block's routing_rules carry the same @/collaboration semantics
// and are gated on the SAME flag — see identityBlock above, which keeps the
// harmless name/open_id and drops only the rules.
+ // transcript(三分支中优先级低于 noTransport):提示里彻底不提 `botmux send`——
+ // 只留改口 intro、helpers、usage_silence 的哨兵(仍是转写 fallback 的抑制规则)、
+ // workflow、防注入与白板;usage_send / heredoc / mention_gate / attachments /
+ // feedback_response_kind / no_visible_output_ok 全部不注入。
const routingInner = noTransport
? [hiddenContextDefense(locale)]
+ : transcript
+ ? [
+ prose('ai.routing.intro_transcript'),
+ '',
+ prose('ai.routing.usage_helpers'),
+ prose('ai.routing.usage_silence'),
+ ...(workflowHint ? [escapeXmlTagLikeTokens(workflowHint)] : []),
+ hiddenContextDefense(locale),
+ ...whiteboardRouting,
+ ]
: [
prose('ai.routing.intro'),
'',
diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts
index 1965413c50..3131150252 100644
--- a/src/adapters/cli/types.ts
+++ b/src/adapters/cli/types.ts
@@ -171,6 +171,16 @@ export interface CliAdapter {
* has such a knob declare these keys; the rest ignore the field, since for
* them a plain child inherits the environment anyway. */
shellSubprocessEnv?: Record;
+ /** Per-bot `replyDelivery` frozen for this session (core/reply-delivery.ts).
+ * 'transcript' → injectsSessionContext adapters reword the routing block:
+ * the final assistant message is auto-forwarded by the daemon, so `botmux
+ * send` is only for mid-turn pushes / attachments / cross-bot @. Omitted or
+ * 'send' → today's text byte-for-byte. `noTransport` takes precedence. */
+ replyDelivery?: 'send' | 'transcript';
+ /** transcript-only: this session is a solo chat (owner + this bot). Drops
+ * the identity routing_rules (no other bot to route to). Ignored for
+ * 'send'. */
+ solo?: boolean;
/** UI / response language for prompts injected into the CLI (e.g. zh / en). */
locale?: import('../../i18n/index.js').Locale;
/** Optional model name from BotConfig.model. Adapters whose CLI accepts a
diff --git a/src/adapters/hook-command.ts b/src/adapters/hook-command.ts
index d53de3baac..09b2ac2611 100644
--- a/src/adapters/hook-command.ts
+++ b/src/adapters/hook-command.ts
@@ -116,6 +116,17 @@ export function userPromptHookCommand(): string {
return renderShellCommand(undefined, 'user-prompt-hook');
}
+/**
+ * 构造 Claude Code `statusLine.command` 的 shell 命令字符串 → `botmux statusline`。
+ * 与 sessionReadyHookCommand 同策略,但写进**进程级** `--settings`(不写全局:全局只能
+ * 有一个 statusLine,写进去会覆盖用户自己的;被 wrapperCli 剥掉时卡片省略即可)。
+ * 子进程靠继承的 BOTMUX_SESSION_ID 定位落盘目录、靠 BOTMUX_STATUSLINE_CHAIN 转发
+ * 用户原有的 statusline 命令;缺 env 时静默 exit 0。
+ */
+export function statuslineHookCommand(): string {
+ return renderShellCommand(undefined, 'statusline');
+}
+
/**
* Construct the process-scoped TraeCode `spawn_agent` runtime-policy hook.
* The stable daemon-written wrapper lets a long-lived pane pick up the current
diff --git a/src/bot-registry.ts b/src/bot-registry.ts
index d7fe274a27..8cbd3c074e 100644
--- a/src/bot-registry.ts
+++ b/src/bot-registry.ts
@@ -1500,6 +1500,19 @@ export interface BotConfig {
* 缺省/`off`:保持内联 envelope(历史行为)。从下一个 follow-up turn 生效。
*/
envelopeInjection?: 'auto' | 'off';
+ /**
+ * 最终回复投递方式。`send`:模型必须自己执行 `botmux send` 把回复发到飞书,
+ * 系统提示与每轮 reminder 都这么要求。`transcript`:daemon 从 CLI 转写自动取
+ * 本轮最后的 assistant 文本发最终回复卡(即原来的 bridge fallback 升为主通道),
+ * 系统提示不再提及 `botmux send`、不再注入每轮 reminder;solo 会话(私聊 / 仅
+ * owner 的 1v1 群)还会去掉 `` 壳与 ``。只对有转写采集
+ * 的 CLI 有效(见 core/reply-delivery.ts),不支持的 CLI 运行时自动回落 send。
+ * 缺省按 CLI:claude-code 缺省 `transcript`,其它 CLI 缺省 `send`
+ * (`defaultReplyDeliveryFor`);显式 `'send'` / `'transcript'` 都持久化,
+ * claude-code 要回旧行为只能显式写 `'send'`。系统提示部分需 /restart 生效,
+ * 逐轮信封立即生效。
+ */
+ replyDelivery?: 'send' | 'transcript';
/**
* Whether each forwarded turn carries a `` tag naming who spoke. Default ON (ABSENT ⇒ ON — only an
@@ -2471,6 +2484,15 @@ export function getOwnerOpenId(larkAppId: string): string | undefined {
return bots.get(larkAppId)?.resolvedAllowedUsers.find(u => u.startsWith('ou_'));
}
+/** Per-bot 最终回复投递方式的**显式**配置值;未配置 / 未注册的 bot 返回 undefined,
+ * 由 core/reply-delivery.ts 的 effectiveReplyDelivery 按 CLI 补缺省(claude-code
+ * → transcript,其它 → send)。只读内存 registry:worker 通过 init IPC 拿冻结值,
+ * 不需要磁盘 mtime 缓存。 */
+export function resolveReplyDelivery(larkAppId: string): 'send' | 'transcript' | undefined {
+ const v = bots.get(larkAppId)?.config.replyDelivery;
+ return v === 'transcript' || v === 'send' ? v : undefined;
+}
+
/** Admins = all resolved allowedUsers, matching `/botconfig`'s permission model. */
export function getDashboardAdminOpenIds(larkAppId: string): string[] {
return [...(bots.get(larkAppId)?.resolvedAllowedUsers ?? [])];
@@ -3532,6 +3554,8 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
: undefined,
disableCliBypass: entry.disableCliBypass === true,
codexAppCleanInput: entry.codexAppCleanInput === true || undefined,
+ // 显式 send / transcript 都保留:claude-code 缺省 transcript,写 send 才是退回旧行为。
+ replyDelivery: entry.replyDelivery === 'transcript' || entry.replyDelivery === 'send' ? entry.replyDelivery : undefined,
codexBrowser,
codexRpcInput: entry.codexRpcInput === true,
existingAppServer,
diff --git a/src/cli.ts b/src/cli.ts
index f35cb376da..e0422eb0b4 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -5611,50 +5611,6 @@ function findDaemon(larkAppId?: string): DaemonDescriptorLite | null {
return listOnlineDaemons()[0] ?? null;
}
-function normalizeCardUsageSnapshot(value: unknown): CardUsageSnapshot | null {
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
- const raw = value as Record;
- const rawContext = raw.context;
- const rawTokens = raw.tokens;
-
- let context: CardUsageSnapshot['context'] = null;
- if (rawContext && typeof rawContext === 'object' && !Array.isArray(rawContext)) {
- const c = rawContext as Record;
- if (typeof c.usedTokens === 'number'
- && Number.isFinite(c.usedTokens)
- && c.usedTokens >= 0) {
- context = {
- usedTokens: c.usedTokens,
- ...(typeof c.windowTokens === 'number'
- && Number.isFinite(c.windowTokens)
- && c.windowTokens > 0
- ? { windowTokens: c.windowTokens }
- : {}),
- ...(typeof c.percentUsed === 'number'
- && Number.isFinite(c.percentUsed)
- && c.percentUsed >= 0
- ? { percentUsed: c.percentUsed }
- : {}),
- };
- }
- }
-
- let tokens: CardUsageSnapshot['tokens'] = null;
- if (rawTokens && typeof rawTokens === 'object' && !Array.isArray(rawTokens)) {
- const u = rawTokens as Record;
- if (typeof u.in === 'number'
- && Number.isFinite(u.in)
- && u.in >= 0
- && typeof u.out === 'number'
- && Number.isFinite(u.out)
- && u.out >= 0) {
- tokens = { in: u.in, out: u.out };
- }
- }
-
- return { context, tokens };
-}
-
/** Prefer the resident daemon's incremental transcript cache. Older/offline
* daemons and isolated environments fall back to the local reader; either path
* degrades to explicit unavailable facts without blocking the reply. */
@@ -5698,7 +5654,7 @@ async function readCardUsageSnapshotForSend(
}
try {
- return getSessionUsageSnapshot({
+ const snapshot = getSessionUsageSnapshot({
cliId: (session.cliId ?? session.adoptedFrom?.cliId ?? 'unknown') as CliId | 'unknown',
sessionId: session.sessionId,
cliSessionId: session.cliSessionId ?? session.adoptedFrom?.sessionId,
@@ -5710,6 +5666,14 @@ async function readCardUsageSnapshotForSend(
// 定价覆盖:从 bot 配置解析,未配置时 undefined(costCny 缺省)。
pricing: larkAppId ? resolvePricingForCli(larkAppId) : undefined,
});
+ // Claude statusline 配额段(与 daemon 侧 getDaemonSessionUsageSnapshot 同口径):
+ // 本地直接读 /statusline//latest.json;沙盒内该目录对本会话可读写。
+ // 非空才带 key;读取失败不影响 transcript 用量。
+ try {
+ const quota = toCardQuota(readStatuslineSnapshot(resolveDataDir(), session.sessionId));
+ if (quota) return { ...snapshot, quota };
+ } catch { /* best-effort */ }
+ return snapshot;
} catch {
return { context: null, tokens: null };
}
@@ -7606,6 +7570,8 @@ import { loadCompanionSecret } from './dashboard/companion-api.js';
import { applyCompanionStartupOptions } from './cli/companion-startup-options.js';
import { unknownFleetArgs } from './cli/fleet-args.js';
import { getSessionUsageSnapshot } from './core/cost-calculator.js';
+import { normalizeCardUsageSnapshot } from './cli/card-usage-normalize.js';
+import { parseStatuslinePayload, readStatuslineSnapshot, toCardQuota, writeStatuslineSnapshot } from './services/statusline-snapshot.js';
import {
resolveQuoteTarget,
shouldDropAfterTheFactTopicQuote,
@@ -12735,22 +12701,30 @@ async function cmdSessionReady(): Promise {
// fail-open 铁律:任何失败(env 缺失 = 非 botmux 会话、daemon 不可达、未命中 =
// 用户手输或 inline 模式、403/404)都空输出 + exit 0。绝不 exit 2(会阻塞该轮
// prompt),绝不抛错(Claude 对 hook 失败的兜底是放弃注入,正合预期)。
-async function cmdUserPromptHook(): Promise {
- // 5s 自限时读 stdin:Claude 写完 payload 会关 stdin,正常情况下立即结束;
- // 万一上游不关管道,也不能挂住 hook(settings.json 里的 10s timeout 是第二道)。
- let payloadText = '';
+/**
+ * 自限时读完 stdin(原始字节)。Claude 写完 hook / statusline payload 会关 stdin,
+ * 正常情况下立即结束;万一上游不关管道,也不能挂住子进程(settings.json 里的 hook
+ * timeout 是第二道)。超时 / 读不到 ⇒ 返回已收到的部分(可能为空),从不抛错。
+ * user-prompt-hook 与 statusline 共用。
+ */
+async function readStdinWithTimeout(ms: number): Promise {
+ const chunks: Buffer[] = [];
try {
- const chunks: Buffer[] = [];
let timedOut = false;
- const timer = setTimeout(() => { timedOut = true; try { process.stdin.destroy(); } catch { /* */ } }, 5000);
+ const timer = setTimeout(() => { timedOut = true; try { process.stdin.destroy(); } catch { /* */ } }, ms);
if (typeof timer.unref === 'function') timer.unref();
for await (const chunk of process.stdin) {
if (timedOut) break;
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
clearTimeout(timer);
- payloadText = Buffer.concat(chunks).toString('utf-8');
- } catch { /* stdin 读不到 → no-op */ }
+ } catch { /* stdin 读不到 → 返回已收到的部分 */ }
+ return Buffer.concat(chunks);
+}
+
+async function cmdUserPromptHook(): Promise {
+ // 5s 自限时读 stdin(见 readStdinWithTimeout)。
+ const payloadText = (await readStdinWithTimeout(5000)).toString('utf-8');
const sessionId = process.env.BOTMUX_SESSION_ID;
// env 缺失 → adopt / 非 botmux 会话 / 用户手输,静默放行。
@@ -12828,6 +12802,92 @@ async function cmdUserPromptHook(): Promise {
process.exit(0);
}
+// ─── botmux statusline ───────────────────────────────────────────────────────
+//
+// Claude Code `statusLine.command` 客户端(由 claude-code.ts buildArgs 经进程级
+// --settings 注入)。Claude 在每条 assistant 消息后 / compact 后 / 到达 resets_at /
+// 每 refreshInterval 秒(300ms 防抖)把 JSON(context_window、rate_limits、model …)
+// 喂到 stdin。本命令做两件互不影响的事,各自 try/catch:
+// 1. 落盘:BOTMUX_SESSION_ID 非空且 payload 可解析 ⇒ 写
+// `/statusline//latest.json`(daemon 合并进卡片用量段)。
+// 2. 转发:BOTMUX_STATUSLINE_CHAIN 非空(worker 找回的、被 --settings 遮蔽的用户
+// statusLine 命令)⇒ `/bin/sh -c `,把**原始 stdin 字节**原样喂给它,
+// stdout/stderr 直接继承(Claude 读的是本进程的 stdout),正常退出透传其退出码;
+// 10s 看门狗 SIGTERM → 1s 后 SIGKILL → exit 0;被信号杀 / spawn 失败 → exit 0。
+// 无 chain ⇒ stdout 空、exit 0(与用户没配 statusline 时一致:状态栏空)。
+// 诊断只走 stderr(stdout 是状态栏内容);顶层兜底 exit 0——statusline 失败对 Claude
+// 只是「状态栏空」,绝不能让它挂住或刷错误。
+const STATUSLINE_STDIN_TIMEOUT_MS = 5000;
+const STATUSLINE_CHAIN_TIMEOUT_MS = 10_000;
+const STATUSLINE_CHAIN_KILL_GRACE_MS = 1000;
+
+function statuslineDiagnostic(message: string): void {
+ try { process.stderr.write(`[botmux statusline] ${message}\n`); } catch { /* */ }
+}
+
+/** 把原始 payload 转发给用户自己的 statusline 命令;本函数负责最终 process.exit。 */
+function forwardStatuslineChain(chain: string, raw: Buffer): void {
+ let child: ReturnType;
+ try {
+ // detached:让 sh 及其子进程独占一个进程组,看门狗按组杀——用户脚本常是
+ // `bash ~/.claude/statusline.sh`(内部再 spawn jq / git),只杀 sh 会留下握着
+ // stdout 管道的孤儿,Claude 读不到 EOF 会一直等。
+ child = spawn('/bin/sh', ['-c', chain], { stdio: ['pipe', 'inherit', 'inherit'], detached: true });
+ } catch (error) {
+ statuslineDiagnostic(`chain spawn failed: ${error instanceof Error ? error.message : String(error)}`);
+ process.exit(0);
+ }
+ const killGroup = (signal: NodeJS.Signals) => {
+ if (child.pid) {
+ try { process.kill(-child.pid, signal); return; } catch { /* 组已不在 → 退回单进程 */ }
+ }
+ try { child.kill(signal); } catch { /* */ }
+ };
+ let killTimer: NodeJS.Timeout | undefined;
+ const watchdog = setTimeout(() => {
+ killGroup('SIGTERM');
+ killTimer = setTimeout(() => {
+ killGroup('SIGKILL');
+ process.exit(0);
+ }, STATUSLINE_CHAIN_KILL_GRACE_MS);
+ }, STATUSLINE_CHAIN_TIMEOUT_MS);
+ const finish = (code: number) => {
+ clearTimeout(watchdog);
+ if (killTimer) clearTimeout(killTimer);
+ process.exit(code);
+ };
+ child.once('error', (error) => {
+ statuslineDiagnostic(`chain failed: ${error instanceof Error ? error.message : String(error)}`);
+ finish(0);
+ });
+ // 被信号杀(含看门狗)⇒ 0;正常退出透传退出码。
+ child.once('exit', (code, signal) => finish(signal ? 0 : (code ?? 0)));
+ // 用户命令可能不读 stdin 就退出 → EPIPE,吞掉即可。
+ child.stdin?.on('error', () => { /* */ });
+ child.stdin?.end(raw);
+}
+
+async function cmdStatusline(): Promise {
+ try {
+ const raw = await readStdinWithTimeout(STATUSLINE_STDIN_TIMEOUT_MS);
+ const sessionId = process.env.BOTMUX_SESSION_ID;
+ // env 缺失 ⇒ 非 botmux 会话(用户手跑 / adopt),不落盘,只做转发。
+ if (sessionId) {
+ try {
+ const snap = parseStatuslinePayload(JSON.parse(raw.toString('utf-8')));
+ if (snap) writeStatuslineSnapshot(resolveDataDir(), sessionId, snap);
+ } catch (error) {
+ statuslineDiagnostic(`snapshot not written: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ const chain = process.env.BOTMUX_STATUSLINE_CHAIN;
+ if (!chain) process.exit(0);
+ forwardStatuslineChain(chain, raw);
+ } catch {
+ process.exit(0);
+ }
+}
+
// ─── botmux native-subagent-runtime-hook ─────────────────────────────────────
const NATIVE_SUBAGENT_HOOK_STDIN_MAX_BYTES = 256 * 1024;
@@ -13868,6 +13928,9 @@ if (process.env.BOTMUX_WORKFLOW === '1') {
// workflow, deployment, or external messaging effect.
'preview',
'session-ready',
+ // Claude statusLine.command 客户端:只写本会话的配额快照 + 转发用户 statusline,
+ // 无聊天 / workflow 副作用;workflow worker 里的 Claude 也会每分钟调它。
+ 'statusline',
'mcp',
'ask', // dedicated cmdAsk guard emits the humanGate-specific guidance
'schedule',
@@ -14940,6 +15003,12 @@ switch (command) {
await cmdUserPromptHook();
break;
}
+ case 'statusline': {
+ // `botmux statusline` — Claude Code statusLine.command 客户端:落盘 ctx/5h/7d
+ // 配额快照,并把原始 payload 转发给被遮蔽的用户 statusline(BOTMUX_STATUSLINE_CHAIN)。
+ await cmdStatusline();
+ break;
+ }
case 'native-subagent-runtime-hook': {
await cmdNativeSubagentRuntimeHook();
break;
diff --git a/src/cli/card-usage-normalize.ts b/src/cli/card-usage-normalize.ts
new file mode 100644
index 0000000000..9cf1ddd409
--- /dev/null
+++ b/src/cli/card-usage-normalize.ts
@@ -0,0 +1,89 @@
+/**
+ * card-usage-normalize.ts — `botmux send` 从 daemon IPC `/api/sessions/:id/usage`
+ * 读回的用量快照的白名单式规范化。
+ *
+ * 白名单而非透传:IPC 响应来自 daemon 进程(可信),但版本可能不一致(旧 daemon /
+ * 新 CLI 或反之),逐字段校验后 CLI 侧的卡片渲染永远不会拿到 NaN / 负数 / 字符串。
+ * 每个字段独立判定,不合法就丢弃该字段而不是整份快照。
+ *
+ * 独立成模块(不放 cli.ts):cli.ts 顶层就是命令分发 switch,测试无法直接 import。
+ */
+import type { CardUsageSnapshot } from '../im/lark/md-card.js';
+import type { StatuslineQuota } from '../services/statusline-snapshot.js';
+
+function nonNegativeFinite(v: unknown): v is number {
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
+}
+
+/** 0–100 的百分比;越界 / 非数值 ⇒ undefined。 */
+function percentField(v: unknown): number | undefined {
+ return nonNegativeFinite(v) && v <= 100 ? v : undefined;
+}
+
+/** 正毫秒时间戳;`<= 0` / 非数值 ⇒ undefined。 */
+function resetsAtField(v: unknown): number | undefined {
+ return nonNegativeFinite(v) && v > 0 ? v : undefined;
+}
+
+/** statusline 配额段(ctx / 5h / 7d)。一个字段都没通过 ⇒ undefined(调用方不带 key)。 */
+export function normalizeCardUsageQuota(value: unknown): StatuslineQuota | undefined {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
+ const q = value as Record;
+ const out: StatuslineQuota = {};
+ const set = (k: K, v: StatuslineQuota[K] | undefined) => {
+ if (v !== undefined) out[k] = v;
+ };
+ set('contextPercent', percentField(q.contextPercent));
+ set('contextWindowTokens', nonNegativeFinite(q.contextWindowTokens) && q.contextWindowTokens > 0 ? q.contextWindowTokens : undefined);
+ set('fiveHourPercent', percentField(q.fiveHourPercent));
+ set('fiveHourResetsAtMs', resetsAtField(q.fiveHourResetsAtMs));
+ set('sevenDayPercent', percentField(q.sevenDayPercent));
+ set('sevenDayResetsAtMs', resetsAtField(q.sevenDayResetsAtMs));
+ return Object.keys(out).length > 0 ? out : undefined;
+}
+
+export function normalizeCardUsageSnapshot(value: unknown): CardUsageSnapshot | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ const raw = value as Record;
+ const rawContext = raw.context;
+ const rawTokens = raw.tokens;
+
+ let context: CardUsageSnapshot['context'] = null;
+ if (rawContext && typeof rawContext === 'object' && !Array.isArray(rawContext)) {
+ const c = rawContext as Record;
+ if (typeof c.usedTokens === 'number'
+ && Number.isFinite(c.usedTokens)
+ && c.usedTokens >= 0) {
+ context = {
+ usedTokens: c.usedTokens,
+ ...(typeof c.windowTokens === 'number'
+ && Number.isFinite(c.windowTokens)
+ && c.windowTokens > 0
+ ? { windowTokens: c.windowTokens }
+ : {}),
+ ...(typeof c.percentUsed === 'number'
+ && Number.isFinite(c.percentUsed)
+ && c.percentUsed >= 0
+ ? { percentUsed: c.percentUsed }
+ : {}),
+ };
+ }
+ }
+
+ let tokens: CardUsageSnapshot['tokens'] = null;
+ if (rawTokens && typeof rawTokens === 'object' && !Array.isArray(rawTokens)) {
+ const u = rawTokens as Record;
+ if (typeof u.in === 'number'
+ && Number.isFinite(u.in)
+ && u.in >= 0
+ && typeof u.out === 'number'
+ && Number.isFinite(u.out)
+ && u.out >= 0) {
+ tokens = { in: u.in, out: u.out };
+ }
+ }
+
+ // statusline 配额段:只在至少一个字段合法时才带 key,保证「无数据 ⇒ 卡片与现状逐字节相同」。
+ const quota = normalizeCardUsageQuota(raw.quota);
+ return quota ? { context, tokens, quota } : { context, tokens };
+}
diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts
index 0782e54652..7adb827fb6 100644
--- a/src/core/dashboard-ipc-server.ts
+++ b/src/core/dashboard-ipc-server.ts
@@ -110,6 +110,7 @@ import type {
OpenPlatformDescriptionUpdateResult,
} from '../services/open-platform-rename.js';
import { findConfigField, applyConfigField, coerceConfigValue, setChatFeedbackPolicy } from '../services/bot-config-store.js';
+import { defaultReplyDeliveryFor, effectiveReplyDelivery, supportsTranscriptReplyDelivery } from './reply-delivery.js';
import { traceFeedbackPolicyForDelivery } from '../services/feedback-policy-resolver.js';
import { globalBuiltinSkillInjectionDefault, resolveSkillInjectionSupport } from '../skills/injection-mode.js';
import { summaryRangeFromBotConfig, updateDashboardSummaryRange } from '../services/summary-range-store.js';
@@ -5059,6 +5060,18 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
} catch { /* default chat */ }
let envelopeInjection: 'auto' | 'off' = 'off';
try { if (getBot(cachedLarkAppId).config.envelopeInjection === 'auto') envelopeInjection = 'auto'; } catch { /* default off */ }
+ // 最终回复投递方式:给 dashboard 的是**生效值**(显式配置 → 否则按 CLI 缺省,
+ // claude-code 缺省 transcript)+ 该 CLI 的缺省值 + 当前 CLI 是否支持 transcript
+ // (dashboard 据此禁用开关并说明)。
+ let replyDelivery: 'send' | 'transcript' = 'send';
+ let replyDeliveryDefault: 'send' | 'transcript' = 'send';
+ let replyDeliverySupported = false;
+ try {
+ const cfg = getBot(cachedLarkAppId).config;
+ replyDelivery = effectiveReplyDelivery(cachedLarkAppId, cfg.cliId);
+ replyDeliveryDefault = defaultReplyDeliveryFor(cfg.cliId);
+ replyDeliverySupported = supportsTranscriptReplyDelivery(cfg.cliId);
+ } catch { /* default send */ }
let codexAuthSync: 'shared' | 'isolated' = 'shared';
try { if (getBot(cachedLarkAppId).config.codexAuthSync === 'isolated') codexAuthSync = 'isolated'; } catch { /* default shared */ }
let skillInjection: 'global' | 'prompt' | 'off' | null = null;
@@ -5259,6 +5272,9 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
grantDefaultDurationMs: grantPrefs.grantDefaultDurationMs,
p2pMode,
envelopeInjection,
+ replyDelivery,
+ replyDeliveryDefault,
+ replyDeliverySupported,
skillInjection,
skillInjectionSupport,
// Resolved machine-wide default → the dashboard shows it as the pre-selected
@@ -6308,6 +6324,34 @@ ipcRoute('PUT', '/api/bot-envelope-injection', async (req, res) => {
jsonRes(res, 200, { ok: true, envelopeInjection: value ?? 'off' });
});
+// Per-bot 最终回复投递方式 replyDelivery。Body `{ replyDelivery: 'transcript'|'send'|'' }`:
+// • 'transcript' → daemon 从 CLI 转写自动取本轮最后的 assistant 文本发最终回复卡,
+// 模型不再被要求 botmux send;仅 claude-code 与结构化转写白名单 CLI 支持,其它
+// CLI 由 store 拒绝(400 reply_delivery_unsupported)。落盘 'transcript'
+// • 'send' → 模型必须自己 botmux send。落盘 'send'(claude-code 退回旧行为的唯一方式)
+// • ''/其它 → 删 key,回到该 CLI 的缺省(claude-code=transcript,其它=send)
+// 走 applyConfigField(与 /botconfig 同一写盘 + 热更新路径):逐轮信封下一轮生效,
+// 系统提示部分要 /restart 才换新值。响应里的 replyDelivery 是写入后的**生效值**。
+ipcRoute('PUT', '/api/bot-reply-delivery', async (req, res) => {
+ if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
+ let body: { replyDelivery?: unknown };
+ try { body = await readJsonBody<{ replyDelivery?: unknown }>(req); }
+ catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }
+
+ const spec = findConfigField('replyDelivery');
+ if (!spec) return jsonRes(res, 500, { ok: false, error: 'spec_missing' });
+ const value = body.replyDelivery === 'transcript' || body.replyDelivery === 'send' ? body.replyDelivery : null;
+ const r = await applyConfigField(cachedLarkAppId, spec, value);
+ if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason });
+ let cliId: string | undefined;
+ try { cliId = getBot(cachedLarkAppId).config.cliId; } catch { cliId = undefined; }
+ jsonRes(res, 200, {
+ ok: true,
+ replyDelivery: effectiveReplyDelivery(cachedLarkAppId, cliId),
+ replyDeliveryDefault: defaultReplyDeliveryFor(cliId),
+ });
+});
+
// Per-bot 内置技能注入模式 skillInjection。Body `{ skillInjection: 'global'|'prompt'|'off'|'' }`:
// • 'global'|'prompt'|'off' → 显式覆盖本 bot
// • ''/其它 → 清回机器级默认(config.json skills.builtinInjection)
diff --git a/src/core/reply-delivery.ts b/src/core/reply-delivery.ts
new file mode 100644
index 0000000000..7e91b74937
--- /dev/null
+++ b/src/core/reply-delivery.ts
@@ -0,0 +1,93 @@
+/**
+ * 最终回复投递方式(per-bot `replyDelivery`)与 solo 会话判定。
+ *
+ * 纯函数层:不 import im/lark,不碰 daemon 状态,daemon / session-manager /
+ * worker-pool 都从这里取同一套判定,避免三处各写一份白名单。
+ *
+ * - `send`:模型必须自己 `botmux send`,系统提示与每轮 reminder 都这么要求。
+ * - `transcript`:daemon 从 CLI 转写自动取本轮最后的 assistant 文本发最终回复卡
+ * (bridge fallback 升为主通道);系统提示不再提及 `botmux send`、不注入每轮
+ * reminder;solo 会话去掉 `` 壳与 ``。
+ *
+ * 缺省值按 CLI 走(`defaultReplyDeliveryFor`):claude-code 缺省 `transcript`,其余
+ * 缺省 `send`;bots.json 显式写 `send` / `transcript` 才覆盖。所有判定 fail-closed:
+ * 拿不准就回到 `send` / 非 solo。
+ */
+import { getOwnerOpenId, resolveReplyDelivery } from '../bot-registry.js';
+import { isStructuredBridgeFallbackActive } from '../services/structured-bridge-clis.js';
+import { logger } from '../utils/logger.js';
+
+export type ReplyDelivery = 'send' | 'transcript';
+
+/** claude-code 走 `claudeDataDir` 转写桥(worker.ts bridge fallback),其余按
+ * 结构化转写白名单(codex/traex/coco/hermes/mtr/pi/oh-my-pi/ebsd/grok)。
+ * cursor 只在 adopt 下有转写,不算;codex-app 天然是转写模式,不需要本开关。 */
+export function supportsTranscriptReplyDelivery(cliId: string | undefined): boolean {
+ if (!cliId) return false;
+ if (cliId === 'claude-code') return true;
+ return isStructuredBridgeFallbackActive(cliId, false);
+}
+
+/** 未显式配置时的缺省投递方式:claude-code 的最终回复由 daemon 从转写自动转发
+ * (transcript),模型不再被教「botmux send」;其它 CLI 保持 send。 */
+export function defaultReplyDeliveryFor(cliId: string | undefined): ReplyDelivery {
+ return cliId === 'claude-code' ? 'transcript' : 'send';
+}
+
+const warnedUnsupported = new Set();
+
+/** 运行时生效值:显式配置(send / transcript)优先,未配置按 CLI 缺省;结果为
+ * transcript 但当前 CLI 不支持时回落 send(每个 bot+cli 组合只 warn 一次,避免
+ * 每轮刷日志)。无 larkAppId / registry 异常 → send(fail-closed)。 */
+export function effectiveReplyDelivery(larkAppId: string | undefined, cliId: string | undefined): ReplyDelivery {
+ if (!larkAppId) return 'send';
+ let configured: ReplyDelivery | undefined;
+ try { configured = resolveReplyDelivery(larkAppId); } catch { return 'send'; }
+ const wanted: ReplyDelivery = configured ?? defaultReplyDeliveryFor(cliId);
+ if (wanted !== 'transcript') return 'send';
+ if (supportsTranscriptReplyDelivery(cliId)) return 'transcript';
+ const key = `${larkAppId}:${cliId ?? ''}`;
+ if (!warnedUnsupported.has(key)) {
+ warnedUnsupported.add(key);
+ logger.warn(`[reply-delivery] bot ${larkAppId} 配置 replyDelivery=transcript,但 cliId=${cliId ?? '(none)'} 没有转写采集,回落 send`);
+ }
+ return 'send';
+}
+
+export interface SoloSessionInput {
+ chatType: 'group' | 'p2p' | undefined;
+ /** `getChatMode` 结果:'group' 普通群 / 'topic' 话题群;undefined = 未知。 */
+ chatMode: 'group' | 'topic' | undefined;
+ /** `getGroupStats` 结果;API 失败时上游返回 {999,999},天然非 solo。 */
+ stats: { userCount: number; botCount: number } | undefined;
+ senderType: 'user' | 'bot' | undefined;
+ senderOpenId: string | undefined;
+ ownerOpenId: string | undefined;
+}
+
+/**
+ * solo = 只有 owner 和本 bot 两个参与者的会话:
+ * - p2p 私聊恒为 solo;
+ * - 普通群(非话题群)且 user_count ≤ 1 且 bot_count ≤ 1,且本轮发言者是 owner;
+ * - 其余(话题群、未知群模式、成员数未知、非 owner、bot 发言)一律非 solo。
+ * 这是判定「可以去掉 sender/壳」的门,宁可漏判也不误判。
+ */
+export function computeSoloSession(input: SoloSessionInput): boolean {
+ if (input.chatType === 'p2p') return true;
+ if (input.chatType !== 'group') return false;
+ if (input.chatMode !== 'group') return false;
+ if (!input.stats || input.stats.userCount > 1 || input.stats.botCount > 1) return false;
+ if (input.senderType !== 'user') return false;
+ if (!input.senderOpenId || !input.ownerOpenId) return false;
+ return input.senderOpenId === input.ownerOpenId;
+}
+
+/** 便捷封装:owner 从 registry 取。 */
+export function computeSoloSessionForBot(
+ larkAppId: string,
+ input: Omit,
+): boolean {
+ let ownerOpenId: string | undefined;
+ try { ownerOpenId = getOwnerOpenId(larkAppId); } catch { ownerOpenId = undefined; }
+ return computeSoloSession({ ...input, ownerOpenId });
+}
diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts
index 77a057e308..e4a5cbbc29 100644
--- a/src/core/session-manager.ts
+++ b/src/core/session-manager.ts
@@ -17,6 +17,7 @@ import { createCliAdapterSync } from '../adapters/cli/registry.js';
import type { CliAdapter } from '../adapters/cli/types.js';
import { botHomePath } from '../adapters/cli/read-isolation.js';
import { buildBotmuxShellHints, buildCredentialBoundaryBlock } from '../adapters/cli/shared-hints.js';
+import { effectiveReplyDelivery, type ReplyDelivery } from './reply-delivery.js';
import {
resolveSkillInjectionModeForApp,
builtinSkillEntries,
@@ -997,7 +998,7 @@ export function ensureSessionWhiteboard(ds: DaemonSession): void {
}
}
-function renderWhiteboardBlock(opts?: { whiteboardId?: string; noTransport?: boolean }): string {
+function renderWhiteboardBlock(opts?: { whiteboardId?: string; noTransport?: boolean; replyDelivery?: ReplyDelivery }): string {
if (!whiteboardEnabled() || !opts?.whiteboardId) return '';
const meta = getWhiteboard(opts.whiteboardId);
if (!meta || meta.archived) return '';
@@ -1012,9 +1013,13 @@ function renderWhiteboardBlock(opts?: { whiteboardId?: string; noTransport?: boo
// 要消除的那条矛盾指令的又一个出口——send 在这类会话里被 assertTurnTransportOrExit
// 硬拦(exit 2),而 又明说不要 send。白板块在首轮与
// 续轮都无条件注入,所以这里必须同样 gate;隐私/本地文件两条与传输无关,保留。
+ // replyDelivery=transcript:最终回复由 daemon 从转写自动转发,「仍必须 send」同样
+ // 与改口后的系统提示矛盾,换成「写进最终回复即可」;noTransport 优先级更高。
opts.noTransport
? '不要直接读写本地文件;不要写密钥/隐私。'
- : '不要直接读写本地文件;不要写密钥/隐私;用户可见结论仍必须 `botmux send`。',
+ : opts.replyDelivery === 'transcript'
+ ? '不要直接读写本地文件;不要写密钥/隐私;用户可见结论写进最终回复即可。'
+ : '不要直接读写本地文件;不要写密钥/隐私;用户可见结论仍必须 `botmux send`。',
'',
].join('\n');
}
@@ -1145,6 +1150,14 @@ function triggerUserAuthEnabledForPrompt(larkAppId?: string): boolean {
catch { return false; }
}
+/** 本会话的最终回复投递方式(per-bot replyDelivery × 该 CLI 的转写能力,见
+ * core/reply-delivery.ts)。缺参 / bot 未加载 / 任何异常 → 'send'(fail-closed:
+ * 信封字节等于今天)。noTransport 的优先级由各调用点自己叠加。 */
+function replyDeliveryFor(larkAppId?: string, cliId?: string): ReplyDelivery {
+ if (!larkAppId || !cliId) return 'send';
+ try { return effectiveReplyDelivery(larkAppId, cliId); } catch { return 'send'; }
+}
+
/** opening 构建选项。在原有 larkAppId/chatId/whiteboardId 等之外,新增 hook 模式
* (#794 后续)所需的 turnId 与 sessionBackendType:turnId 是 opening 轮的权威
* turnId(= 发给 worker 的 turnId,最终成为 managedTurnOrigin.turnId),用于
@@ -1157,6 +1170,12 @@ type NewTopicOpts = {
chatContext?: ChatContext;
turnId?: string;
sessionBackendType?: BackendType;
+ /** replyDelivery=transcript 且本轮是 solo 会话(daemon 算好的 ds.soloSession):
+ * 去掉 壳与 sender/attachments/mentions 块,改用裸文本 +
+ * `[附件]`/`[@提及]` 行(buildBridgeInputContent)。send 模式下忽略。 */
+ solo?: boolean;
+ /** solo 裸文本时用于剥掉开头的自 @(同 buildBridgeInputContent)。 */
+ selfMention?: { name?: string | null; openId?: string | null };
};
type NewTopicBlockKey = 'routing' | 'skill' | 'identity' | 'credentials' | 'sessionId' | 'role'
@@ -1195,9 +1214,14 @@ function buildNewTopicBlocks(
// static `adapter.systemHints` array that was baked at module load.
// No-transport sessions (apiOnly bot / HTTP virtual chat) get the collapsed
// hints (hidden-context defense only) — same gate as buildBotmuxSystemPromptText.
+ // replyDelivery=transcript 只在有传输的会话上生效(noTransport 优先);bare =
+ // transcript + solo,首轮同样去壳。
+ const noTransport = sessionIsNoTransport(opts?.larkAppId, opts?.chatId);
+ const replyDelivery: ReplyDelivery = noTransport ? 'send' : replyDeliveryFor(opts?.larkAppId, cliId);
+ const bare = replyDelivery === 'transcript' && opts?.solo === true;
const hints = adapter.injectsSessionContext
? []
- : buildBotmuxShellHints(locale, sessionIsNoTransport(opts?.larkAppId, opts?.chatId));
+ : buildBotmuxShellHints(locale, noTransport, replyDelivery);
const routingBlock = hints.length > 0
? `\n${hints.join('\n')}\n`
@@ -1231,12 +1255,14 @@ function buildNewTopicBlocks(
// the routing block above and the system-prompt identity path in
// buildBotmuxSystemPromptText. botIdentity is passed even for a NORMAL bot
// running an HTTP task (R1), so this block reaches HTTP turns and must gate too.
- const identityNoTransport = sessionIsNoTransport(opts?.larkAppId, opts?.chatId);
+ // transcript(含 solo)同样只留 name/open_id:short_routing 整句是「协作必须
+ // botmux send --mention」,transcript 模式的提示不再提 send;solo 会话更没有
+ // 别的 bot 可路由。
identityBlock = [
'',
` ${xmlEscape(botIdentity.name ?? unknown)}`,
` ${xmlEscape(botIdentity.openId ?? unknown)}`,
- ...(identityNoTransport
+ ...(noTransport || replyDelivery === 'transcript'
? []
: [` ${escapeXmlTagLikeTokens(t('ai.identity.short_routing', undefined, locale))}`]),
'',
@@ -1246,7 +1272,8 @@ function buildNewTopicBlocks(
const roleBlock = renderApplicationRoleBlock(opts?.larkAppId, opts?.chatId);
const whiteboardBlock = renderWhiteboardBlock({
whiteboardId: opts?.whiteboardId,
- noTransport: sessionIsNoTransport(opts?.larkAppId, opts?.chatId),
+ noTransport,
+ replyDelivery,
});
const summaryMemoryBlock = renderSummaryMemoryBlock(opts?.larkAppId);
const chatContextPolicyBlock = renderChatContextPolicyBlock(opts?.chatContext, locale);
@@ -1264,10 +1291,14 @@ function buildNewTopicBlocks(
const mergedMessage = followUps && followUps.length > 0
? [userMessage, ...followUps].join('\n\n')
: userMessage;
+ // bare(transcript + solo):裸文本 + `[附件]`/`[@提及]` 行,附件/提及已折进正文,
+ // 下面的 sender / senderNote / attachHint / mentionBlock 一并跳过。
// hook 模式(#794 后续):PTY 文本只保留用户正文,不再包 外壳。
// 理由同 follow-up:会话发现主防线是 collectBotmuxSessionIdentities 按文件名排除,
- // 标题提取有 ?? rawContent 兜底。inline 模式保持原样。
- const userBlock = hookMode ? mergedMessage : `\n${mergedMessage}\n`;
+ // 标题提取有 ?? rawContent 兜底。inline 且非 bare 时保持原样。
+ const userBlock = bare
+ ? buildBridgeInputContent(mergedMessage, { attachments, mentions, selfMention: opts?.selfMention, locale })
+ : hookMode ? mergedMessage : `\n${mergedMessage}\n`;
const blocks: Array<{ key: NewTopicBlockKey; text: string }> = [];
// Put stable, instruction-like context before the user's first turn. This
@@ -1309,21 +1340,21 @@ function buildNewTopicBlocks(
blocks.push({ key: 'userMessage', text: userBlock });
- const senderBlock = renderSenderTag(sender, opts?.larkAppId);
+ const senderBlock = bare ? '' : renderSenderTag(sender, opts?.larkAppId);
if (senderBlock) blocks.push({ key: 'sender', text: senderBlock });
const substituteBlock = renderSubstituteTrigger(opts?.substituteTrigger);
if (substituteBlock) blocks.push({ key: 'substitute', text: substituteBlock });
- const senderNote = renderCursorSenderNote(cliId, !!senderBlock, locale);
+ const senderNote = bare ? '' : renderCursorSenderNote(cliId, !!senderBlock, locale);
if (senderNote) blocks.push({ key: 'senderNote', text: senderNote });
- const attachHint = formatAttachmentsHint(attachments, locale);
+ const attachHint = bare ? '' : formatAttachmentsHint(attachments, locale);
if (attachHint) blocks.push({ key: 'attachments', text: attachHint });
// CLIs with injectsSessionContext (Claude Code) get Lark routing/identity
// and session ID via system prompt, so skip those blocks here.
- if (mentionBlock) blocks.push({ key: 'mentions', text: mentionBlock });
+ if (mentionBlock && !bare) blocks.push({ key: 'mentions', text: mentionBlock });
if (botBlock) blocks.push({ key: 'availableBots', text: botBlock });
// The per-session skill catalog block is appended later in the worker-pool
// fork path (prepareSessionSkillPrompt), which also writes the manifest and
@@ -1383,6 +1414,9 @@ export function buildNewTopicCliInput(
/** Host-resolved identity for this turn. Only the caller knows where the
* turn came from, so it is passed in rather than derived here. */
trustedCaller?: CliTurnPayload['trustedCaller'];
+ /** 见 NewTopicOpts 同名字段。 */
+ solo?: boolean;
+ selfMention?: { name?: string | null; openId?: string | null };
/** opening 轮的权威 turnId(= 发给 worker 的 turnId,最终成为
* managedTurnOrigin.turnId)。hook 模式下用于 sidecar 绑定;缺失回退 inline。 */
turnId?: string;
@@ -1433,6 +1467,7 @@ export function buildNewTopicCliInput(
const whiteboardBlock = renderWhiteboardBlock({
whiteboardId: opts?.whiteboardId,
noTransport: sessionIsNoTransport(opts?.larkAppId, opts?.chatId),
+ replyDelivery: replyDeliveryFor(opts?.larkAppId, cliId),
});
const summaryMemoryBlock = renderSummaryMemoryBlock(opts?.larkAppId);
const senderBlock = renderSenderTag(sender, opts?.larkAppId);
@@ -1503,6 +1538,12 @@ type FollowUpOpts = {
turnId?: string;
/** Host-resolved identity for this turn (see buildNewTopicCliInput). */
trustedCaller?: CliTurnPayload['trustedCaller'];
+ /** replyDelivery=transcript 且本轮 solo(daemon 的 ds.soloSession):去掉
+ * 壳与 sender/attachments/mentions 块,裸文本 + `[附件]`/`[@提及]`
+ * 行(buildBridgeInputContent)。send 模式下忽略。 */
+ solo?: boolean;
+ /** solo 裸文本时剥掉开头的自 @(同 buildBridgeInputContent)。 */
+ selfMention?: { name?: string | null; openId?: string | null };
};
function buildFollowUpBlocks(
@@ -1512,10 +1553,17 @@ function buildFollowUpBlocks(
hookMode = false,
): Array<{ key: FollowUpBlockKey; text: string }> {
const blocks: Array<{ key: FollowUpBlockKey; text: string }> = [];
+ // replyDelivery=transcript(core/reply-delivery.ts):最终回复由 daemon 从转写自动
+ // 转发,续轮不再注入 ;noTransport 优先(HTTP 虚拟会话照旧走
+ // reminder_no_transport)。bare = transcript + solo → 信封去壳。
+ const noTransport = sessionIsNoTransport(opts?.larkAppId, opts?.chatId);
+ const transcript = !noTransport && replyDeliveryFor(opts?.larkAppId, opts?.cliId) === 'transcript';
+ const bare = transcript && opts?.solo === true;
const roleBlock = renderApplicationRoleBlock(opts?.larkAppId, opts?.chatId, { followUp: true });
const whiteboardBlock = renderWhiteboardBlock({
whiteboardId: opts?.whiteboardId,
- noTransport: sessionIsNoTransport(opts?.larkAppId, opts?.chatId),
+ noTransport,
+ replyDelivery: transcript ? 'transcript' : 'send',
});
const summaryMemoryBlock = renderSummaryMemoryBlock(opts?.larkAppId);
const skipSessionId = opts?.isAdoptMode || (opts?.cliId
@@ -1530,7 +1578,9 @@ function buildFollowUpBlocks(
if (!skipSessionId) blocks.push({ key: 'sessionId', text: `${xmlEscape(sessionId)}` });
if (roleBlock) blocks.push({ key: 'role', text: roleBlock });
if (summaryMemoryBlock) blocks.push({ key: 'summaryMemory', text: summaryMemoryBlock });
- if (opts?.cliId !== 'mira') {
+ // transcript:不注入 reminder(判定同样落在 KEY 选择层,hook 模式的 sidecar 自然为空
+ // → buildFollowUpCliInput 回退 inline 且不写 sidecar)。
+ if (opts?.cliId !== 'mira' && !transcript) {
// All non-Mira CLIs — including Hermes, which no longer gets reverse
// send-first guidance (#653) and now shares this standard path — get the
// anti-resend variant only when the experimental dashboard toggle is on
@@ -1546,7 +1596,6 @@ function buildFollowUpBlocks(
// 同样调本函数、但把 reminder 走 per-turn sidecar;只在 inline 分支 gate 会让 hook
// 模式的续轮 reminder 从 sidecar 漏出去。哨兵语义只在本轮内容的
// 出现一次(迁移不删,#808 async settle 依赖它)。
- const noTransport = sessionIsNoTransport(opts?.larkAppId, opts?.chatId);
const reminderKey = noTransport
? 'ai.followup.reminder_no_transport'
: hookMode
@@ -1557,28 +1606,39 @@ function buildFollowUpBlocks(
}
if (whiteboardBlock) blocks.push({ key: 'whiteboard', text: whiteboardBlock });
+ // bare(transcript + solo):裸文本 + `[附件]`/`[@提及]` 行,附件/提及已折进正文,
+ // 下面的 sender / senderNote / attachments / mentions 块一并跳过;substitute 保留。
// hook 模式(#794 后续):PTY 文本只保留用户正文,不再包 外壳。
// 外壳的唯一作用是给 transcript 消费方(会话发现 / 标题提取)做结构标记,
// 但会话发现的主防线是 collectBotmuxSessionIdentities 的按文件名排除(不依赖
// transcript 内容),标题提取有 ?? rawContent 兜底,所以 hook 模式下可以去掉。
- // inline 模式保持原样(其它 CLI 与旧会话发现正则仍依赖外壳)。
- blocks.push({ key: 'userMessage', text: hookMode ? content : `\n${content}\n` });
+ blocks.push({
+ key: 'userMessage',
+ text: bare
+ ? buildBridgeInputContent(content, {
+ attachments: opts?.attachments,
+ mentions: opts?.mentions,
+ selfMention: opts?.selfMention,
+ locale: opts?.locale,
+ })
+ : hookMode ? content : `\n${content}\n`,
+ });
- const senderBlock = renderSenderTag(opts?.sender, opts?.larkAppId);
+ const senderBlock = bare ? '' : renderSenderTag(opts?.sender, opts?.larkAppId);
if (senderBlock) blocks.push({ key: 'sender', text: senderBlock });
const substituteBlock = renderSubstituteTrigger(opts?.substituteTrigger);
if (substituteBlock) blocks.push({ key: 'substitute', text: substituteBlock });
- const senderNote = renderCursorSenderNote(opts?.cliId, !!senderBlock, opts?.locale);
+ const senderNote = bare ? '' : renderCursorSenderNote(opts?.cliId, !!senderBlock, opts?.locale);
if (senderNote) blocks.push({ key: 'senderNote', text: senderNote });
- const attachHint = opts?.attachments && opts.attachments.length > 0
+ const attachHint = !bare && opts?.attachments && opts.attachments.length > 0
? formatAttachmentsHint(opts.attachments, opts.locale)
: '';
if (attachHint) blocks.push({ key: 'attachments', text: attachHint });
- const mentionBlock = renderMentionBlock(opts?.mentions);
+ const mentionBlock = bare ? '' : renderMentionBlock(opts?.mentions);
if (mentionBlock) blocks.push({ key: 'mentions', text: mentionBlock });
return blocks;
@@ -1737,6 +1797,7 @@ export function buildFollowUpCliInput(
const whiteboardBlock = renderWhiteboardBlock({
whiteboardId: opts.whiteboardId,
noTransport: sessionIsNoTransport(opts.larkAppId, opts.chatId),
+ replyDelivery: replyDeliveryFor(opts.larkAppId, opts.cliId),
});
const summaryMemoryBlock = renderSummaryMemoryBlock(opts.larkAppId);
const senderBlock = renderSenderTag(opts.sender, opts.larkAppId);
@@ -1898,6 +1959,8 @@ export function buildReforkPrompt(
chatId: ds.session.chatId,
whiteboardId: ds.session.whiteboardId,
sessionBackendType: ds.session.backendType,
+ solo: ds.soloSession,
+ selfMention: opts?.selfMention,
});
}
@@ -1949,6 +2012,8 @@ export function buildReforkCliInput(
codexAppText: opts?.codexAppText,
codexAppApplicationContext: opts?.codexAppApplicationContext,
codexAppMessageContext: opts?.codexAppMessageContext,
+ solo: ds.soloSession,
+ selfMention: opts?.selfMention,
});
}
diff --git a/src/core/types.ts b/src/core/types.ts
index a0c309898c..bfe664612a 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -38,6 +38,9 @@ export interface FrozenCard {
/** Whether this historical turn deliberately completed with no reply. The
* value belongs to this frozen card, not to the session's latest turn. */
silentIdle?: boolean;
+ /** 冻结时的 idle 卡头标签:'silent' = 判定无需回复;'completed' = transcript
+ * 模式下最终回复卡已投递。新写入以此为准,`silentIdle` 仅为读旧盘保留。 */
+ idleLabel?: 'silent' | 'completed';
}
/** Resolve effective display mode for a frozen card.
@@ -351,6 +354,10 @@ export interface DaemonSession {
* silence" from "stuck". Cleared by every new-turn entry point
* (beginNewTurn and both worker-exited re-fork branches). In-memory only. */
silentIdleTurnId?: string;
+ /** transcript 模式(replyDelivery=transcript)下最终回复卡已投递成功的轮次:
+ * idle 时卡头显示「已完成」而非「等待输入」。清理点与 `silentIdleTurnId`
+ * 完全一致(每个新轮次入口)。内存态,不落盘。 */
+ completedIdleTurnId?: string;
/** turnId of the most recently STARTED turn (beginNewTurn and both
* worker-exited re-fork branches). Lineage anchor for `silentIdleTurnId`: a
* turn_terminal that lands after a NEWER turn already opened — the normal
@@ -359,6 +366,11 @@ export interface DaemonSession {
* the live card. Left undefined for sessions driven only by HTTP/async
* triggers, where an unknown-lineage turn stays trusted. In-memory only. */
currentTurnId?: string;
+ /** replyDelivery=transcript 下本轮是否 solo 会话(只有 owner 与本 bot:私聊,或
+ * 仅 owner + 本 bot 的普通群)。solo 时逐轮信封去壳:裸文本、无 。
+ * daemon 在构建 CLI 输入前按轮重算(resolveSoloSessionForTurn);send 模式恒为
+ * false 且不发额外 API。内存态,不持久化——重启后首轮重算即可。 */
+ soloSession?: boolean;
/** Dedupe guard: turnIds whose silent-turn auto receipt was already posted
* (dispatchAttempt replays must not double-post). A bounded FIFO Set, not a
* single slot: replays can interleave with other turns (A₁ → B → A₂), and a
diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts
index aefc47534d..e6c70e870b 100644
--- a/src/core/worker-pool.ts
+++ b/src/core/worker-pool.ts
@@ -12,6 +12,7 @@ import { ensureSkills, ensureAskSkill, ensurePluginSkills, ensureWhiteboardSkill
import { shouldInstallGlobalSkills } from '../skills/injection-mode.js';
import { whiteboardEnabled } from '../services/whiteboard-store.js';
import { cliSupportsNativeUsage } from '../services/transcript-resolver.js';
+import { readStatuslineSnapshot, toCardQuota } from '../services/statusline-snapshot.js';
import { cleanupTraexAskHooks, installHook } from '../adapters/hook-installer.js';
import { hookCommandFor } from '../adapters/hook-command.js';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
@@ -29,9 +30,10 @@ import {
import { persistStreamCardState, rememberLastCliInput } from './session-manager.js';
import { spawnWorker, isStandaloneBinary, WORKER_ENTRY_SUBCOMMAND } from './self-spawn.js';
import { resolveSessionLaunchModel } from './session-model.js';
+import { effectiveReplyDelivery } from './reply-delivery.js';
import { fallbackTurnId, frozenReplyContextForTurn, isSubstituteTurn, pickTurnReplyTarget, rehomeReplyTargetState, replyTargetKey } from './reply-target.js';
import { updateMessage, deleteMessage, pinMessage, unpinMessage, listChatPins, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, resolveCurrentChatBotOpenIdsByLarkAppIds, MessageWithdrawnError, type LarkPinRecord } from '../im/lark/client.js';
-import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, buildTurnFailedCard, getCliDisplayName } from '../im/lark/card-builder.js';
+import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, buildTurnFailedCard, getCliDisplayName, type IdleCardLabel } from '../im/lark/card-builder.js';
import { codexServiceTierBadge } from '../services/codex-service-tier.js';
import { isFableModelId, normalizeClaudeModelId } from '../services/claude-transcript.js';
import { cliModelSupportsReasoningEffort, isConfigurableReasoningCliId } from '../services/codex-reasoning-effort.js';
@@ -270,7 +272,7 @@ export function getDaemonSessionUsageSnapshot(
?? ds.adoptedFrom?.cliId
?? getBot(ds.larkAppId).config.cliId
) as CliId;
- return getSessionUsageSnapshot({
+ const snapshot = getSessionUsageSnapshot({
cliId: resolvedCliId,
sessionId: ds.session.sessionId,
cliSessionId:
@@ -294,6 +296,17 @@ export function getDaemonSessionUsageSnapshot(
// 定价覆盖:从 bot 配置解析,未配置时 undefined(costCny 缺省)。
pricing: resolvePricingForBot(ds.larkAppId ?? ds.session.larkAppId),
});
+ // Claude Code statusline 快照(`botmux statusline` 落盘的 ctx / 5h / 7d 百分比):
+ // 回复卡页脚、流式卡用量行、IPC /usage 三个读取点都经本函数,只在这里合并一次。
+ // 独立 try/catch:快照读取失败不能拖垮 transcript 用量。无文件 / 陈旧 ⇒ 不带 key,
+ // 卡片与无 statusline 时逐字节相同。
+ if (resolvedCliId === 'claude-code') {
+ try {
+ const quota = toCardQuota(readStatuslineSnapshot(config.session.dataDir, ds.session.sessionId));
+ if (quota) return { ...snapshot, quota };
+ } catch { /* best-effort */ }
+ }
+ return snapshot;
} catch (error) {
logger.warn(
`[${ds.session.sessionId.slice(0, 8)}] Failed to read card usage snapshot: `
@@ -976,7 +989,7 @@ function scheduleLocalCliOpenReadinessPatch(ds: DaemonSession): void {
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -1032,7 +1045,7 @@ function scheduleActiveRuntimePatch(ds: DaemonSession): void {
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -1051,7 +1064,18 @@ function flushPendingActiveRuntimePatch(ds: DaemonSession): void {
* 「已处理 · 判定无需回复」 instead of 「等待输入」 until beginNewTurn clears
* the flag, or an unrelated patch would silently revert the label. */
export function silentIdleCardFlag(ds: DaemonSession): boolean {
- return !!ds.silentIdleTurnId;
+ return idleCardLabel(ds) === 'silent';
+}
+
+/** idle 卡头的替代标签(所有 buildStreamingCard 调用点统一从这里取):
+ * 'completed' = transcript 模式下本轮最终回复卡已投递(`completedIdleTurnId`);
+ * 'silent' = 本轮判定无需回复(`silentIdleTurnId`);两者都无 → undefined,
+ * 卡头照旧「等待输入」。两个 turnId 在每个新轮次入口一起清理,正常不会同时
+ * 存在;万一同时存在,「已完成」更贴近事实(回复确实发出去了)。 */
+export function idleCardLabel(ds: DaemonSession): IdleCardLabel | undefined {
+ if (ds.completedIdleTurnId) return 'completed';
+ if (ds.silentIdleTurnId) return 'silent';
+ return undefined;
}
const TURN_EXPLICIT_MENTION_MAX = 64;
@@ -1148,7 +1172,7 @@ function scheduleCodexServiceTierPatch(ds: DaemonSession): void {
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -1229,7 +1253,7 @@ export function refreshStreamingCardUsage(ds: DaemonSession): void {
// path fires every 12s while a turn works, so omitting it would drop the
// ⚡ badge until the next status-edge PATCH.
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -1314,7 +1338,7 @@ export function scheduleRiffAccessUrlPatch(ds: DaemonSession): void {
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -2080,7 +2104,7 @@ function scheduleUsageLimitCardPatch(ds: DaemonSession): void {
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, bot.config),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -2228,7 +2252,11 @@ export function parkStreamCard(ds: DaemonSession): void {
title: ds.currentTurnTitle ?? '',
displayMode: ds.displayMode ?? 'hidden',
imageKey: ds.currentImageKey,
- ...(silentIdleCardFlag(ds) ? { silentIdle: true } : {}),
+ ...(() => {
+ // 新字段 idleLabel 为准;'silent' 同时写旧字段 silentIdle,旧版 daemon 读盘不退化。
+ const label = idleCardLabel(ds);
+ return label ? { idleLabel: label, ...(label === 'silent' ? { silentIdle: true } : {}) } : {};
+ })(),
...(() => {
const badge = codexServiceTierBadge(
sessionCliId(ds, getBot(ds.larkAppId).config),
@@ -3165,7 +3193,7 @@ function reconcilePostedStartingCard(ds: DaemonSession, turnId: string | undefin
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: status === 'idle' }),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3232,7 +3260,7 @@ export async function postTurnStartingCard(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3373,7 +3401,7 @@ export async function postFreshStreamingCard(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -6528,7 +6556,7 @@ export function buildStreamingCardJson(ds: DaemonSession, status?: StreamStatus)
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -10927,6 +10955,11 @@ export function forkWorker(
// Feishu (uploader/cred-write are also skipped downstream on the same test).
larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
apiOnly: botCfg.apiOnly,
+ // replyDelivery=transcript 的冻结值(core/reply-delivery.ts):worker 只用它给
+ // injectsSessionContext 适配器选系统提示措辞;solo 由 daemon 在 fork 前按轮算好
+ // 写在 ds 上(resolveSoloSessionForTurn),缺省非 solo。
+ replyDelivery: effectiveReplyDelivery(botCfg.larkAppId, agentCfg.cliId),
+ solo: ds.soloSession === true,
feedback: feedbackPolicy,
// Freeze the ACTUAL loaded bots-config path (getLoadedConfigPath) so a
// no-transport worker's fs-policy denies it from a HOST-owned fact, not a
@@ -11852,7 +11885,7 @@ function setupWorkerHandlers(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -11962,7 +11995,7 @@ function setupWorkerHandlers(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -12569,7 +12602,7 @@ function setupWorkerHandlers(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -12684,7 +12717,7 @@ function setupWorkerHandlers(
}),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -12761,7 +12794,7 @@ function setupWorkerHandlers(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: ds.lastScreenStatus === 'idle' }),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -13191,7 +13224,7 @@ function setupWorkerHandlers(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: true }),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -13266,7 +13299,7 @@ function setupWorkerHandlers(
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: true }),
sessionRuntimeDisplayName(ds, botCfg),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -14664,6 +14697,28 @@ async function persistFinalOutputFeedback(
}
}
+/** transcript 模式(replyDelivery=transcript):最终回复卡投递成功后给本轮打
+ * 「已完成」标签,idle 时卡头不再显示「等待输入」。
+ * - 只对 bridge 类 final_output 生效:local-turn 系列是终端本地对话同步到飞书,
+ * 不是对某个飞书轮次的回复;VC 会议 receiver 的输出走审计 sink,与状态卡无关。
+ * - send 模式(缺省)在此直接 return,行为与今天逐字节相同。
+ * - lineage 守卫与 `silentIdleTurnId` 同款:type-ahead 下更新的轮次已开启时,
+ * 本次投递属于旧轮次,不得把正在工作的卡改成「已完成」。 */
+function markTurnReplyDelivered(
+ ds: DaemonSession,
+ msg: Extract,
+ effectiveCliId: string | undefined,
+): void {
+ if (msg.kind && msg.kind !== 'bridge') return;
+ if (ds.session.vcMeetingReceiver) return;
+ if (effectiveReplyDelivery(ds.larkAppId, effectiveCliId) !== 'transcript') return;
+ if (ds.currentTurnId && ds.currentTurnId !== msg.turnId) return;
+ ds.completedIdleTurnId = msg.turnId;
+ // 卡已 idle 就立即重刷卡头;仍在 working 则等下一次状态边沿自然带上标签。
+ // 卡已冻结 / 禁用流式卡时 scheduleActiveRuntimePatch 自行 no-op。
+ if (ds.lastScreenStatus === 'idle') scheduleActiveRuntimePatch(ds);
+}
+
function deliverFinalOutput(
ds: DaemonSession,
msg: Extract,
@@ -15150,6 +15205,7 @@ function deliverFinalOutput(
finishVcMeetingImReply(config.session.dataDir, preparedListenerReply.ref, messageId);
}
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
+ markTurnReplyDelivered(ds, msg, effectiveCliId);
logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`);
if (feedbackPolicy && baseFeedbackCard && messageId) {
await persistFinalOutputFeedback(ds, msg, safeAssistantText, effectiveCliId, messageId, feedbackPolicy!, baseFeedbackCard, feedbackRequesterSubjectId, getBot(ds.larkAppId).config.feedbackWebhooks?.destinations, t);
diff --git a/src/daemon.ts b/src/daemon.ts
index b43c374620..f68174dd77 100644
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -205,6 +205,7 @@ import {
storedSessionAnchorId,
larkTransportEnabled,
} from './core/types.js';
+import { computeSoloSessionForBot, effectiveReplyDelivery } from './core/reply-delivery.js';
import { stagePendingRepoSetup, persistPendingRepoCardMessageId } from './core/pending-repo-journal.js';
import { hasPendingSessionTurns, runSessionTurn } from './core/session-turn-queue.js';
import { buildTerminalUrl, setTerminalProxyPort, setTerminalExternalPort } from './core/terminal-url.js';
@@ -265,7 +266,7 @@ import {
type WorkerSessionReplyOptions,
migrateMojoSessionIdentities,
mojoLivePatchForSession,
- silentIdleCardFlag,
+ idleCardLabel,
dshRuntimeForSession,
recordTurnExplicitMention,
} from './core/worker-pool.js';
@@ -509,7 +510,7 @@ function republishResolvedAllowedUsers(larkAppId: string, resolved: string[]): v
try { writeDaemonDescriptor(desc); } catch { /* best effort */ }
}
let vcMeetingTerminalReconciler: VcMeetingTerminalReconciler | undefined;
-import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, evaluateBotTalk, evaluateAskAnswerTalk, askCustomReplyCandidate, grantCommandRestriction, isKnownPeerBot, resolveSiblingBotNameByUnionId, checkRequiredScopes, ensureVcMeetingEventsSubscribed, type RoutingContext, type TalkEvaluation, type DocCommentContext, type EventHandlers } from './im/lark/event-dispatcher.js';
+import { isBotMentioned, getGroupStats, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, evaluateBotTalk, evaluateAskAnswerTalk, askCustomReplyCandidate, grantCommandRestriction, isKnownPeerBot, resolveSiblingBotNameByUnionId, checkRequiredScopes, ensureVcMeetingEventsSubscribed, type RoutingContext, type TalkEvaluation, type DocCommentContext, type EventHandlers } from './im/lark/event-dispatcher.js';
import { getDocSubscription, listAllDocSubscriptions, listDocSubscriptionsForSession, putDocSubscription, removeDocSubscription, setDocCommentPollCursor, type DocSubscription } from './services/doc-subs-store.js';
import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, removeCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js';
import { learnFromMentions, resolveSender, flushIdentityCacheSync, type ResolvedSender } from './im/lark/identity-cache.js';
@@ -4935,7 +4936,7 @@ function beginNewTurn(ds: DaemonSession, title: string, turnId: string): void {
const prevTitle = ds.currentTurnTitle || ds.session.title || runtimeDisplayName || getCliDisplayName(effectiveCliId);
const prevMode = ds.displayMode ?? 'hidden';
const previousCodexTierBadge = codexServiceTierBadge(effectiveCliId, ds.codexServiceTier);
- const previousSilentIdle = silentIdleCardFlag(ds);
+ const previousIdleLabel = idleCardLabel(ds);
const frozenCard = buildStreamingCard(
ds.session.sessionId, sessionAnchorId(ds), readUrl, prevTitle,
ds.lastScreenContent ?? '', previousStatus, effectiveCliId,
@@ -4947,8 +4948,8 @@ function beginNewTurn(ds: DaemonSession, title: string, turnId: string): void {
runtimeDisplayName,
previousCodexTierBadge,
// A silently-closed previous turn freezes with its honest label
- // (「已处理 · 判定无需回复」), not a misleading 「等待输入」.
- silentIdleCardFlag(ds),
+ // (「已处理 · 判定无需回复」/ transcript 模式「已完成」), not a misleading 「等待输入」.
+ previousIdleLabel,
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -4965,7 +4966,9 @@ function beginNewTurn(ds: DaemonSession, title: string, turnId: string): void {
displayMode: prevMode,
imageKey: ds.currentImageKey,
...(previousCodexTierBadge ? { codexServiceTierBadge: previousCodexTierBadge } : {}),
- ...(previousSilentIdle ? { silentIdle: true } : {}),
+ // 新字段 idleLabel 为准;'silent' 同时写旧字段 silentIdle,旧版 daemon 读盘不退化。
+ ...(previousIdleLabel ? { idleLabel: previousIdleLabel } : {}),
+ ...(previousIdleLabel === 'silent' ? { silentIdle: true } : {}),
});
saveFrozenCards(ds.session.sessionId, ds.frozenCards);
}
@@ -4977,6 +4980,7 @@ function beginNewTurn(ds: DaemonSession, title: string, turnId: string): void {
// New turn — the previous turn's deliberate-silence marker (if any) has been
// baked into the frozen card above; live cards return to normal labels.
ds.silentIdleTurnId = undefined;
+ ds.completedIdleTurnId = undefined;
// Lineage anchor for the deliberate-silence label: a turn_terminal that lands
// AFTER this point belongs to an older turn (type-ahead admits the follow-up
// while the previous turn is still running) and must not relabel this card.
@@ -17104,6 +17108,42 @@ function startAutoWorktreePending(ds: DaemonSession, args: {
type AvailableBot = Awaited>[number];
+/** replyDelivery=transcript 下按本轮(chatType + 发言者)判定 solo 会话(只有 owner
+ * 与本 bot),结果写到 ds.soloSession(内存态;session-manager 据此给信封去壳,
+ * worker-pool 据此在 init 上冻结 solo)。send 模式直接置 false、零额外 API;
+ * p2p 恒 solo,group 才查 getChatMode / getGroupStats(都带缓存);任何异常 →
+ * false(fail-closed:回到带壳/带 sender 的现状)。 */
+async function resolveSoloSessionForTurn(
+ ds: DaemonSession,
+ chatType: 'group' | 'p2p' | undefined,
+ sender: ResolvedSender | undefined,
+): Promise {
+ let solo = false;
+ try {
+ const cliId = ds.session.cliLaunchSnapshot?.cliId ?? ds.session.cliId ?? getBot(ds.larkAppId).config.cliId;
+ if (effectiveReplyDelivery(ds.larkAppId, cliId) === 'transcript') {
+ let chatMode: 'group' | 'topic' | undefined;
+ let stats: { userCount: number; botCount: number } | undefined;
+ if (chatType === 'group') {
+ const mode = await getChatMode(ds.larkAppId, ds.chatId);
+ chatMode = mode === 'group' || mode === 'topic' ? mode : undefined;
+ stats = await getGroupStats(ds.larkAppId, ds.chatId);
+ }
+ solo = computeSoloSessionForBot(ds.larkAppId, {
+ chatType,
+ chatMode,
+ stats,
+ senderType: sender?.type,
+ senderOpenId: sender?.openId,
+ });
+ }
+ } catch {
+ solo = false;
+ }
+ ds.soloSession = solo;
+ return solo;
+}
+
/** Materialize the opening input at the final synchronous boundary before
* fork. All potentially-slow preparation must happen before this call; the
* pendingFollowUps snapshot and fork then cannot be interleaved by a later
@@ -17148,6 +17188,9 @@ function buildReservedInitialInput(
? undefined
: (ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId),
sessionBackendType: ds.session.backendType,
+ // transcript + solo(resolveSoloSessionForTurn 在注册会话后算好):首轮去壳。
+ solo: ds.soloSession,
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
},
);
// R5-B1-1: COPY the frozen new-topic steer authorization onto the opening
@@ -17292,6 +17335,8 @@ function releaseQueuedActivationReservationNow(ds: DaemonSession, acknowledgedTo
codexAppText: rawCodexText || buffered.join('\n\n'),
sessionBackendType: ds.session.backendType,
turnId,
+ solo: ds.soloSession,
+ selfMention: { name: bot.botName, openId: bot.botOpenId },
codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
codexAppMessageContext: (ds.pendingCodexAppFollowUpContexts ?? [])
.filter(Boolean)
@@ -18870,6 +18915,10 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise {
- const botCfg = getBot(ds.larkAppId).config;
+ const tailBot = getBot(ds.larkAppId);
+ const botCfg = tailBot.config;
+ // solo 必须在构造信封之前解析:transcript 下 solo 决定是否去掉
+ // 壳与 标签。
+ const tailSender = await getThreadSender();
+ await resolveSoloSessionForTurn(ds, ctxChatType, tailSender);
const followUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
attachments,
mentions: parsed.mentions,
isAdoptMode: false,
cliId: ds.session.cliLaunchSnapshot?.cliId ?? ds.session.cliId ?? botCfg.cliId,
cliPathOverride: ds.session.cliLaunchSnapshot?.cliPathOverride ?? ds.session.cliPathOverride ?? botCfg.cliPathOverride,
- sender: await getThreadSender(),
+ sender: tailSender,
+ solo: ds.soloSession,
+ selfMention: { name: tailBot.botName, openId: tailBot.botOpenId },
larkAppId,
chatId: ds.session.chatId,
whiteboardId: ds.session.whiteboardId,
@@ -20469,7 +20525,10 @@ async function handleThreadReplyAdmitted(
turnId: parsed.messageId,
});
} else {
- const botCfg = getBot(ds.larkAppId).config;
+ const pendingBot = getBot(ds.larkAppId);
+ const botCfg = pendingBot.config;
+ // 此分支是 pending 首个 worker 的同步屏障,不能 await 网络(见上方注释),
+ // 所以不重算 solo,沿用会话注册时算好的 ds.soloSession。
const exactFollowUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
attachments,
mentions: parsed.mentions,
@@ -20483,6 +20542,8 @@ async function handleThreadReplyAdmitted(
substituteTrigger,
codexAppText: parsed.content,
codexAppApplicationContext,
+ solo: ds.soloSession,
+ selfMention: { name: pendingBot.botName, openId: pendingBot.botOpenId },
codexAppMessageContext,
sessionBackendType: ds.session.backendType,
turnId: parsed.messageId,
@@ -20728,6 +20789,8 @@ async function handleThreadReplyAdmitted(
}
return;
}
+ // transcript 模式的 solo 判定(同 handleNewTopicAdmitted):fork 前算好。
+ await resolveSoloSessionForTurn(newDs, autoCreateChatType, autoCreateSender);
if (newDs.pendingRepo) {
stageClaimedPendingRepoSetup(activeSessions, newDs, {
mode: autoWt ? 'auto_worktree' : 'picker',
@@ -20854,6 +20917,8 @@ async function handleThreadReplyAdmitted(
const wantsOpening = !isBridge && isInitialUserTurnPending(ds);
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
const turnSender = await getThreadSender();
+ // transcript 模式按轮重算 solo(bridge 会话本就是裸文本,跳过)。
+ if (!isBridge) await resolveSoloSessionForTurn(ds, ctxChatType, turnSender);
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
const cliInput = isBridge
? { content: buildBridgeInputContent(promptContent, {
@@ -20886,6 +20951,8 @@ async function handleThreadReplyAdmitted(
// sendWorkerInput 的权威 turnId(parsed.messageId)一致,sidecar 可被 claim。
turnId: parsed.messageId,
sessionBackendType: ds.session.backendType,
+ solo: ds.soloSession,
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
},
)
: buildFollowUpCliInput(promptContent, ds.session.sessionId, {
@@ -20902,6 +20969,8 @@ async function handleThreadReplyAdmitted(
codexAppText: parsed.content,
codexAppApplicationContext,
codexAppMessageContext,
+ solo: ds.soloSession,
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
sessionBackendType: ds.session.backendType,
turnId: parsed.messageId,
});
@@ -20968,6 +21037,7 @@ async function handleThreadReplyAdmitted(
// without clearing, a previous turn's deliberate-silence marker survives the
// re-fork and mislabels THIS turn's idle card 「已处理 · 判定无需回复」.
ds.silentIdleTurnId = undefined;
+ ds.completedIdleTurnId = undefined;
ds.currentTurnId = parsed.messageId;
ds.currentImageKey = undefined;
persistStreamCardState(ds);
@@ -20992,13 +21062,17 @@ async function handleThreadReplyAdmitted(
userPrompt: promptContent,
turnId: parsed.messageId,
build: async () => {
+ const stagedSender = await getThreadSender();
+ await resolveSoloSessionForTurn(ds, ctxChatType, stagedSender);
const currentFollowUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
attachments,
mentions: parsed.mentions,
isAdoptMode: false,
cliId: ds.session.cliLaunchSnapshot?.cliId ?? ds.session.cliId ?? dsBotCfgForFork.cliId,
cliPathOverride: ds.session.cliLaunchSnapshot?.cliPathOverride ?? ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
- sender: await getThreadSender(),
+ sender: stagedSender,
+ solo: ds.soloSession,
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
larkAppId,
chatId: ds.session.chatId,
whiteboardId: ds.session.whiteboardId,
@@ -21114,6 +21188,9 @@ async function handleThreadReplyAdmitted(
const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds);
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
const reforkSender = await getThreadSender();
+ // transcript 模式按轮重算 solo:refork 走 worker-pool init 冻结 solo,且
+ // buildReforkCliInput / buildNewTopicCliInput 都据 ds.soloSession 去壳。
+ if (!ds.adoptedFrom) await resolveSoloSessionForTurn(ds, ctxChatType, reforkSender);
// An empty-started CLI has nothing to resume: `hasHistory` is set
// unconditionally by restoreActiveSessions (and by claude_exit /
// suspendWorker), so it cannot tell "booted idle" from "has real history".
@@ -21149,6 +21226,8 @@ async function handleThreadReplyAdmitted(
substituteTrigger,
codexAppText: reforkCodexApp.text,
codexAppApplicationContext,
+ solo: ds.soloSession,
+ selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
codexAppMessageContext: reforkCodexApp.messageContext,
// #794 后续:worker-null refork 的 empty-start 首轮 opening 也走 hook 注入。
// turnId 与下方 forkWorker 的权威 turnId 一致(非 queued 时 = parsed.messageId);
@@ -21612,6 +21691,7 @@ async function handleDocCommentAdmitted(ctx: DocCommentContext): Promise {
return;
}
+ // PUT /api/bots/:appId/reply-delivery — proxy to that bot's daemon.
+ // Body `{ replyDelivery: 'transcript'|'send'|'' }` (''/other clears back to
+ // the CLI default: claude-code=transcript, others=send). 最终回复投递方式的
+ // per-bot 开关;'send' 与 'transcript' 都显式落盘。
+ let mBotReplyDelivery: RegExpMatchArray | null;
+ if (req.method === 'PUT' && (mBotReplyDelivery = url.pathname.match(/^\/api\/bots\/([^/]+)\/reply-delivery$/))) {
+ const appId = decodeURIComponent(mBotReplyDelivery[1]);
+ const chunks: Buffer[] = [];
+ for await (const c of req) chunks.push(c as Buffer);
+ const raw = Buffer.concat(chunks).toString('utf8') || '{}';
+ const upstream = await proxyToDaemon(appId, `/api/bot-reply-delivery`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: raw,
+ });
+ res.writeHead(upstream.status, { 'content-type': 'application/json' });
+ res.end(await upstream.text());
+ return;
+ }
+
// PUT /api/bots/:appId/skill-injection — proxy to that bot's daemon. Body
// `{ skillInjection: 'global'|'prompt'|'off'|'' }` (''/other clears back to
// the machine default). Governs how botmux built-in skills reach global-
diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts
index 610ce1948f..68e96fb4bc 100644
--- a/src/dashboard/bot-payload.ts
+++ b/src/dashboard/bot-payload.ts
@@ -162,6 +162,9 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?:
messageQuotaDefaultLimit: typeof j?.messageQuotaDefaultLimit === 'number' ? j.messageQuotaDefaultLimit : null,
p2pMode: j?.p2pMode === 'thread' ? 'thread' : j?.p2pMode === 'group' ? 'group' : 'chat',
envelopeInjection: j?.envelopeInjection === 'auto' ? 'auto' : 'off',
+ replyDelivery: j?.replyDelivery === 'transcript' ? 'transcript' : 'send',
+ replyDeliveryDefault: j?.replyDeliveryDefault === 'transcript' ? 'transcript' : 'send',
+ replyDeliverySupported: j?.replyDeliverySupported === true,
codexAuthSync: j?.codexAuthSync === 'isolated' ? 'isolated' : 'shared',
// Trigger-user CLI auth policy, verbatim (no secrets in it — just which
// tools and what to do when the sender has not authorized).
diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx
index 1ff34ab43e..3a65614bb4 100644
--- a/src/dashboard/web/bot-defaults-page.tsx
+++ b/src/dashboard/web/bot-defaults-page.tsx
@@ -1179,6 +1179,9 @@ function BotDefaultsCard(props: {
{bot.cliId === 'claude-code' ? (
) : null}
+ {/* 转写回复模式对所有 CLI 都显示:不支持的 CLI 禁用开关并说明原因,
+ 避免用户在别的 tab 找不到这个开关却在 /botconfig 里能设。 */}
+
{/* 注入对所有 CLI 都生效(每种 CLI 的 prompt 都会带这个块),
所以不按 cliId 收窄——不像上面的 hook 注入只验证过 claude-code。 */}
@@ -4602,6 +4605,65 @@ export function EnvelopeInjectionSection(props: { bot: BotDefaultsRow; patchBot:
);
}
+/** 最终回复投递方式:on = transcript(daemon 从 CLI 转写自动取最终回复,模型不再被
+ * 要求 botmux send),off = send(模型自己 botmux send)。开关显示的是生效值:缺省
+ * 按 CLI(claude-code 默认开,其它默认关),两个方向都显式落盘。当前 CLI 没有转写
+ * 采集通道时开关禁用并说明。 */
+export function ReplyDeliverySection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) {
+ const tr = useT();
+ const [transcript, setTranscript] = useState(props.bot.replyDelivery === 'transcript');
+ const [status, setStatus] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const supported = props.bot.replyDeliverySupported === true;
+ const defaultMode = props.bot.replyDeliveryDefault === 'transcript' ? 'transcript' : 'send';
+
+ useEffect(() => setTranscript(props.bot.replyDelivery === 'transcript'), [props.bot.replyDelivery]);
+
+ async function save(next: boolean): Promise {
+ const previous = transcript;
+ setTranscript(next);
+ setBusy(true);
+ setStatus(null);
+ try {
+ const res = await sendJson('PUT', `/api/bots/${encodeURIComponent(props.bot.larkAppId)}/reply-delivery`, { replyDelivery: next ? 'transcript' : 'send' });
+ if (res.ok && res.body.ok) {
+ const saved = res.body.replyDelivery === 'transcript';
+ setTranscript(saved);
+ props.patchBot(props.bot.larkAppId, { replyDelivery: saved ? 'transcript' : 'send' });
+ setStatus({ text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true });
+ } else {
+ setTranscript(previous);
+ setStatus({ text: `✗ ${responseErrorText(res)}` });
+ }
+ } catch (e: any) {
+ setTranscript(previous);
+ setStatus({ text: `✗ ${caughtErrorText(e)}` });
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+ {tr('botDefaults.replyDelivery')}
+ void save(checked)}
+ />
+
+ {supported ? tr('botDefaults.replyDeliveryNote', { defaultMode }) : tr('botDefaults.replyDeliveryUnsupported')}
+
+
+
+
+
+ );
+}
+
function SenderTagSection(props: { bot: BotDefaultsRow; patchBot: PatchBot; putCardPref(patch: CardPrefPatch): Promise }) {
const tr = useT();
const [on, setOn] = useState(props.bot.senderTag !== false);
diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts
index ccfca1153a..14da2cb304 100644
--- a/src/dashboard/web/bot-defaults.ts
+++ b/src/dashboard/web/bot-defaults.ts
@@ -142,6 +142,13 @@ export type BotDefaultsRow = {
p2pMode?: string;
/** #794: per-turn 上下文注入方式。'auto' = 支持的 CLI 走 hook 注入;缺省/'off' = 内联。 */
envelopeInjection?: 'auto' | 'off' | null;
+ /** 最终回复投递方式的**生效值**(显式配置,否则按 CLI 缺省)。'transcript' = daemon
+ * 从 CLI 转写自动取最终回复,模型不再被要求 botmux send;'send' = 模型自己 botmux send。 */
+ replyDelivery?: 'send' | 'transcript' | null;
+ /** 当前 cliId 的缺省投递方式:claude-code 为 'transcript',其它为 'send'。 */
+ replyDeliveryDefault?: 'send' | 'transcript';
+ /** 当前 cliId 是否有转写采集通道(claude-code / 结构化转写白名单);false 时开关禁用。 */
+ replyDeliverySupported?: boolean;
regularGroupReplyMode?: string;
regularGroupMentionMode?: string;
substituteMode?: BotSubstituteMode | null;
diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts
index e36e46c151..c370a2de3c 100644
--- a/src/dashboard/web/i18n.ts
+++ b/src/dashboard/web/i18n.ts
@@ -2453,6 +2453,11 @@ const zh = {
'botDefaults.envelopeInjectionAuto': 'Hook 注入每轮上下文(实验性)',
'botDefaults.envelopeInjectionHelp': '开启后,操作提醒与白板不再写进输入框文本,改由 Claude Code 的 UserPromptSubmit hook 以系统提醒注入;输入框只显示消息本身。仅支持 claude-code,其它 CLI 自动保持内联。',
'botDefaults.envelopeInjectionNote': '默认关闭,保持内联行为;开启后从下一条回复生效,可随时切回。',
+ 'botDefaults.replyDelivery': '回复投递',
+ 'botDefaults.replyDeliveryTranscript': '转写回复模式',
+ 'botDefaults.replyDeliveryHelp': '开启后,daemon 从 CLI 转写自动取本轮最后的 assistant 文本作为最终回复发卡;系统提示不再提及 botmux send(附件、跨 bot @ 等场景模型可按需自行发现内置 botmux-send 技能)、不再逐轮注入 ;私聊或仅 owner 的 1v1 群这类 solo 会话还会去掉 壳与 。关闭则模型必须自己 botmux send。',
+ 'botDefaults.replyDeliveryNote': '当前 CLI 默认:{defaultMode}(claude-code 默认开启,其它 CLI 默认关闭);开关两个方向都会显式写入 bots.json。逐轮信封从下一轮生效;系统提示部分需 /restart 会话才换新值。代价:solo 会话的裸文本形态不再被 /adopt 识别为本 bot 自产会话。',
+ 'botDefaults.replyDeliveryUnsupported': '当前 CLI 没有转写采集通道,无法开启;仅 claude-code 与 codex / traex / coco / hermes / mtr / pi / oh-my-pi / ebsd / grok 支持。',
'botDefaults.senderTag': '发言人标签',
'botDefaults.senderTagInject': '每轮注入 发言人标签',
'botDefaults.senderTagHelp': '默认开启。每轮消息附带一个 标签,告诉模型这句话是谁说的(open_id / 姓名 / 邮箱)。关掉后模型看不到发言人身份,多人会话里无法区分谁说的、也无法按人称呼;`botmux send --mention-back` 不受影响(它读 daemon 侧独立记录的本轮触发者,与这个标签无关)。适合模型会把标签内容抄进回复的 CLI(如 cursor),或不希望把每条消息的身份写进 CLI 记录的场景。',
@@ -5365,6 +5370,11 @@ const en: Record = {
'botDefaults.envelopeInjectionAuto': 'Inject per-turn context via hook (experimental)',
'botDefaults.envelopeInjectionHelp': 'When enabled, the reminder and whiteboard are no longer written into the input text; Claude Code\'s UserPromptSubmit hook injects them as a system reminder instead. The input box shows only the message itself. claude-code only; other CLIs keep inline context.',
'botDefaults.envelopeInjectionNote': 'Off by default to preserve inline behavior. When enabled, it applies from the next reply and can be switched back at any time.',
+ 'botDefaults.replyDelivery': 'Reply Delivery',
+ 'botDefaults.replyDeliveryTranscript': 'Transcript reply mode',
+ 'botDefaults.replyDeliveryHelp': 'When enabled, the daemon takes the last assistant text of the turn from the CLI transcript and posts it as the final reply card. The system prompt no longer mentions botmux send at all (for attachments or cross-bot @ the model can discover the built-in botmux-send skill on its own), the per-turn is no longer injected, and solo sessions (DMs or an owner-only 1:1 group) also drop the wrapper and . When disabled the model must run botmux send itself.',
+ 'botDefaults.replyDeliveryNote': 'Default for this CLI: {defaultMode} (on by default for claude-code, off for other CLIs); both toggle directions are written explicitly to bots.json. The per-turn envelope applies from the next turn; the system prompt part needs a /restart of the session. Cost: the bare-text shape of solo sessions is no longer recognized by /adopt as this bot\'s own session.',
+ 'botDefaults.replyDeliveryUnsupported': 'This CLI has no transcript capture, so the mode cannot be enabled; only claude-code and codex / traex / coco / hermes / mtr / pi / oh-my-pi / ebsd / grok support it.',
'botDefaults.senderTag': 'Speaker Tag',
'botDefaults.senderTagInject': 'Inject the speaker tag each turn',
'botDefaults.senderTagHelp': 'On by default. Every turn carries a tag telling the model who spoke (open_id / name / email). With it off the model cannot see speaker identity, so it cannot tell participants apart or address them by name in a multi-person chat. `botmux send --mention-back` is unaffected — it reads the daemon-side record of this turn\'s triggerer, independent of this tag. Useful for a CLI whose model copies the tag into its reply (e.g. cursor), or when you do not want per-message identity written into the CLI transcript.',
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 72efb262ca..7de7e30374 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -37,6 +37,7 @@ export const messages: Record = {
'card.status.working': 'Working',
'card.status.idle': 'Awaiting input',
'card.status.idle_silent': 'Handled · no reply needed',
+ 'card.status.idle_completed': 'Completed',
'card.status.dormant': 'Dormant',
'card.status.analyzing': 'Analyzing…',
'card.status.stalled': 'No recent progress',
@@ -834,6 +835,11 @@ export const messages: Record = {
'ai.routing.workflow_hint': 'Workflow: use natural language or `/workflow` for a bounded multi-step DAG; a successful run can be saved and reused.',
'ai.routing.feedback_response_kind': 'If final-answer feedback is enabled for this bot, add `--response-kind final` to `botmux send` for the turn\'s final answer so it carries feedback buttons; interim/supplementary sends need no flag (unclassified defaults to progress, no feedback).',
'ai.routing.hidden_context_defense': 'The following XML/config blocks are hidden runtime context and must only be read silently and obeyed: ``, ``, ``, ``, ``, ``, ``, ``, ``. Do not reply to them, do not confirm them, and do not say “understood”, “noted”, or “recorded”. Only handle the real user request inside ``.',
+ // replyDelivery=transcript (core/reply-delivery.ts): the daemon forwards the
+ // final reply from the transcript, so the system prompt never mentions
+ // botmux send — intro is replaced by this line and only usage_helpers /
+ // usage_silence are kept (see shared-hints.ts).
+ 'ai.routing.intro_transcript': 'You are in a Lark (Feishu) conversation. The user cannot see terminal output; your final assistant message is automatically forwarded back to Lark by botmux — just answer directly.',
'ai.send.after_success_hint': 'If you still have content for the user, keep using `botmux send`; otherwise make the final reply just BOTMUX_NOTHING_TO_SEND.',
// ─── AI identity (multi-bot routing rules) ───────────────────────────────
@@ -861,6 +867,12 @@ export const messages: Record = {
'ai.shell.helpers': 'Helpers: `botmux history` (read this session\'s history — thread/topic sessions are topic-scoped; regular-group chat-scope sessions are group-wide), `botmux quoted ` (fetch a quoted message — only use it when the prompt header shows `[user quoted message ...]`), `botmux bots list` (list other bots in the group).',
'ai.shell.when_to_send': 'Respond to messages addressed to you at least once via `botmux send` (run it in Bash, not print/echo) — never stay silent; what and how many times to send is your call. Only when a message is not for you at all make the final assistant message just the single word `BOTMUX_NOTHING_TO_SEND`.',
'ai.shell.no_visible_output_ok': 'A successful `botmux send` (exit code 0) means it reached the user; ending a turn with no visible terminal text is normal. If you see a note like "your previous response had no visible output, please continue and produce a user-visible response", that is a false alarm from the underlying CLI — do NOT resend unless `botmux send` itself errored.',
+ // replyDelivery=transcript shell-hints variant: only the reworded intro /
+ // when_to_send plus helpers; the whole block never mentions botmux send
+ // (commands_are_shell / how_to_send / heredoc / mention_gate are not injected,
+ // see shared-hints.ts).
+ 'ai.shell.intro_transcript': 'You are running inside a Lark (Feishu) conversation. The user reads on Lark and cannot see your terminal output; your final assistant message is automatically forwarded back to Lark by botmux.',
+ 'ai.shell.when_to_send_transcript': 'Answer messages addressed to you directly in your final assistant message — never stay silent. Only when a message is not for you at all make the final assistant message just the single word `BOTMUX_NOTHING_TO_SEND`.',
'ai.shell.mention_gate': '@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention ` (name a specific person/bot; REQUIRED to communicate or collaborate with another bot) / `--mention-back` (@ the triggerer of THIS turn) / `--no-mention` (none). First decide WHETHER to @ by VALUE: substantive conclusion the other party should read/confirm/decide → @ someone; pure record / low-priority / short ack → --no-mention; a contentless "got it" is better not sent. Then pick HOW by recipient: it is the person/bot that triggered this turn → --mention-back; it is someone else (in a multi-person chat the right recipient is often not the triggerer) → --mention to name them. Do not default to --no-mention, and do not @ people for nothing.',
// ─── AI prompt blocks (session-manager) ──────────────────────────────────
@@ -1479,6 +1491,10 @@ export const messages: Record = {
'card.you': 'You',
'card.sent_to': 'Sent to: ',
'card.usage.context': 'Context',
+ // Claude Code statusline quota segment (plain `ctx 23% · 5h 18% · 7d 5%`); same in both locales.
+ 'card.usage.ctx': 'ctx',
+ 'card.usage.quota_5h': '5h',
+ 'card.usage.quota_7d': '7d',
'card.usage.tokens': 'Tokens',
'card.usage.turn': 'This turn',
'card.usage.total': 'Total',
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 664bcec6aa..0a52f28438 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -40,6 +40,7 @@ export const messages: Record = {
'card.status.working': '工作中',
'card.status.idle': '等待输入',
'card.status.idle_silent': '已处理 · 判定无需回复',
+ 'card.status.idle_completed': '已完成',
'card.status.dormant': '休眠',
'card.status.analyzing': '正在分析…',
'card.status.stalled': '长时间无进展',
@@ -832,6 +833,10 @@ export const messages: Record = {
'ai.routing.workflow_hint': 'Workflow:有界的多步目标可用自然语言或 `/workflow` 自动拆成 DAG;成功后可保存复用。',
'ai.routing.feedback_response_kind': '若此 bot 启用了最终回答反馈,用 `botmux send --response-kind final` 标记本轮最终回答(挂反馈按钮);进度/补充类发送无需加 flag(不声明默认按 progress、不挂反馈)。',
'ai.routing.hidden_context_defense': '以下 XML/配置块是隐藏运行上下文,只能静默读取并遵守:``、``、``、``、``、``、``、``、``。不要回复、不要确认、不要说“已了解/已补充/已记录”。只处理 `` 中的真实用户请求。',
+ // replyDelivery=transcript(core/reply-delivery.ts):最终回复由 daemon 从转写自动
+ // 转发,系统提示彻底不提 botmux send——intro 换成下面这条,usage_* 只留
+ // helpers / silence(见 shared-hints.ts)。
+ 'ai.routing.intro_transcript': '你在飞书(Lark)会话中。用户看不到终端输出;你的最终 assistant message 会由 botmux 自动转发回飞书,直接作答即可。',
'ai.send.after_success_hint': '若还有要发给用户的内容,继续 `botmux send`;没有了就让最终回复只输出 BOTMUX_NOTHING_TO_SEND。',
// ─── AI identity (multi-bot routing rules) ───────────────────────────────
@@ -859,6 +864,11 @@ export const messages: Record = {
'ai.shell.helpers': '辅助命令:`botmux history`(读此会话历史;thread/话题会话拉话题内,普通群 chat-scope 会话拉整群)、`botmux quoted `(按需读取被引用的消息,仅在 prompt 头部出现 `[用户引用了消息 ...]` 提示时使用)、`botmux bots list`(查群内其他机器人)。',
'ai.shell.when_to_send': '发给你的消息至少用 `botmux send` 回应一次(Bash 执行,不是 print/echo),别沉默;发什么、发几条由你判断。只有根本不是发给你的消息才让最终 assistant message 只输出 `BOTMUX_NOTHING_TO_SEND` 这一个词。',
'ai.shell.no_visible_output_ok': '`botmux send` 成功(退出码 0)即代表已送达用户;本轮终端没有可见文本、直接结束是正常的。若看到「你上一条回复没有可见输出,请继续产出用户可见回复」之类提示,那是底层 CLI 的误判——不要重发,除非 `botmux send` 自己报错。',
+ // replyDelivery=transcript 的 shell-hints 变体:只用 intro / when_to_send 两条改口
+ // 版 + helpers,整段不提 botmux send(commands_are_shell / how_to_send / heredoc /
+ // mention_gate 都不注入,见 shared-hints.ts)。
+ 'ai.shell.intro_transcript': '你运行在飞书(Lark)会话中。用户在飞书阅读回复,看不到你的终端输出;你的最终 assistant message 会由 botmux 自动转发回飞书。',
+ 'ai.shell.when_to_send_transcript': '发给你的消息直接在最终 assistant message 里作答即可,别沉默。只有根本不是发给你的消息才让最终 assistant message 只输出 `BOTMUX_NOTHING_TO_SEND` 这一个词。',
'ai.shell.mention_gate': '@ 决策(硬性):每条 `botmux send` 必须显式三选一否则报错——`--mention `(点名指定人/bot,跟别的 bot 沟通/协作必须用它)/ `--mention-back`(@回本轮触发者本人)/ `--no-mention`(不@)。先按内容价值决定要不要 @:有实质结论要对方看/确认/决策→需要 @;纯记录/低优先级/简短确认→--no-mention;没信息量的"收到"不如不发。再按收件人选方式:就是回触发这轮的人/bot→--mention-back;要 @ 别人(多人会话回复对象不一定是触发者)→--mention 显式点名。别把 --no-mention 当默认,也别无意义 @ 打扰。',
// ─── AI prompt blocks (session-manager) ──────────────────────────────────
@@ -1477,6 +1487,10 @@ export const messages: Record = {
'card.you': '你',
'card.sent_to': '发送给:',
'card.usage.context': '上下文',
+ // Claude Code statusline 配额段(纯文本 `ctx 23% · 5h 18% · 7d 5%`),两语言同值。
+ 'card.usage.ctx': 'ctx',
+ 'card.usage.quota_5h': '5h',
+ 'card.usage.quota_7d': '7d',
'card.usage.tokens': 'Token',
'card.usage.turn': '本轮',
'card.usage.total': '累计',
diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts
index e96d6037bc..3eb3b55305 100644
--- a/src/im/lark/card-builder.ts
+++ b/src/im/lark/card-builder.ts
@@ -851,16 +851,45 @@ export function truncateContent(content: string, locale?: Locale, maxBytes: numb
* card limit, leaving room for JSON escaping + the card's structural overhead. */
const PRIVATE_SNAPSHOT_TEXT_MAX = 50_000;
+/** idle 状态下卡头的替代标签:
+ * - 'silent':本轮判定无需回复(worker terminal outputDisposition 'nothing_to_send');
+ * - 'completed':transcript 模式下最终回复卡已投递成功。
+ * 只对 idle 生效,其它状态一律忽略。 */
+export type IdleCardLabel = 'silent' | 'completed';
+
+/** 兼容旧调用:布尔 `true` 等价于 'silent'。 */
+function normalizeIdleLabel(v: boolean | IdleCardLabel | undefined): IdleCardLabel | undefined {
+ if (v === true) return 'silent';
+ if (v === 'silent' || v === 'completed') return v;
+ return undefined;
+}
+
+/** 冻结卡(FrozenCard)回读 idle 标签:新字段 `idleLabel` 优先;旧盘只有
+ * `silentIdle: true` 时按 'silent' 处理。结构化参数,避免 card-builder 反向依赖 core。 */
+export function frozenIdleLabel(fc: { idleLabel?: IdleCardLabel; silentIdle?: boolean }): IdleCardLabel | undefined {
+ return fc.idleLabel ?? (fc.silentIdle ? 'silent' : undefined);
+}
+
/** Header status label for a streaming/snapshot card. Shared by the live card
* and the private snapshot so the two never drift. */
-function streamStatusLabel(status: StreamStatus, usageLimit: CliUsageLimitState | undefined, locale?: Locale, silentIdle?: boolean): string {
+function streamStatusLabel(status: StreamStatus, usageLimit: CliUsageLimitState | undefined, locale?: Locale, idleLabel?: boolean | IdleCardLabel): string {
switch (status) {
case 'starting': return t('card.status.starting', undefined, locale);
case 'working': return t('card.status.working', undefined, locale);
- // silentIdle: the turn completed as DELIBERATE silence (bare
+ // idleLabel 'silent': the turn completed as DELIBERATE silence (bare
// nothing-to-send sentinel). Plain 「等待输入」 here is indistinguishable
// from a hung session; say "handled, judged no reply needed" instead.
- case 'idle': return t(silentIdle ? 'card.status.idle_silent' : 'card.status.idle', undefined, locale);
+ // 'completed': transcript 模式下最终回复卡已投递,卡头改「已完成」。
+ case 'idle': {
+ const label = normalizeIdleLabel(idleLabel);
+ return t(
+ label === 'completed' ? 'card.status.idle_completed'
+ : label === 'silent' ? 'card.status.idle_silent'
+ : 'card.status.idle',
+ undefined,
+ locale,
+ );
+ }
case 'analyzing': return t('card.status.analyzing', undefined, locale);
case 'stalled': return t('card.status.stalled', undefined, locale);
case 'limited': return usageLimit?.retryReady
@@ -962,7 +991,8 @@ export function buildStreamingCard(
usage?: CardUsageSnapshot,
runtimeDisplayName?: string,
serviceTierBadge?: string,
- silentIdle?: boolean,
+ /** idle 卡头替代标签;布尔 `true` 兼容旧调用(= 'silent')。见 {@link IdleCardLabel}。 */
+ silentIdle?: boolean | IdleCardLabel,
/** Live per-bot `dshRuntime`. Only meaningful for cliId 'dsh': 'tui' means the
* worker spawns the PTY-driven dsh-tui adapter (a real interactive TUI that
* accepts a raw /compact), so the compact button must stay visible. Omitted ⇒
diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts
index 8c24a0ef25..a1cbc1bd04 100644
--- a/src/im/lark/card-handler.ts
+++ b/src/im/lark/card-handler.ts
@@ -11,7 +11,7 @@ import { getBot, getAllBots, getOwnerOpenId } from '../../bot-registry.js';
import { resolveHiddenStreamingCardButtons } from './streaming-card-buttons.js';
import { canOperate, canTalk, canRunDaemonCommand } from './event-dispatcher.js';
import { updateMessage, deleteMessage, replyMessage, sendMessage, sendUserMessage, sendEphemeralCard, getMessageDetail, isHumanOpenId, resolveUserUnionId as defaultResolveUserUnionId } from './client.js';
-import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildGrantResultCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigQuotaCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js';
+import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildGrantResultCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigQuotaCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard, frozenIdleLabel } from './card-builder.js';
import { codexServiceTierBadge } from '../../services/codex-service-tier.js';
import {
findConfigField,
@@ -94,7 +94,7 @@ import { buildTurnContinuePrompt } from '../../services/turn-failure-notice.js';
import { loadFrozenCards, saveFrozenCards } from '../../services/frozen-card-store.js';
import { resumeStartsFresh } from '../../services/resume-fresh-policy.js';
import { cliHasNoRawPassthroughSurface } from '../../core/passthrough-commands.js';
-import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock, silentIdleCardFlag, dshRuntimeForSession, type WorkerSessionReplyOptions } from '../../core/worker-pool.js';
+import { forkWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, closeSession as closeWorkerPoolSession, teardownAuthoritativePersistentBackingBeforeClose, scheduleCardPatch, parkStreamCard, clearUsageLimitState, cardUsageLimit, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, resolvePrivateCardAudience, deliverWriteLinkCard, deliverEphemeralOrReply, CARD_POSTING_SENTINEL, requestSessionRestart, isSessionTransferring, getDaemonStreamingCardUsageSnapshot, withActiveSessionKeyLock, buildStreamingCardJson, canCommitStreamingCardPublication, continuePublishedStreamingCardPinChain, idleCardLabel, dshRuntimeForSession, type WorkerSessionReplyOptions } from '../../core/worker-pool.js';
import { reconcileResumedStreamingCard } from '../../core/resume-streaming-card.js';
import { getSessionWorkingDir, buildNewTopicCliInput, getAvailableBots, persistStreamCardState, resumeSession, rememberLastCliInput, ensureSessionWhiteboard } from '../../core/session-manager.js';
import { markInitialUserTurnPending } from '../../core/initial-user-turn.js';
@@ -3144,7 +3144,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, sessionCliId(ds)),
sessionRuntimeDisplayName(ds),
codexServiceTierBadge(sessionCliId(ds), ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3658,7 +3658,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3707,7 +3707,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds),
effectiveCliId === 'codex' ? frozen.codexServiceTierBadge : undefined,
- frozen.silentIdle === true,
+ frozenIdleLabel(frozen),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3754,7 +3754,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3826,7 +3826,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3890,7 +3890,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
@@ -3983,7 +3983,7 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId),
sessionRuntimeDisplayName(ds),
codexServiceTierBadge(effectiveCliId, ds.codexServiceTier),
- silentIdleCardFlag(ds),
+ idleCardLabel(ds),
dshRuntimeForSession(ds),
resolveHiddenStreamingCardButtons(getBot(ds.larkAppId).config),
);
diff --git a/src/im/lark/md-card.ts b/src/im/lark/md-card.ts
index 74558bd6ed..e0dd317abe 100644
--- a/src/im/lark/md-card.ts
+++ b/src/im/lark/md-card.ts
@@ -34,6 +34,7 @@ import {
} from './reply-card-footer-signature.js';
import { buildFeedbackElement } from './skill-feedback-card.js';
import type { FeedbackPolicy } from '../../services/feedback-policy.js';
+import type { StatuslineQuota } from '../../services/statusline-snapshot.js';
import type { ReplyCardHeader } from './reply-card-style.js';
export { REPLY_CARD_FOOTER_MARKER } from './reply-card-footer-signature.js';
@@ -108,6 +109,10 @@ export interface CardUsageSnapshot {
* by test/streaming-card-usage-arg.test.ts), so no call site can forget it.
* Not a usage metric, but the same class of runtime identity as `model`. */
modelFallback?: ModelFallbackState;
+ /** Claude Code statusline 快照(`botmux statusline` 落盘,daemon 合并)。存在时
+ * 上下文段改渲染纯百分比 `ctx N%`,并追加 `5h N%` / `7d N%` 账号配额段。
+ * 缺省 / null ⇒ 与无 statusline 时逐字节相同(只看 `context`)。 */
+ quota?: StatuslineQuota | null;
}
export interface ReplyCardFooter {
@@ -419,11 +424,19 @@ export function contextOverCompactThreshold(
* window (⇒ no percentage to show). Shared so the footer text and
* {@link contextOverCompactThreshold} can never disagree on the value. */
function contextPercentUsed(usage: CardUsageSnapshot): number | undefined {
- return isNonNegativeFinite(usage.context?.percentUsed)
- ? Math.min(100, Math.round(usage.context.percentUsed))
+ // statusline 给的 contextPercent 优先(Claude Code 的 transcript 本身没有窗口字段,
+ // 这是它唯一的百分比来源);其余 CLI 仍走 transcript 的 percentUsed。
+ const pct = usage.quota?.contextPercent ?? usage.context?.percentUsed;
+ return isNonNegativeFinite(pct)
+ ? Math.min(100, Math.round(pct))
: undefined;
}
+/** 配额百分比(5h / 7d):与上下文同口径 round + clamp;非法值 ⇒ undefined(省略该段)。 */
+function quotaPercent(value: unknown): number | undefined {
+ return isNonNegativeFinite(value) ? Math.min(100, Math.round(value)) : undefined;
+}
+
export function cardUsageFooterSegment(
usage: CardUsageSnapshot,
locale?: Locale,
@@ -431,7 +444,18 @@ export function cardUsageFooterSegment(
opts?: { compactHintThreshold?: number },
): string | null {
const parts: string[] = [];
- if (usage.context && isNonNegativeFinite(usage.context.usedTokens)) {
+ const quota = usage.quota ?? undefined;
+ const quotaPct = quota ? contextPercentUsed(usage) : undefined;
+ if (quota && quotaPct !== undefined) {
+ // statusline 路径(Claude Code):只渲染纯百分比 `ctx 23%`——不带绝对值(statusline
+ // 的 used_percentage 与 transcript 的 usedTokens 口径不同,混排会自相矛盾)、不画
+ // 进度条、不渲染 resets_at。「建议压缩」提示与下方绝对值分支同源同阈值。
+ const overThreshold = contextOverCompactThreshold(usage, opts?.compactHintThreshold);
+ parts.push(
+ `${t('card.usage.ctx', undefined, locale)} ${quotaPct}%`
+ + (overThreshold ? ` · ${t('card.context.compact_hint', undefined, locale)}` : ''),
+ );
+ } else if (usage.context && isNonNegativeFinite(usage.context.usedTokens)) {
const used = compactTokenCount(usage.context.usedTokens);
const window = usage.context.windowTokens;
const windowSuffix = isNonNegativeFinite(window) && window > 0
@@ -450,6 +474,15 @@ export function cardUsageFooterSegment(
+ (overThreshold ? ` · ${t('card.context.compact_hint', undefined, locale)}` : ''),
);
}
+ // 账号级配额(statusline 独有):5h / 7d 滚动窗口用量,footer 与 streaming 都渲染——
+ // 它比 Token 累计更值得占 footer 的位置(用户关心的是「还能跑多久」)。
+ // 窗口已滚动的桶在读取端已被丢弃(readStatuslineSnapshot),这里只看是否有值。
+ if (quota) {
+ const fiveHour = quotaPercent(quota.fiveHourPercent);
+ if (fiveHour !== undefined) parts.push(`${t('card.usage.quota_5h', undefined, locale)} ${fiveHour}%`);
+ const sevenDay = quotaPercent(quota.sevenDayPercent);
+ if (sevenDay !== undefined) parts.push(`${t('card.usage.quota_7d', undefined, locale)} ${sevenDay}%`);
+ }
// Footer variant is context-only (keeps the cramped reply-card footer clean);
// the token breakdown below is streaming-only.
if (variant !== 'streaming') {
diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts
index f7bf78b44f..ece00ea92e 100644
--- a/src/services/bot-config-store.ts
+++ b/src/services/bot-config-store.ts
@@ -43,6 +43,7 @@ import {
MIN_CARD_ACTION_ACK_TIMEOUT_MS,
} from '../core/card-action-ack.js';
import { parseHiddenStreamingCardButtonsInput } from '../im/lark/streaming-card-buttons.js';
+import { defaultReplyDeliveryFor, supportsTranscriptReplyDelivery } from '../core/reply-delivery.js';
/**
* 生效时机:
@@ -68,6 +69,9 @@ export interface ConfigFieldSpec {
defaultOn?: boolean;
/** kind==='enum' 时的合法取值(已小写)。 */
enumValues?: readonly string[];
+ /** kind==='enum' 且缺省即某个取值的字段:未设置时 `/config get` 显示该值而非 ∅。
+ * 缺省随 bot 其它配置变化(如按 cliId)时给函数,展示时按当前 config 求值。 */
+ enumDefault?: string | ((cfg: BotConfig) => string);
/** kind==='string' 的最大长度(trim 后计),超出 coerce 报 too_long。缺省不限。 */
maxLen?: number;
/** kind==='number' 的闭区间下界;缺省仍只要求正整数。 */
@@ -118,6 +122,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [
{ key: 'disableCliBypass', configKey: 'disableCliBypass', kind: 'boolean', effect: 'next-session', clearable: false, hint: '不加 CLI 审批/sandbox 绕过参数 on|off' },
{ key: 'codexAppCleanInput', configKey: 'codexAppCleanInput', kind: 'boolean', effect: 'immediate', clearable: false, hint: '实验性:Codex App 用户气泡只保留真实输入,Botmux 元数据走隐藏上下文;默认 off,从下一次 turn 派发生效,不改已有历史' },
{ key: 'envelopeInjection', configKey: 'envelopeInjection', kind: 'enum', effect: 'immediate', clearable: true, enumValues: ['auto', 'off'], hint: '每轮上下文注入方式:auto=支持的 CLI(claude-code)把提醒/白板经 hook 注入为系统提醒,输入框只留消息本身,不支持的自动回退|off=内联(默认);unset 回 off' },
+ { key: 'replyDelivery', configKey: 'replyDelivery', kind: 'enum', effect: 'next-session', clearable: true, enumValues: ['send', 'transcript'], enumDefault: cfg => defaultReplyDeliveryFor(cfg.cliId), hint: '最终回复投递方式:transcript=从 CLI 转写自动取最终回复发卡,模型不再被要求 botmux send(claude-code 默认)|send=模型必须自己 botmux send(其它 CLI 默认);仅 claude-code 与 codex/traex/coco/hermes/mtr/pi/oh-my-pi/ebsd/grok 支持 transcript;系统提示需 /restart 才换新值,逐轮信封立即生效;unset 回各 CLI 默认' },
{ key: 'senderTag', configKey: 'senderTag', kind: 'boolean', effect: 'immediate', clearable: false, defaultOn: true, hint: '每轮注入 发言人标签 on|off(默认 on):标注本轮是谁在说话(open_id/姓名/邮箱)。关掉后模型看不到发言人身份,多人会话里无法区分谁说的;--mention-back 不受影响(走 daemon 侧独立记录)。代价:/adopt 少一条识别本 bot 自产会话的指纹,dashboard 洞察无法从标签判断发言人类型与 A2A 对方名字' },
{ key: 'restrictGrantCommands', configKey: 'restrictGrantCommands', kind: 'boolean', effect: 'immediate', clearable: false, hint: '被授权人仅能纯对话、拦截斜杠命令 on|off' },
{ key: 'p2pOpen', configKey: 'p2pOpen', kind: 'boolean', effect: 'immediate', clearable: false, hint: '私聊对话全开 on|off:任何能看到本 bot 的人都可私聊(只放行对话;管理操作默认仍只认 allowedUsers,被 canTalkDaemonCommands 显式降级的命令除外);不影响群聊' },
@@ -154,8 +159,8 @@ export function parseBooleanValue(raw: string): boolean | undefined {
return undefined;
}
-/** 展示某字段当前值的人类可读文本。 */
-function formatFieldValue(spec: ConfigFieldSpec, value: unknown): string {
+/** 展示某字段当前值的人类可读文本。`cfg` 供 enumDefault 为函数的字段求缺省值。 */
+function formatFieldValue(spec: ConfigFieldSpec, value: unknown, cfg: BotConfig): string {
if (spec.kind === 'boolean') return (spec.defaultOn ? value !== false : value === true) ? 'on' : 'off';
if (spec.kind === 'allowedUsers' || spec.kind === 'stringList') {
const arr = Array.isArray(value) ? value : [];
@@ -189,7 +194,10 @@ function formatFieldValue(spec: ConfigFieldSpec, value: unknown): string {
if (spec.kind === 'json') {
return value === undefined || value === null ? '∅' : JSON.stringify(value);
}
- if (value === undefined || value === null || value === '') return '∅';
+ if (value === undefined || value === null || value === '') {
+ const d = typeof spec.enumDefault === 'function' ? spec.enumDefault(cfg) : spec.enumDefault;
+ return d ?? '∅';
+ }
return String(value);
}
@@ -210,7 +218,7 @@ export function getConfigSnapshot(larkAppId: string): {
const cfg = bot.config;
const rows: ConfigSnapshotRow[] = CONFIG_FIELDS.map(spec => ({
key: spec.key,
- value: formatFieldValue(spec, (cfg as any)[spec.configKey]),
+ value: formatFieldValue(spec, (cfg as any)[spec.configKey], cfg),
effect: spec.effect,
}));
return {
@@ -267,10 +275,13 @@ async function applyConfigFieldInternal(
const previousPinStreamingCard = spec.configKey === 'pinStreamingCard'
? bot.config.pinStreamingCard === true
: undefined;
- const oldText = formatFieldValue(spec, (bot.config as any)[spec.configKey]);
+ const oldText = formatFieldValue(spec, (bot.config as any)[spec.configKey], bot.config);
// 空数组(stringList 全被过滤)等价清除,bots.json 保持干净。
- const effective = spec.kind === 'stringList' && Array.isArray(value) && value.length === 0 ? null : value;
+ // replyDelivery 的 send / transcript 都显式落盘:缺省按 CLI 走(claude-code 缺省
+ // transcript),`set send` 是 claude-code 退回旧行为的唯一方式;只有 unset 才删 key。
+ const effective = spec.kind === 'stringList' && Array.isArray(value) && value.length === 0 ? null
+ : value;
const r = await rmwBotEntry(larkAppId, (entry) => {
const currentCliId = typeof entry.cliId === 'string' && entry.cliId.trim()
@@ -305,6 +316,11 @@ async function applyConfigFieldInternal(
&& !cliModelSupportsReasoningEffort(nextCliId, nextModel, nextReasoningEffort)) {
return { write: false, result: 'reasoning_effort_not_supported_by_model' };
}
+ // transcript 依赖 CLI 有转写采集通道;没有的 CLI 拒绝写入,而不是落盘后运行时静默回落 send。
+ if (spec.configKey === 'replyDelivery' && effective === 'transcript'
+ && !supportsTranscriptReplyDelivery(nextCliId)) {
+ return { write: false, result: 'reply_delivery_unsupported' };
+ }
if (effective === null) {
delete entry[spec.configKey];
} else if (spec.kind === 'boolean') {
@@ -343,7 +359,7 @@ async function applyConfigFieldInternal(
if (spec.configKey === 'cliId' && !isConfigurableReasoningCliId(String(effective ?? bot.config.cliId))) {
bot.config.reasoningEffort = undefined;
}
- const newText = formatFieldValue(spec, (bot.config as any)[spec.configKey]);
+ const newText = formatFieldValue(spec, (bot.config as any)[spec.configKey], bot.config);
if (spec.configKey === 'feedback') {
try {
const path = sendCredFilePath(config.session.dataDir, larkAppId);
diff --git a/src/services/bridge-fallback-gate.ts b/src/services/bridge-fallback-gate.ts
index 91571bdd96..611882df77 100644
--- a/src/services/bridge-fallback-gate.ts
+++ b/src/services/bridge-fallback-gate.ts
@@ -283,11 +283,61 @@ function markerSetCoversFinal(markers: readonly BridgeSendMarker[], finalText: s
return !finalIsMateriallyLongerThanSends(finalNormalized.length, structuredMarkers);
}
+/** A bounded preview is a prefix of the send body, with a trailing「…」when it
+ * was cut. Compare it against the final on that basis. */
+function previewMatchesFinal(previewText: string, finalNormalized: string): boolean {
+ const body = previewText.endsWith('…') ? previewText.slice(0, -1) : previewText;
+ const normalizedPreview = normaliseForFingerprint(body);
+ if (!normalizedPreview) return true;
+ return finalNormalized.startsWith(normalizedPreview);
+}
+
+/**
+ * transcript-mode duplicate test — the counterpart of {@link markerSetCoversFinal}.
+ *
+ * Under `send` the final is a FALLBACK, so "the model already sent something
+ * comparable" is reason enough to drop it. Under `transcript` the final IS the
+ * delivery channel, so the same reasoning would silently eat the turn's real
+ * answer whenever the model also pushed something mid-turn (an attachment note,
+ * a progress line) — and mid-turn sends are explicitly legitimate there.
+ *
+ * So the bar is inverted: suppress ONLY when the final is the same content that
+ * already went out. A marker stores the fingerprint-normalized LENGTH plus a
+ * bounded preview, never the full body, so "same content" is judged by exact
+ * length equality confirmed by the preview prefix. Anything of a different
+ * length is delivered.
+ *
+ * Markers with no `contentLength` (`botmux send --images` with no body, and the
+ * `--voice` path, whose marker is hand-assembled) cannot establish equality at
+ * all, so they never suppress — a duplicate message is a far cheaper failure
+ * than a silently swallowed answer.
+ */
+function markerSetDuplicatesFinal(markers: readonly BridgeSendMarker[], finalText: string | undefined): boolean {
+ const finalNormalized = normaliseForFingerprint(finalText ?? '');
+ // Empty final. This is NOT only "the model said nothing": emitReadyCodexTurns
+ // re-runs this gate for SYNTHESISED failure cards / empty-turn diagnostics,
+ // whose visible text lives in `content`, never in finalText. Suppressing them
+ // unconditionally would swallow the failure reason on a turn where the model
+ // sent nothing at all — and it would not even match `send`, which delivers on
+ // "empty final + zero markers" (markerSetCoversFinal returns false there).
+ // So mirror that: suppress only when something actually went out this turn.
+ if (!finalNormalized) return markers.length > 0;
+ return markers.some(marker => {
+ if (marker.contentLength !== finalNormalized.length) return false;
+ return marker.previewText === undefined
+ || previewMatchesFinal(marker.previewText, finalNormalized);
+ });
+}
+
export function shouldSuppressBridgeEmit(
turn: BridgeGateInput,
nextBoundaryMs: number | undefined,
markers: readonly BridgeSendMarker[],
adoptMode: boolean,
+ /** How this session's final reply reaches Lark. Under 'transcript' the final
+ * is the delivery channel rather than a fallback, which inverts two of the
+ * rules below. Defaults to the historical 'send' semantics. */
+ replyDelivery: 'send' | 'transcript' = 'send',
): boolean {
if (adoptMode) return false;
if (isBridgeNothingToSendFinal(turn.finalText)) return true;
@@ -313,7 +363,10 @@ export function shouldSuppressBridgeEmit(
const visibleFinalText = turn.finalText === undefined
? undefined
: stripTrailingOaiMemoryCitation(turn.finalText);
- if (visibleFinalText !== undefined
+ // transcript 例外:那里 prose 就是本轮答案,模型习惯性在末尾补 sentinel
+ // (提示词仍教它)不该让整轮答案消失。剥掉 sentinel 后交给下面的重复判定。
+ if (replyDelivery !== 'transcript'
+ && visibleFinalText !== undefined
&& hasTrailingBridgeSentinelLine(visibleFinalText)
&& markersInWindow.length > 0) {
return true;
@@ -326,7 +379,9 @@ export function shouldSuppressBridgeEmit(
const gatedFinal = visibleFinalText === undefined
? undefined
: stripTrailingBridgeSentinelLine(visibleFinalText);
- return markerSetCoversFinal(markersInWindow, gatedFinal);
+ return replyDelivery === 'transcript'
+ ? markerSetDuplicatesFinal(markersInWindow, gatedFinal)
+ : markerSetCoversFinal(markersInWindow, gatedFinal);
}
/** Some structured CLIs can report a durable completed turn while their
@@ -362,12 +417,13 @@ export function shouldEmitEmptyCompletedBridgeFallback(
nextBoundaryMs: number | undefined,
markers: readonly BridgeSendMarker[],
adoptMode: boolean,
+ replyDelivery: 'send' | 'transcript' = 'send',
): boolean {
if (adoptMode) return false;
if (turn.isLocal) return false;
if (turn.terminalStatus !== undefined && turn.terminalStatus !== 'completed') return false;
if ((turn.finalText ?? '').trim().length > 0) return false;
- return !shouldSuppressBridgeEmit(turn, nextBoundaryMs, markers, adoptMode);
+ return !shouldSuppressBridgeEmit(turn, nextBoundaryMs, markers, adoptMode, replyDelivery);
}
/** 结构化失败回合补发可见错误;部分回答不能替代失败原因。 */
@@ -376,11 +432,12 @@ export function shouldEmitFailedBridgeFallback(
nextBoundaryMs: number | undefined,
markers: readonly BridgeSendMarker[],
adoptMode: boolean,
+ replyDelivery: 'send' | 'transcript' = 'send',
): boolean {
if (adoptMode) return false;
if (turn.isLocal) return false;
if (turn.terminalStatus !== 'failed') return false;
- return !shouldSuppressBridgeEmit(turn, nextBoundaryMs, markers, adoptMode);
+ return !shouldSuppressBridgeEmit(turn, nextBoundaryMs, markers, adoptMode, replyDelivery);
}
/** Which fallback content the worker should post for a ready structured turn.
@@ -401,15 +458,16 @@ export function structuredFallbackKind(
markers: readonly BridgeSendMarker[],
adoptMode: boolean,
hasDedicatedRateLimitChain: boolean,
+ replyDelivery: 'send' | 'transcript' = 'send',
): StructuredFallbackKind {
const rateLimitHandled = hasDedicatedRateLimitChain
&& turn.terminalErrorCode === CODEX_RATE_LIMIT_ERROR_CODE;
if (!rateLimitHandled
- && shouldEmitFailedBridgeFallback(turn, nextBoundaryMs, markers, adoptMode)) {
+ && shouldEmitFailedBridgeFallback(turn, nextBoundaryMs, markers, adoptMode, replyDelivery)) {
return 'failed';
}
if (turn.finalText && turn.finalText.trim()) return 'final';
- if (shouldEmitEmptyCompletedBridgeFallback(turn, nextBoundaryMs, markers, adoptMode)) {
+ if (shouldEmitEmptyCompletedBridgeFallback(turn, nextBoundaryMs, markers, adoptMode, replyDelivery)) {
return 'empty_completed';
}
return 'none';
diff --git a/src/services/session-store.ts b/src/services/session-store.ts
index 12c24b2edd..180ac999cc 100644
--- a/src/services/session-store.ts
+++ b/src/services/session-store.ts
@@ -9,6 +9,7 @@ import { cleanupMaterializedDashboardImages } from '../core/dashboard-images.js'
import { getSessionTokenUsage } from '../core/cost-calculator.js';
import { deleteFrozenCards } from './frozen-card-store.js';
import { removePromptContextDir } from './prompt-context-store.js';
+import { removeStatuslineDir } from './statusline-snapshot.js';
import {
applySessionRowCommand,
type HostSessionCommand,
@@ -2123,6 +2124,8 @@ export function closeSession(
// #794: per-turn hook sidecar 与 turn-sends 同生命周期,关会话一并清掉,
// 否则 prompt-ctx// 成为孤儿目录(24h TTL 兜底但 daemon 长命会累积)。
removePromptContextDir(sessionId);
+ // Claude statusline 快照目录同生命周期(best-effort,内部吞错)。
+ removeStatuslineDir(config.session.dataDir, sessionId);
deleteFrozenCards(sessionId);
logger.info(`Closed session ${sessionId}`);
}
diff --git a/src/services/statusline-snapshot.ts b/src/services/statusline-snapshot.ts
new file mode 100644
index 0000000000..0f045a672f
--- /dev/null
+++ b/src/services/statusline-snapshot.ts
@@ -0,0 +1,205 @@
+/**
+ * statusline-snapshot.ts — Claude Code statusline 数据的落盘与读取。
+ *
+ * Claude Code 会把一份 JSON(`context_window`、`rate_limits`、`model`、
+ * `transcript_path` …)喂给 settings 里配置的 `statusLine.command`(stdin),
+ * 触发时机:每条 assistant 消息后、/compact 后、到达 `resets_at` 时、以及可选的
+ * `refreshInterval` 定时。botmux 用进程级 `--settings` 把该命令指向
+ * `botmux statusline`,由它把快照写到 `/statusline//latest.json`;
+ * daemon 在组装卡片用量段时按 mtime 缓存读回,渲染成 `ctx 23% · 5h 18% · 7d 5%`。
+ *
+ * 为什么是每会话一个目录而不是单文件:沙盒(bwrap / Seatbelt)对单文件只授
+ * readWrite,父目录不可写,原子写的 tmp+rename 必败;目录粒度既保原子写又隔离会话。
+ *
+ * 为什么不塞进 SessionUsageSnapshot:5h/7d 是账号级配额,不是 session transcript
+ * 的解析结果,职责边界不同;在卡片组装点合并即可。
+ *
+ * 本模块保持叶子:只依赖 node:fs 与 atomic-write,daemon 与 CLI 子进程都能用;
+ * dataDir 由调用方传入(daemon 用 config.session.dataDir,CLI 用 resolveDataDir())。
+ */
+import { mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { atomicWriteFileSync } from '../utils/atomic-write.js';
+
+export interface StatuslineSnapshot {
+ /** context_window.used_percentage,0–100。 */
+ contextPercent?: number;
+ /** context_window.context_window_size(token)。 */
+ contextWindowTokens?: number;
+ /** rate_limits.five_hour.used_percentage,0–100。 */
+ fiveHourPercent?: number;
+ /** rate_limits.five_hour.resets_at,已换算为毫秒。 */
+ fiveHourResetsAtMs?: number;
+ sevenDayPercent?: number;
+ sevenDayResetsAtMs?: number;
+ /** model.id,仅存档;卡片的 runtime 段另有来源。 */
+ model?: string;
+ transcriptPath?: string;
+ /** Claude 自己的 session_id(≠ botmux sessionId)。 */
+ claudeSessionId?: string;
+ /** 写入时刻(ms)。 */
+ ts: number;
+}
+
+/** 卡片用量段需要的子集(md-card 的 `CardUsageSnapshot.quota` 复用此类型)。 */
+export interface StatuslineQuota {
+ contextPercent?: number;
+ contextWindowTokens?: number;
+ fiveHourPercent?: number;
+ fiveHourResetsAtMs?: number;
+ sevenDayPercent?: number;
+ sevenDayResetsAtMs?: number;
+}
+
+export const STATUSLINE_DIR_NAME = 'statusline';
+export const STATUSLINE_FILE_BASENAME = 'latest.json';
+/** 快照最长可信时长:Claude 退出 / --settings 被 wrapper 剥掉 / 旧版不认
+ * refreshInterval 时,陈旧值最多存活这么久就自动消失(卡片回到「无数据即省略」)。 */
+export const STATUSLINE_STALE_MS = 10 * 60_000;
+
+export function statuslineDir(dataDir: string, sessionId: string): string {
+ return join(dataDir, STATUSLINE_DIR_NAME, sessionId);
+}
+
+export function statuslineFilePath(dataDir: string, sessionId: string): string {
+ return join(statuslineDir(dataDir, sessionId), STATUSLINE_FILE_BASENAME);
+}
+
+function finiteNumber(v: unknown): number | undefined {
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
+}
+
+function percent(v: unknown): number | undefined {
+ const n = finiteNumber(v);
+ if (n === undefined) return undefined;
+ if (n < 0) return 0;
+ if (n > 100) return 100;
+ return n;
+}
+
+/** resets_at:文档为 Unix 秒;启发式兼容毫秒(< 1e12 视为秒)。 */
+function resetsAtMs(v: unknown): number | undefined {
+ const n = finiteNumber(v);
+ if (n === undefined || n <= 0) return undefined;
+ return n < 1e12 ? Math.round(n * 1000) : Math.round(n);
+}
+
+function nonEmptyString(v: unknown): string | undefined {
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
+}
+
+/**
+ * 把 Claude 喂进 stdin 的原始 JSON 规范成快照:逐字段容错(缺失 / 非数值的字段
+ * 丢弃,不影响其它字段);整体不是对象 → null。写端与读端共用,口径一致。
+ */
+export function parseStatuslinePayload(raw: unknown, now: number = Date.now()): StatuslineSnapshot | null {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const o = raw as Record;
+ const cw = o.context_window && typeof o.context_window === 'object' ? o.context_window : undefined;
+ const rl = o.rate_limits && typeof o.rate_limits === 'object' ? o.rate_limits : undefined;
+ const five = rl?.five_hour && typeof rl.five_hour === 'object' ? rl.five_hour : undefined;
+ const seven = rl?.seven_day && typeof rl.seven_day === 'object' ? rl.seven_day : undefined;
+ const snap: StatuslineSnapshot = { ts: now };
+ const set = (k: K, v: StatuslineSnapshot[K] | undefined) => {
+ if (v !== undefined) (snap as any)[k] = v;
+ };
+ set('contextPercent', percent(cw?.used_percentage));
+ const windowTokens = finiteNumber(cw?.context_window_size);
+ set('contextWindowTokens', windowTokens !== undefined && windowTokens > 0 ? Math.round(windowTokens) : undefined);
+ set('fiveHourPercent', percent(five?.used_percentage));
+ set('fiveHourResetsAtMs', resetsAtMs(five?.resets_at));
+ set('sevenDayPercent', percent(seven?.used_percentage));
+ set('sevenDayResetsAtMs', resetsAtMs(seven?.resets_at));
+ set('model', nonEmptyString(o.model?.id));
+ set('transcriptPath', nonEmptyString(o.transcript_path));
+ set('claudeSessionId', nonEmptyString(o.session_id));
+ return snap;
+}
+
+/** CLI 侧写:mkdir -p(0700)+ 原子写(0600)。异常向上抛,由调用方决定是否吞掉。 */
+export function writeStatuslineSnapshot(dataDir: string, sessionId: string, snap: StatuslineSnapshot): void {
+ mkdirSync(statuslineDir(dataDir, sessionId), { recursive: true, mode: 0o700 });
+ atomicWriteFileSync(statuslineFilePath(dataDir, sessionId), JSON.stringify(snap), { mode: 0o600 });
+}
+
+interface CacheEntry { mtimeMs: number; size: number; snap: StatuslineSnapshot | undefined }
+const cache = new Map();
+
+function readSnapshotFile(path: string): StatuslineSnapshot | undefined {
+ let raw: unknown;
+ try { raw = JSON.parse(readFileSync(path, 'utf-8')); } catch { return undefined; }
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
+ const o = raw as Record;
+ const ts = finiteNumber(o.ts);
+ if (ts === undefined) return undefined;
+ const snap: StatuslineSnapshot = { ts };
+ const p = (v: unknown) => percent(v);
+ const ms = (v: unknown) => { const n = finiteNumber(v); return n !== undefined && n > 0 ? n : undefined; };
+ if (p(o.contextPercent) !== undefined) snap.contextPercent = p(o.contextPercent);
+ const wt = finiteNumber(o.contextWindowTokens);
+ if (wt !== undefined && wt > 0) snap.contextWindowTokens = wt;
+ if (p(o.fiveHourPercent) !== undefined) snap.fiveHourPercent = p(o.fiveHourPercent);
+ if (ms(o.fiveHourResetsAtMs) !== undefined) snap.fiveHourResetsAtMs = ms(o.fiveHourResetsAtMs);
+ if (p(o.sevenDayPercent) !== undefined) snap.sevenDayPercent = p(o.sevenDayPercent);
+ if (ms(o.sevenDayResetsAtMs) !== undefined) snap.sevenDayResetsAtMs = ms(o.sevenDayResetsAtMs);
+ if (nonEmptyString(o.model)) snap.model = o.model as string;
+ if (nonEmptyString(o.transcriptPath)) snap.transcriptPath = o.transcriptPath as string;
+ if (nonEmptyString(o.claudeSessionId)) snap.claudeSessionId = o.claudeSessionId as string;
+ return snap;
+}
+
+/**
+ * daemon / CLI 侧读:按 (mtimeMs, size) 缓存,文件没变不重新 parse(一次 stat 比一次
+ * parse 便宜得多,12s tick 可承受)。文件缺失 / 损坏 → undefined;`ts` 早于
+ * `now - maxAgeMs` → undefined;某桶 `resetsAtMs <= now` → 该桶百分比丢弃
+ * (窗口已滚动,旧百分比必错,但重置时间本身仍可能有用所以保留)。
+ */
+export function readStatuslineSnapshot(
+ dataDir: string,
+ sessionId: string,
+ opts?: { now?: number; maxAgeMs?: number },
+): StatuslineSnapshot | undefined {
+ const now = opts?.now ?? Date.now();
+ const maxAgeMs = opts?.maxAgeMs ?? STATUSLINE_STALE_MS;
+ const path = statuslineFilePath(dataDir, sessionId);
+ let st: { mtimeMs: number; size: number };
+ try { st = statSync(path); } catch { cache.delete(path); return undefined; }
+ let entry = cache.get(path);
+ if (!entry || entry.mtimeMs !== st.mtimeMs || entry.size !== st.size) {
+ entry = { mtimeMs: st.mtimeMs, size: st.size, snap: readSnapshotFile(path) };
+ cache.set(path, entry);
+ }
+ const snap = entry.snap;
+ if (!snap) return undefined;
+ if (snap.ts < now - maxAgeMs) return undefined;
+ const out: StatuslineSnapshot = { ...snap };
+ if (out.fiveHourResetsAtMs !== undefined && out.fiveHourResetsAtMs <= now) delete out.fiveHourPercent;
+ if (out.sevenDayResetsAtMs !== undefined && out.sevenDayResetsAtMs <= now) delete out.sevenDayPercent;
+ return out;
+}
+
+/** 会话关闭清理(与 prompt-ctx 目录同时机)。任何错误吞掉。 */
+export function removeStatuslineDir(dataDir: string, sessionId: string): void {
+ const dir = statuslineDir(dataDir, sessionId);
+ cache.delete(join(dir, STATUSLINE_FILE_BASENAME));
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
+}
+
+/** 快照 → 卡片用量段子集。百分比四舍五入到整数(statusline 给的是
+ * `14.000000000000002` 这类浮点);一个字段都没有时返回 undefined,调用方不带 key。 */
+export function toCardQuota(snap: StatuslineSnapshot | undefined): StatuslineQuota | undefined {
+ if (!snap) return undefined;
+ const q: StatuslineQuota = {};
+ if (snap.contextPercent !== undefined) q.contextPercent = Math.round(snap.contextPercent);
+ if (snap.contextWindowTokens !== undefined) q.contextWindowTokens = snap.contextWindowTokens;
+ if (snap.fiveHourPercent !== undefined) q.fiveHourPercent = Math.round(snap.fiveHourPercent);
+ if (snap.fiveHourResetsAtMs !== undefined) q.fiveHourResetsAtMs = snap.fiveHourResetsAtMs;
+ if (snap.sevenDayPercent !== undefined) q.sevenDayPercent = Math.round(snap.sevenDayPercent);
+ if (snap.sevenDayResetsAtMs !== undefined) q.sevenDayResetsAtMs = snap.sevenDayResetsAtMs;
+ return Object.keys(q).length > 0 ? q : undefined;
+}
+
+/** 测试用:清空 mtime 缓存。 */
+export function __testOnly_resetStatuslineCache(): void {
+ cache.clear();
+}
diff --git a/src/types.ts b/src/types.ts
index a468d18967..104bc002dd 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1240,7 +1240,7 @@ export interface PendingRepoSetup {
/** Messages sent from Daemon to Worker */
type DaemonToWorkerBase =
- | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliRuntime?: import('./adapters/cli/runtime.js').CliRuntimeSnapshot; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; modelBackendVariant?: 'standard' | 'max'; turnTimeoutMs?: number; dshProfile?: string; dshRuntime?: 'official' | 'tui'; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; disableCliBypass?: boolean; codexBrowser?: import('./core/codex-browser-config.js').CodexBrowserConfig; codexRpcInput?: boolean; codexAuthSync?: import('./services/codex-auth-sync.js').CodexAuthSyncMode; triggerUserAuth?: import('./services/trigger-user-auth.js').TriggerUserAuthConfig; existingAppServerEndpoint?: string; startupCommands?: string[]; env?: Record; replyStyle?: import('./im/lark/reply-card-style.js').ReplyStyleConfig; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig | MojoConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; queuedActivationToken?: string; resume?: boolean; forkSession?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; apiOnly?: boolean; loadedBotsConfigPath?: string; loadedBotsConfigProvenance?: import('./core/config-dir.js').BotsConfigProvenance; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; replyTurnId?: string; dispatchAttempt?: number; atMostOnce?: boolean; codexAppDispatchId?: string; codexAppSteerable?: true; codexAppRecoveredDispatches?: CodexAppDispatchLedgerEntry[]; codexAppGenerationCommits?: CodexAppGenerationCommit[]; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; trustedCaller?: TrustedCaller; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string }
+ | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliRuntime?: import('./adapters/cli/runtime.js').CliRuntimeSnapshot; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; modelBackendVariant?: 'standard' | 'max'; turnTimeoutMs?: number; dshProfile?: string; dshRuntime?: 'official' | 'tui'; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; disableCliBypass?: boolean; codexBrowser?: import('./core/codex-browser-config.js').CodexBrowserConfig; codexRpcInput?: boolean; codexAuthSync?: import('./services/codex-auth-sync.js').CodexAuthSyncMode; triggerUserAuth?: import('./services/trigger-user-auth.js').TriggerUserAuthConfig; existingAppServerEndpoint?: string; startupCommands?: string[]; env?: Record; replyStyle?: import('./im/lark/reply-card-style.js').ReplyStyleConfig; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig | MojoConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; queuedActivationToken?: string; resume?: boolean; forkSession?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; apiOnly?: boolean; replyDelivery?: 'send' | 'transcript'; solo?: boolean; loadedBotsConfigPath?: string; loadedBotsConfigProvenance?: import('./core/config-dir.js').BotsConfigProvenance; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; replyTurnId?: string; dispatchAttempt?: number; atMostOnce?: boolean; codexAppDispatchId?: string; codexAppSteerable?: true; codexAppRecoveredDispatches?: CodexAppDispatchLedgerEntry[]; codexAppGenerationCommits?: CodexAppGenerationCommit[]; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; trustedCaller?: TrustedCaller; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string }
/** `model` rides along on every turn for the SAME reason the restart IPC carries
* it: the crash-loop park recovery respawns the CLI from inside the worker on
* the next message, with no restart IPC to refresh the snapshot. Same
diff --git a/src/utils/child-env.ts b/src/utils/child-env.ts
index acc67c2686..e71bd4cbe0 100644
--- a/src/utils/child-env.ts
+++ b/src/utils/child-env.ts
@@ -424,6 +424,10 @@ export const BOTMUX_INJECTED_ENV_KEYS = [
'BOTMUX_LARK_LIST_BOTS_API_ENABLED',
'BOTMUX_LARK_LIST_BOTS_API_TIMEOUT_MS',
'BOTMUX_READY_COMMAND',
+ // Per-session computed shell command string: the user's own statusLine
+ // command that `botmux statusline` chains to after persisting the snapshot.
+ // Not a credential — it is what the user already put in their settings.json.
+ 'BOTMUX_STATUSLINE_CHAIN',
// Path to a one-shot 0600 Codex App control bootstrap. Only the path reaches
// the pane; the runner consumes+unlinks the file before app-server starts.
'BOTMUX_CODEX_APP_CONTROL_BOOTSTRAP',
@@ -537,6 +541,9 @@ export const SESSION_TURN_MARKER_ENV_KEYS = [
'BOTMUX_CODEX_APP_CONTROL_BOOTSTRAP',
// Ready-gate hook command, sessionReadyHookCommand() per session.
'BOTMUX_READY_COMMAND',
+ // Shadowed user statusLine command, resolved per session from the spawn cwd
+ // + user settings (worker.ts); an inherited copy points at another project.
+ 'BOTMUX_STATUSLINE_CHAIN',
// Per-app value pinned by the ecosystemConfig env block; an inherited copy
// is untrusted (the daemon resolves its bot via BOTMUX_BOT_INDEX).
'BOTMUX_LARK_APP_ID',
diff --git a/src/worker.ts b/src/worker.ts
index e1b4c37d99..a470303155 100644
--- a/src/worker.ts
+++ b/src/worker.ts
@@ -283,8 +283,9 @@ import {
} from './core/session-discovery.js';
import { CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS, RpcEngagementFence, codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane, rpcTranscriptIngestBlockedByAwaitingActivation, type EngageOutcome } from './codex-rpc-lifecycle.js';
import { delay } from './utils/timing.js';
-import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
+import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, resolveShadowedStatusLine, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
import { sessionReadyHookCommand } from './adapters/hook-command.js';
+import { statuslineDir } from './services/statusline-snapshot.js';
import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js';
import { ompSessionDir } from './adapters/cli/oh-my-pi.js';
import { assertEbsdPerBotEnv, ebsdBotmuxSessionDir } from './adapters/cli/ebsd.js';
@@ -2248,6 +2249,13 @@ function ensureZellijAttachConfig(): string {
let sessionId = '';
let lastInitConfig: Extract | null = null;
+
+/** 本会话最终回复的投递方式。daemon 在 init 上冻结(core/reply-delivery.ts),
+ * 抑制闸据此判断 final 是「兜底」还是「投递通道」。读不到一律 'send'——
+ * fail-closed 等于历史行为。 */
+function replyDeliveryMode(): 'send' | 'transcript' {
+ return lastInitConfig?.replyDelivery === 'transcript' ? 'transcript' : 'send';
+}
let closeRequested = false;
/** Dashboard「复现命令」:session 冷启时最终交给 backend.spawn 的真实调用
* (bin + argv + cwd + 关键 env)。原样保留,worker `ready` 时随消息上报给 daemon
@@ -4774,7 +4782,7 @@ function deliverMojoTurnFinal(text: string): void {
isLocal: false,
finalText: text,
};
- if (shouldSuppressBridgeEmit(gateInput, undefined, markers, adoptMode)) {
+ if (shouldSuppressBridgeEmit(gateInput, undefined, markers, adoptMode, replyDeliveryMode())) {
log(
`Mojo final bridge suppressed for turn ${turnId.substring(0, 12)} `
+ `(${isBridgeNothingToSendFinal(text) ? 'nothing-to-send sentinel' : 'model already called botmux send'})`,
@@ -6052,7 +6060,7 @@ function emitReadyTurns(opts: { explicitTerminalOnly?: boolean } = {}): void {
// provider error through transcript fallback (regardless of send markers).
if (turn.terminalOutcome && turn.terminalOutcome.status !== 'completed') continue;
const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
- if (turn.isLocal && shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
+ if (turn.isLocal && shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode, replyDeliveryMode())) {
const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
continue;
@@ -6078,7 +6086,7 @@ function emitReadyTurns(opts: { explicitTerminalOnly?: boolean } = {}): void {
const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
const gateInput = { markTimeMs: turn.markTimeMs, isLocal: turn.isLocal, finalText: assistantText };
- if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
+ if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode, replyDeliveryMode())) {
// Completed turn whose output went out via `botmux send` (or deliberate
// silence) — see the codex bridge's twin for why this must arm here.
// Hardcoded 'answered' rather than bridgeTurnOutcome(turn): this queue has
@@ -7708,7 +7716,7 @@ function emitReadyCodexTurns(): void {
// rate-limit chain (Codex): TRAE 429 has no such chain, so skipping the
// generic failed fallback would post nothing at all.
const fallbackKind = structuredFallbackKind(
- gateInput, nextBoundaryMs, markers, adoptMode, structuredBridgeIsCodex(),
+ gateInput, nextBoundaryMs, markers, adoptMode, structuredBridgeIsCodex(), replyDeliveryMode(),
);
const content = fallbackKind === 'failed'
? failedBridgeFallbackContent(turn.terminalErrorCode, turn.terminalErrorSummary, turn.finalText)
@@ -7728,11 +7736,11 @@ function emitReadyCodexTurns(): void {
// the most common success path of all. failed/ambiguous stay a no-op via
// bridgeTurnOutcome, so a limit refusal never reads as success.
const turnOutcome = bridgeTurnOutcome(turn);
- if (!content || shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
+ if (!content || shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode, replyDeliveryMode())) {
usageLimitTracker.noteTurnCompleted(turnOutcome);
}
if (!content) continue;
- if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
+ if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode, replyDeliveryMode())) {
log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
// Distinguish DELIBERATE SILENCE (bare nothing-to-send sentinel, no prose,
// no send) from other suppression reasons (already `botmux send`-ed this
@@ -9818,6 +9826,7 @@ async function handleTrustedCodexAppMarker(
completedAtMs + 5_001,
suppressMarkers,
false,
+ replyDeliveryMode(),
);
if (suppressDelivery) {
log(`${cliName()} final_output suppressed (model already called botmux send)`);
@@ -14431,6 +14440,9 @@ async function spawnCli(
// Adapters whose CLI filters the environment of the shell commands it runs
// (codex) re-declare these; the rest ignore them and inherit normally.
...(Object.keys(identityShellEnv).length ? { shellSubprocessEnv: identityShellEnv } : {}),
+ // replyDelivery=transcript + solo:daemon 冻结在 init 上的值,系统提示改口用。
+ replyDelivery: cfg.replyDelivery,
+ solo: cfg.solo,
locale: cfg.locale,
model: ttadkGateway ? undefined : cfg.model,
modelBackendVariant: cfg.modelBackendVariant,
@@ -14782,6 +14794,25 @@ async function spawnCli(
// rcfile/tmux env (mirrors the chatBotDiscovery injection above).
childEnv.BOTMUX_WORKFLOW_ENABLED = isWorkflowFeatureEnabled() ? 'true' : 'false';
if (cliAdapter.injectsReadyHook) childEnv.BOTMUX_READY_COMMAND = sessionReadyHookCommand();
+ // Claude Code statusline 链:botmux 的进程级 --settings 会遮蔽用户自己的 statusLine
+ // (单值、不合并),这里按 Claude 的优先级把它找回来,交给 `botmux statusline` 在落盘
+ // 后转发。只对真 claude-code 做(seed / relay 不注入 statusLine)。不按 wrapperCli 分流:
+ // aiden 会剥掉 --settings ⇒ Claude 直接用用户自己的 statusLine、`botmux statusline` 根本
+ // 不会被调用,这个 env 只是闲置;而 cjadk / ccr / ttadk 会透传 --settings、沙盒开启时
+ // wrapperCli 又被整体忽略——这些形态都需要链,按 wrapperCli 跳过会静默吞掉用户的状态栏。
+ // userSettingsPath 用 CLI 实际读的那份:read-isolation 下是 /claude/settings.json
+ // (effectiveReadyHookInstall 已改写)。无用户配置时**显式 delete**,理由同上方
+ // BOTMUX_READ_ISOLATION:rcfile / tmux 里残留的旧值会让别的项目的 statusline 命令在本会话里执行。
+ if (cliAdapter.id === 'claude-code') {
+ const shadowed = resolveShadowedStatusLine({
+ workingDir: cfg.workingDir,
+ userSettingsPath: effectiveReadyHookInstall?.configPath ?? cliAdapter.hookInstall?.configPath,
+ });
+ if (shadowed.command) childEnv.BOTMUX_STATUSLINE_CHAIN = shadowed.command;
+ else delete childEnv.BOTMUX_STATUSLINE_CHAIN;
+ } else {
+ delete childEnv.BOTMUX_STATUSLINE_CHAIN;
+ }
// Initial value only; long-lived panes get the latest turn via the JSON pid marker.
if (cfg.turnId) childEnv.BOTMUX_TURN_ID = cfg.turnId;
if (cfg.dispatchAttempt !== undefined) {
@@ -15072,6 +15103,9 @@ async function spawnCli(
} catch { /* */ }
// UserPromptSubmit sidecar 目录(#794):daemon 逐 turn 写入,沙盒内 hook 只读。
try { mkdirSync(join(dataDir, 'prompt-ctx', cfg.sessionId), { recursive: true, mode: 0o700 }); } catch { /* */ }
+ // Claude statusline 快照目录:沙盒内 `botmux statusline` 原子写 latest.json(tmp+rename
+ // 需要目录可写),fs-policy 授的是这个目录;bwrap 不能 bind 不存在的源,先建好。
+ try { mkdirSync(statuslineDir(dataDir, cfg.sessionId), { recursive: true, mode: 0o700 }); } catch { /* */ }
try { mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true }); } catch { /* */ }
// (Schedules moved into each bot's BOT_HOME — the whole dir is already
// bound readWrite for the owner, so no per-file pre-create is needed.)
diff --git a/test/bot-config-store.test.ts b/test/bot-config-store.test.ts
index a8043fed33..c73d42d830 100644
--- a/test/bot-config-store.test.ts
+++ b/test/bot-config-store.test.ts
@@ -803,6 +803,94 @@ describe('bot-config store', () => {
expect(registry.getBot('app_default').config.reasoningEffort).toBe('xhigh');
});
+ it('replyDelivery: claude-code defaults to transcript; explicit send persists; unset clears back to the CLI default', async () => {
+ const { registry, store } = await loaded({ cliId: 'claude-code' });
+ const spec = store.findConfigField('replyDelivery')!;
+ expect(spec.kind).toBe('enum');
+ expect(spec.effect).toBe('next-session');
+ expect(spec.clearable).toBe(true);
+ expect(store.coerceConfigValue(spec, 'TRANSCRIPT')).toEqual({ ok: true, value: 'transcript' });
+ expect(store.coerceConfigValue(spec, 'send')).toEqual({ ok: true, value: 'send' });
+ expect(store.coerceConfigValue(spec, 'auto')).toEqual({ ok: false, reason: 'invalid_enum' });
+
+ // 缺省展示随 CLI(而非 ∅):claude-code 未配置时 /config get 读到的生效值是 transcript。
+ const before = store.getConfigSnapshot('app_default');
+ expect(before.ok && before.rows.find(r => r.key === 'replyDelivery')?.value).toBe('transcript');
+ expect('replyDelivery' in readConfig()).toBe(false);
+ expect(registry.resolveReplyDelivery('app_default')).toBeUndefined();
+
+ // set send:显式落盘 'send'——claude-code 退回旧行为(模型自己 botmux send)的唯一方式。
+ const r1 = await store.applyConfigField('app_default', spec, 'send');
+ expect(r1.ok).toBe(true);
+ if (r1.ok) expect(r1).toMatchObject({ oldText: 'transcript', newText: 'send', effect: 'next-session' });
+ expect(readConfig().replyDelivery).toBe('send');
+ expect(registry.getBot('app_default').config.replyDelivery).toBe('send');
+ expect(registry.resolveReplyDelivery('app_default')).toBe('send');
+
+ // set transcript:显式落盘 'transcript'。
+ const r2 = await store.applyConfigField('app_default', spec, 'transcript');
+ expect(r2.ok).toBe(true);
+ if (r2.ok) expect(r2).toMatchObject({ oldText: 'send', newText: 'transcript' });
+ expect(readConfig().replyDelivery).toBe('transcript');
+ expect(registry.getBot('app_default').config.replyDelivery).toBe('transcript');
+ expect(registry.resolveReplyDelivery('app_default')).toBe('transcript');
+
+ // unset:删 key,回 CLI 缺省(claude-code 展示仍是 transcript),内存同步为 undefined。
+ const r3 = await store.applyConfigField('app_default', spec, null);
+ expect(r3.ok).toBe(true);
+ if (r3.ok) expect(r3).toMatchObject({ oldText: 'transcript', newText: 'transcript' });
+ expect('replyDelivery' in readConfig()).toBe(false);
+ expect(registry.getBot('app_default').config.replyDelivery).toBeUndefined();
+ expect(registry.resolveReplyDelivery('app_default')).toBeUndefined();
+ });
+
+ it('replyDelivery: an explicit "send" in bots.json survives loadBotConfigs (claude-code opts back out)', async () => {
+ const { registry, store } = await loaded({ cliId: 'claude-code', replyDelivery: 'send' });
+ expect(registry.getBot('app_default').config.replyDelivery).toBe('send');
+ expect(registry.resolveReplyDelivery('app_default')).toBe('send');
+ const snap = store.getConfigSnapshot('app_default');
+ expect(snap.ok && snap.rows.find(r => r.key === 'replyDelivery')?.value).toBe('send');
+ });
+
+ it('replyDelivery: non-claude CLIs default to send; transcript persists on structured-bridge CLIs (codex) and unset clears', async () => {
+ const { registry, store } = await loaded({ cliId: 'codex' });
+ const spec = store.findConfigField('replyDelivery')!;
+ const before = store.getConfigSnapshot('app_default');
+ expect(before.ok && before.rows.find(r => r.key === 'replyDelivery')?.value).toBe('send');
+ expect(registry.resolveReplyDelivery('app_default')).toBeUndefined();
+
+ const r1 = await store.applyConfigField('app_default', spec, 'transcript');
+ expect(r1.ok).toBe(true);
+ if (r1.ok) expect(r1).toMatchObject({ oldText: 'send', newText: 'transcript' });
+ expect(readConfig().replyDelivery).toBe('transcript');
+ expect(registry.getBot('app_default').config.replyDelivery).toBe('transcript');
+ expect(registry.resolveReplyDelivery('app_default')).toBe('transcript');
+
+ const r2 = await store.applyConfigField('app_default', spec, null);
+ expect(r2.ok).toBe(true);
+ if (r2.ok) expect(r2).toMatchObject({ oldText: 'transcript', newText: 'send' });
+ expect('replyDelivery' in readConfig()).toBe(false);
+ expect(registry.getBot('app_default').config.replyDelivery).toBeUndefined();
+ });
+
+ it('rejects replyDelivery=transcript for CLIs without transcript capture', async () => {
+ const { registry, store } = await loaded({ cliId: 'cursor' });
+ const spec = store.findConfigField('replyDelivery')!;
+ const before = store.getConfigSnapshot('app_default');
+ expect(before.ok && before.rows.find(r => r.key === 'replyDelivery')?.value).toBe('send');
+ const r = await store.applyConfigField('app_default', spec, 'transcript');
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.reason).toBe('reply_delivery_unsupported');
+ expect('replyDelivery' in readConfig()).toBe(false);
+ expect(registry.getBot('app_default').config.replyDelivery).toBeUndefined();
+
+ // send 在不支持的 CLI 上照样允许,且同样显式落盘。
+ const r2 = await store.applyConfigField('app_default', spec, 'send');
+ expect(r2.ok).toBe(true);
+ expect(readConfig().replyDelivery).toBe('send');
+ expect(registry.resolveReplyDelivery('app_default')).toBe('send');
+ });
+
it('stringList (customPassthroughCommands) coerces, dedupes, drops daemon-shadowing + junk', async () => {
const { store } = await freshModules();
const spec = store.findConfigField('customPassthroughCommands')!;
diff --git a/test/bridge-fallback-gate.test.ts b/test/bridge-fallback-gate.test.ts
index bcc86582d4..6c20b56563 100644
--- a/test/bridge-fallback-gate.test.ts
+++ b/test/bridge-fallback-gate.test.ts
@@ -488,6 +488,90 @@ describe('shouldSuppressBridgeEmit', () => {
expect(shouldSuppressBridgeEmit(turn(100, true), 200, [], false)).toBe(true);
});
+ describe('transcript mode — final is the delivery channel, not a fallback (F1)', () => {
+ // Markers MUST come from the real builder: hand-writing { sentAtMs,
+ // contentLength } always produces the structured shape and would test the
+ // no-contentLength path as a false negative. `--images` with no body and
+ // the `--voice` path both yield a marker with no contentLength, because
+ // buildBridgeSendMarkerContent returns undefined for empty content.
+ const realMarker = (sentAtMs: number, body: string): BridgeSendMarker =>
+ ({ sentAtMs, ...(buildBridgeSendMarkerContent(body) ?? {}) });
+
+ const ANSWER = '这是本轮真正的答案,比中途那条进度消息长一些,但远没到两倍加一百二十字。';
+
+ it('a short mid-turn send no longer swallows the real answer', () => {
+ // send mode: the length ratio gate (2x + 120) suppresses this final...
+ const markers = [realMarker(150, '好的,我看一下')];
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: ANSWER }, 200, markers, false,
+ )).toBe(true);
+ // ...transcript mode delivers it: the lengths differ, so it is not the
+ // same content that already went out.
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: ANSWER }, 200, markers, false, 'transcript',
+ )).toBe(false);
+ });
+
+ it('a body-less send (--images / --voice shape) never suppresses', () => {
+ const imagesOnly = realMarker(150, '');
+ expect(imagesOnly.contentLength).toBeUndefined(); // the shape under test
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: ANSWER }, 200, [imagesOnly], false, 'transcript',
+ )).toBe(false);
+ // Mixing one body-less marker with a structured one must not resurrect
+ // the old back-compat "suppress everything" behaviour either.
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: ANSWER }, 200,
+ [imagesOnly, realMarker(160, '进度')], false, 'transcript',
+ )).toBe(false);
+ });
+
+ it('prose + trailing sentinel is delivered as the prose, even after a send', () => {
+ const markers = [realMarker(150, '附件发你了')];
+ const finalText = `${ANSWER}\n\n${BRIDGE_NOTHING_TO_SEND_SENTINEL}`;
+ // send mode suppresses this regardless of length; transcript must not.
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText }, 200, markers, false,
+ )).toBe(true);
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText }, 200, markers, false, 'transcript',
+ )).toBe(false);
+ });
+
+ it('identical content is still suppressed — dedup must survive the fix', () => {
+ const markers = [realMarker(150, ANSWER)];
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: ANSWER }, 200, markers, false, 'transcript',
+ )).toBe(true);
+ // Same length but different text: the preview prefix rejects the match,
+ // so it is delivered rather than mistaken for the same message.
+ const other = 'X'.repeat(ANSWER.length);
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: other }, 200, markers, false, 'transcript',
+ )).toBe(false);
+ });
+
+ it('an empty final is delivered when nothing was sent — synthesised failure cards depend on it', () => {
+ // emitReadyCodexTurns re-runs this gate for synthesised failure / empty-turn
+ // diagnostics, whose visible text is in `content`, not finalText. Suppressing
+ // on empty finalText would swallow the failure reason — and would not even
+ // match send mode, which delivers on "empty final + zero markers".
+ const empty = { markTimeMs: 100, isLocal: false, finalText: '' };
+ expect(shouldSuppressBridgeEmit(empty, 200, [], false, 'transcript')).toBe(false);
+ expect(shouldSuppressBridgeEmit(empty, 200, [], false)).toBe(false); // send parity
+ // But a mid-turn send in the window still suppresses, same as send mode.
+ const markers = [realMarker(150, '进度更新')];
+ expect(shouldSuppressBridgeEmit(empty, 200, markers, false, 'transcript')).toBe(true);
+ });
+
+ it('a bare sentinel final stays suppressed in transcript mode too', () => {
+ expect(shouldSuppressBridgeEmit(
+ { markTimeMs: 100, isLocal: false, finalText: BRIDGE_NOTHING_TO_SEND_SENTINEL },
+ 200, [], false, 'transcript',
+ )).toBe(true);
+ });
+ });
+
it('non-adopt: emits when no marker landed in window', () => {
const markers: BridgeSendMarker[] = [{ sentAtMs: 50 }, { sentAtMs: 250 }];
// window is [100, 200); both markers fall outside
diff --git a/test/bridge-final-output-retry.test.ts b/test/bridge-final-output-retry.test.ts
index 2472e21abd..6cc53c271a 100644
--- a/test/bridge-final-output-retry.test.ts
+++ b/test/bridge-final-output-retry.test.ts
@@ -68,6 +68,10 @@ vi.mock('../src/bot-registry.js', () => ({
// Reply-card footer usage only renders in 'footer' mode; tests override this
// per case. Default 'footer' keeps the positive usage-render tests below green.
resolveUsageDisplay: vi.fn(() => 'footer'),
+ // Per-bot replyDelivery 的显式配置值。缺省给显式 'send'(旧行为,绝大多数用例的
+ // 前提);transcript 用例逐个翻转。注意 undefined = 未配置,此时 claude-code 会按
+ // CLI 缺省走 transcript(见 completedIdleTurnId 用例)。
+ resolveReplyDelivery: vi.fn((): 'send' | 'transcript' | undefined => 'send'),
}));
vi.mock('../src/config.js', () => ({
@@ -132,7 +136,8 @@ import {
import { listVcMeetingActions } from '../src/services/vc-meeting-action-store.js';
import { listVcMeetingListenerMessageIds } from '../src/services/vc-meeting-listener-message-store.js';
import { getSessionUsageSnapshot } from '../src/core/cost-calculator.js';
-import { getBot, getOwnerOpenId, resolveUsageDisplay } from '../src/bot-registry.js';
+import { getBot, getOwnerOpenId, resolveReplyDelivery, resolveUsageDisplay } from '../src/bot-registry.js';
+import { buildStreamingCard } from '../src/im/lark/card-builder.js';
import {
clearMessageListenerRunPreviewStore,
createMessageListenerRunPreview,
@@ -558,6 +563,129 @@ describe('Bridge final_output delivery (P2 retry)', () => {
expect(String(sessionReply.mock.calls[0][1])).not.toContain('在 adopted pane 中直接输入');
});
+ // ─── transcript 模式:最终回复卡投递成功后给本轮打「已完成」标签 ──────────
+ // replyDelivery=transcript 时最终回复由这条 bridge fallback 主投递;成功后
+ // ds.completedIdleTurnId 记住该轮,idle 卡头改「已完成」。send 模式(缺省)
+ // 必须一个字节都不变。
+ describe('transcript replyDelivery → completedIdleTurnId', () => {
+ function armTranscript(): ReturnType {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('transcript');
+ const sessionReply = vi.fn(async () => 'om_reply');
+ initWorkerPool({ sessionReply, getSessionWorkingDir: () => '/tmp', getActiveCount: () => 1, closeSession: vi.fn() });
+ return sessionReply;
+ }
+
+ it('marks the turn only AFTER the canonical send succeeds', async () => {
+ const sessionReply = armTranscript();
+ const ds = makeDs();
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ // Nothing is marked before the (0ms-deferred) send settles.
+ expect(ds.completedIdleTurnId).toBeUndefined();
+ await vi.advanceTimersByTimeAsync(10);
+ expect(sessionReply).toHaveBeenCalledTimes(1);
+ expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY);
+ expect(ds.completedIdleTurnId).toBe('turn-1');
+ });
+
+ it('a failed send leaves the turn unmarked (retry still pending)', async () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('transcript');
+ const sessionReply = vi.fn(async () => { throw new Error('lark 500'); });
+ initWorkerPool({ sessionReply, getSessionWorkingDir: () => '/tmp', getActiveCount: () => 1, closeSession: vi.fn() });
+ const ds = makeDs();
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(sessionReply).toHaveBeenCalledTimes(1);
+ expect(ds.completedIdleTurnId).toBeUndefined();
+ });
+
+ it('unconfigured replyDelivery on claude-code = transcript by default → marks the turn too', async () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue(undefined);
+ const sessionReply = vi.fn(async () => 'om_reply');
+ initWorkerPool({ sessionReply, getSessionWorkingDir: () => '/tmp', getActiveCount: () => 1, closeSession: vi.fn() });
+ const ds = makeDs();
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(sessionReply).toHaveBeenCalledTimes(1);
+ expect(ds.completedIdleTurnId).toBe('turn-1');
+ });
+
+ it('explicit send mode never sets completedIdleTurnId', async () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('send');
+ const sessionReply = vi.fn(async () => 'om_reply');
+ initWorkerPool({ sessionReply, getSessionWorkingDir: () => '/tmp', getActiveCount: () => 1, closeSession: vi.fn() });
+ const ds = makeDs();
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(sessionReply).toHaveBeenCalledTimes(1);
+ expect(ds.completedIdleTurnId).toBeUndefined();
+ });
+
+ it('stale lineage (a newer turn already opened) does not relabel the live card', async () => {
+ armTranscript();
+ const ds = makeDs();
+ // Type-ahead: turn-2 was admitted while turn-1 was still running.
+ ds.currentTurnId = 'turn-2';
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(ds.lastBridgeEmittedUuid).toBe(SCOPED_DEDUPE_KEY);
+ expect(ds.completedIdleTurnId).toBeUndefined();
+ });
+
+ it('known matching lineage still marks the turn', async () => {
+ armTranscript();
+ const ds = makeDs();
+ ds.currentTurnId = 'turn-1';
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(ds.completedIdleTurnId).toBe('turn-1');
+ });
+
+ it("kind 'local-turn' (terminal-local sync) never marks the turn", async () => {
+ const sessionReply = armTranscript();
+ const ds = makeDs();
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, { ...finalOutputMsg(), kind: 'local-turn', userText: 'question' }, 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(sessionReply).toHaveBeenCalledTimes(1);
+ expect(ds.completedIdleTurnId).toBeUndefined();
+ });
+
+ it('a card already settled to idle is re-patched immediately with the completed label', async () => {
+ armTranscript();
+ const ds = makeDs();
+ ds.workerReady = true;
+ ds.streamCardId = 'om_card';
+ ds.lastScreenStatus = 'idle';
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(ds.completedIdleTurnId).toBe('turn-1');
+ // idle label is the 20th positional arg of buildStreamingCard.
+ const calls = vi.mocked(buildStreamingCard).mock.calls;
+ expect(calls.some(c => c[19] === 'completed')).toBe(true);
+ });
+
+ it('a card still working is NOT patched; the label rides the next status edge', async () => {
+ armTranscript();
+ const ds = makeDs();
+ ds.workerReady = true;
+ ds.streamCardId = 'om_card';
+ ds.lastScreenStatus = 'working';
+ const { __testOnly_deliverFinalOutput } = await import('../src/core/worker-pool.js') as any;
+ __testOnly_deliverFinalOutput(ds, finalOutputMsg(), 'tag', 0);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(ds.completedIdleTurnId).toBe('turn-1');
+ const calls = vi.mocked(buildStreamingCard).mock.calls;
+ expect(calls.some(c => c[19] === 'completed')).toBe(false);
+ });
+ });
+
it('routes synthetic Codex App identities through their frozen reply turn and uses dispatch-stable Lark UUIDs', async () => {
const sessionReply = vi.fn(async () => 'om_reply');
initWorkerPool({
diff --git a/test/bridge-turn-queue.test.ts b/test/bridge-turn-queue.test.ts
index 4732bb8b48..f7307f2ff3 100644
--- a/test/bridge-turn-queue.test.ts
+++ b/test/bridge-turn-queue.test.ts
@@ -1232,6 +1232,43 @@ describe('BridgeTurnQueue', () => {
});
});
+// ─── replyDelivery=transcript + solo:裸文本(无 壳)也能按指纹命中 ──
+// solo 会话的 PTY 输入是 buildBridgeInputContent 的裸文本 + `[附件]` 行;worker 的
+// bridgeMarkPendingTurn 用同一段原文取前 30 字符指纹 + 全文归一化,转写里的 user
+// 事件正文就是这段裸文本本身——这里钉住"信封去壳不影响 turn 归属"。
+describe('BridgeTurnQueue — bare (solo transcript) input fingerprint', () => {
+ function markLike(q: BridgeTurnQueue, turnId: string, text: string): void {
+ q.mark(turnId, makeFingerprint(text), Date.now(), makeFingerprintFull(text));
+ }
+
+ it('bare text mark matches the identical user event (attachment lines included)', () => {
+ const q = new BridgeTurnQueue();
+ const bare = '帮我看下这张图\n\n[附件]\n- x.jpg (/tmp/x.jpg)';
+ markLike(q, 't1', bare);
+ // 与裸文本无关的本地输入不得吃掉这个 pending turn。
+ q.ingest([user('local-u', 'ls -la'), assistant('local-a', 'listing')]);
+ expect(q.peek().find(t => t.turnId === 't1')?.started).toBe(false);
+ q.ingest([user('u1', bare), assistant('a1', '看到了')]);
+ const ready = q.drainEmittable();
+ const t1 = ready.find(t => t.turnId === 't1');
+ expect(t1?.assistantUuids).toEqual(['a1']);
+ expect(q.size()).toBe(0);
+ });
+
+ it('two different bare texts bind FIFO to their own user events', () => {
+ const q = new BridgeTurnQueue();
+ const first = '第一条:把 README 翻译成英文';
+ const second = '第二条:顺便修一下拼写\n\n[@提及]\n- @Alice';
+ markLike(q, 't1', first);
+ markLike(q, 't2', second);
+ q.ingest([user('u1', first), assistant('a1', 'done 1'), user('u2', second), assistant('a2', 'done 2')]);
+ const ready = q.drainEmittable();
+ expect(ready.map(t => t.turnId)).toEqual(['t1', 't2']);
+ expect(ready[0].assistantUuids).toEqual(['a1']);
+ expect(ready[1].assistantUuids).toEqual(['a2']);
+ });
+});
+
/** Local helper: full normalised content (what the worker stores as
* contentNormalized), distinct from the 30-char makeFingerprint. */
function makeFingerprintFull(message: string): string {
diff --git a/test/card-builder.test.ts b/test/card-builder.test.ts
index 9694c54774..29b9c0ec36 100644
--- a/test/card-builder.test.ts
+++ b/test/card-builder.test.ts
@@ -26,6 +26,7 @@ import {
buildTuiPromptFailedCard,
buildSlashListCard,
getCliDisplayName,
+ frozenIdleLabel,
} from '../src/im/lark/card-builder.js';
import type { RelayPickerEntry } from '../src/im/lark/card-builder.js';
import type { ProjectInfo } from '../src/services/project-scanner.js';
@@ -950,6 +951,71 @@ describe('buildStreamingCard', () => {
expect(card.header.title.content).toContain('工作中');
});
+ // transcript 模式:最终回复卡已投递 → idle 卡头「已完成」。颜色沿用 idle 的绿色。
+ it("idle + 'completed' label renders 「已完成」 instead of 「等待输入」", () => {
+ const card = parse(buildStreamingCard(
+ SID, ROOT, URL, TITLE, '', 'idle', undefined, 'hidden',
+ undefined, undefined, false, false, undefined, undefined, undefined, false,
+ undefined, undefined, undefined, 'completed',
+ ));
+ expect(card.header.template).toBe('green');
+ expect(card.header.title.content).toContain('已完成');
+ expect(card.header.title.content).not.toContain('等待输入');
+ expect(card.header.title.content).not.toContain('已处理 · 判定无需回复');
+ });
+
+ it("idle + 'completed' label renders 'Completed' in English", () => {
+ const card = parse(buildStreamingCard(
+ SID, ROOT, URL, TITLE, '', 'idle', undefined, 'hidden',
+ undefined, undefined, false, false, 'en', undefined, undefined, false,
+ undefined, undefined, undefined, 'completed',
+ ));
+ expect(card.header.title.content).toContain('Completed');
+ expect(card.header.title.content).not.toContain('Awaiting input');
+ });
+
+ it("idle + 'silent' string label equals the legacy boolean flag", () => {
+ const card = parse(buildStreamingCard(
+ SID, ROOT, URL, TITLE, '', 'idle', undefined, 'hidden',
+ undefined, undefined, false, false, undefined, undefined, undefined, false,
+ undefined, undefined, undefined, 'silent',
+ ));
+ expect(card.header.title.content).toContain('已处理 · 判定无需回复');
+ });
+
+ it("'completed' label is inert for non-idle statuses (working keeps its label)", () => {
+ const card = parse(buildStreamingCard(
+ SID, ROOT, URL, TITLE, '', 'working', undefined, 'hidden',
+ undefined, undefined, false, false, undefined, undefined, undefined, false,
+ undefined, undefined, undefined, 'completed',
+ ));
+ expect(card.header.title.content).toContain('工作中');
+ expect(card.header.title.content).not.toContain('已完成');
+ });
+
+ // 冻结卡回读:新字段 idleLabel 优先;旧盘只有 silentIdle:true 仍按 silent 渲染。
+ it('frozenIdleLabel: idleLabel wins, legacy silentIdle maps to silent, neither → undefined', () => {
+ expect(frozenIdleLabel({ idleLabel: 'completed' })).toBe('completed');
+ expect(frozenIdleLabel({ idleLabel: 'completed', silentIdle: true })).toBe('completed');
+ expect(frozenIdleLabel({ silentIdle: true })).toBe('silent');
+ expect(frozenIdleLabel({ silentIdle: false })).toBeUndefined();
+ expect(frozenIdleLabel({})).toBeUndefined();
+
+ const completed = parse(buildStreamingCard(
+ SID, ROOT, URL, TITLE, '', 'idle', undefined, 'hidden',
+ undefined, undefined, false, false, undefined, undefined, undefined, false,
+ undefined, undefined, undefined, frozenIdleLabel({ idleLabel: 'completed' }),
+ ));
+ expect(completed.header.title.content).toContain('已完成');
+
+ const legacySilent = parse(buildStreamingCard(
+ SID, ROOT, URL, TITLE, '', 'idle', undefined, 'hidden',
+ undefined, undefined, false, false, undefined, undefined, undefined, false,
+ undefined, undefined, undefined, frozenIdleLabel({ silentIdle: true }),
+ ));
+ expect(legacySilent.header.title.content).toContain('已处理 · 判定无需回复');
+ });
+
it('renders usage + runtime as one single-line markdown run (tail-joined, no column_set)', () => {
const card = parse(buildStreamingCard(
SID, ROOT, URL, TITLE, '', 'idle', 'traex', 'hidden',
diff --git a/test/card-handler-retry-turn.test.ts b/test/card-handler-retry-turn.test.ts
index 5949608ef3..e4f588ac18 100644
--- a/test/card-handler-retry-turn.test.ts
+++ b/test/card-handler-retry-turn.test.ts
@@ -104,6 +104,7 @@ vi.mock('../src/core/worker-pool.js', () => ({
withActiveSessionKeyLock: vi.fn(async (_m: any, _k: string, action: () => any) => action()),
buildStreamingCardJson: vi.fn(),
silentIdleCardFlag: vi.fn(() => false),
+ idleCardLabel: vi.fn(() => undefined),
}));
const rememberLastCliInputMock = vi.fn();
diff --git a/test/card-handler-stop-compact.test.ts b/test/card-handler-stop-compact.test.ts
index 1b88c6cc52..296257638b 100644
--- a/test/card-handler-stop-compact.test.ts
+++ b/test/card-handler-stop-compact.test.ts
@@ -94,6 +94,7 @@ vi.mock('../src/core/worker-pool.js', () => ({
withActiveSessionKeyLock: vi.fn(async (_map: any, _key: string, action: () => any) => action()),
buildStreamingCardJson: vi.fn(),
silentIdleCardFlag: vi.fn(() => false),
+ idleCardLabel: vi.fn(() => undefined),
dshRuntimeForSession: vi.fn(() => undefined),
}));
diff --git a/test/card-usage-normalize.test.ts b/test/card-usage-normalize.test.ts
new file mode 100644
index 0000000000..1166465704
--- /dev/null
+++ b/test/card-usage-normalize.test.ts
@@ -0,0 +1,78 @@
+/**
+ * card-usage-normalize — `botmux send` 读回 daemon /usage 响应的白名单式规范化,
+ * 重点是 statusline 配额段 `quota` 的逐字段放行 / 丢弃。
+ */
+import { describe, expect, it } from 'vitest';
+import { normalizeCardUsageQuota, normalizeCardUsageSnapshot } from '../src/cli/card-usage-normalize.js';
+
+describe('normalizeCardUsageSnapshot — quota 白名单', () => {
+ it('合法字段原样放行', () => {
+ const out = normalizeCardUsageSnapshot({
+ context: { usedTokens: 100, windowTokens: 1000, percentUsed: 10 },
+ tokens: { in: 1, out: 2 },
+ quota: {
+ contextPercent: 23,
+ contextWindowTokens: 1_000_000,
+ fiveHourPercent: 18,
+ fiveHourResetsAtMs: 1_788_000_000_000,
+ sevenDayPercent: 5,
+ sevenDayResetsAtMs: 1_788_086_400_000,
+ },
+ });
+ expect(out).toEqual({
+ context: { usedTokens: 100, windowTokens: 1000, percentUsed: 10 },
+ tokens: { in: 1, out: 2 },
+ quota: {
+ contextPercent: 23,
+ contextWindowTokens: 1_000_000,
+ fiveHourPercent: 18,
+ fiveHourResetsAtMs: 1_788_000_000_000,
+ sevenDayPercent: 5,
+ sevenDayResetsAtMs: 1_788_086_400_000,
+ },
+ });
+ });
+
+ it('>100 / 负数 / NaN / 字符串逐字段丢弃,其它字段保留', () => {
+ const out = normalizeCardUsageSnapshot({
+ context: null,
+ tokens: null,
+ quota: {
+ contextPercent: 101, // >100 ⇒ 丢
+ fiveHourPercent: -1, // 负数 ⇒ 丢
+ sevenDayPercent: 7, // 合法
+ fiveHourResetsAtMs: Number.NaN, // NaN ⇒ 丢
+ sevenDayResetsAtMs: '123', // 字符串 ⇒ 丢
+ contextWindowTokens: 0, // 必须 > 0 ⇒ 丢
+ },
+ });
+ expect(out).toEqual({ context: null, tokens: null, quota: { sevenDayPercent: 7 } });
+ });
+
+ it('边界:0% 与 100% 合法,resetsAtMs 必须 > 0', () => {
+ expect(normalizeCardUsageQuota({ contextPercent: 0, fiveHourPercent: 100, fiveHourResetsAtMs: 0 }))
+ .toEqual({ contextPercent: 0, fiveHourPercent: 100 });
+ });
+
+ it('没有任何合法字段 ⇒ 不带 quota key(与无 statusline 时逐字节相同)', () => {
+ expect(normalizeCardUsageSnapshot({ context: null, tokens: null, quota: { contextPercent: 'x' } }))
+ .toEqual({ context: null, tokens: null });
+ expect(normalizeCardUsageSnapshot({ context: null, tokens: null, quota: {} }))
+ .toEqual({ context: null, tokens: null });
+ expect(normalizeCardUsageSnapshot({ context: null, tokens: null, quota: null }))
+ .toEqual({ context: null, tokens: null });
+ expect(normalizeCardUsageSnapshot({ context: null, tokens: null, quota: [1] }))
+ .toEqual({ context: null, tokens: null });
+ expect(normalizeCardUsageSnapshot({ context: null, tokens: null }))
+ .toEqual({ context: null, tokens: null });
+ });
+
+ it('既有 context / tokens 校验行为不变', () => {
+ expect(normalizeCardUsageSnapshot({
+ context: { usedTokens: -1 },
+ tokens: { in: 1, out: 'x' },
+ })).toEqual({ context: null, tokens: null });
+ expect(normalizeCardUsageSnapshot(null)).toBeNull();
+ expect(normalizeCardUsageSnapshot([])).toBeNull();
+ });
+});
diff --git a/test/child-env.test.ts b/test/child-env.test.ts
index 4e0b6133e6..c934a074fc 100644
--- a/test/child-env.test.ts
+++ b/test/child-env.test.ts
@@ -513,6 +513,8 @@ describe('scrubSessionTurnMarkerEnv()', () => {
'BOTMUX_LARK_APP_ID',
'BOTMUX_SESSION_SCOPE',
'BOTMUX_SEND_RELAY',
+ // per-session shadowed user statusLine command (worker-computed from cwd)
+ 'BOTMUX_STATUSLINE_CHAIN',
]) {
expect(SESSION_TURN_MARKER_ENV_KEYS, key).toContain(key);
}
@@ -722,6 +724,10 @@ describe('BOTMUX_INJECTED_ENV_KEYS carries the read-isolation markers', () => {
expect(BOTMUX_INJECTED_ENV_KEYS).toContain('BOTMUX_REPLY_STYLE');
expect(BOTMUX_INJECTED_ENV_KEYS).toContain('BOTMUX_PLUGIN_CARD_ACTION_CAPABILITIES');
expect(SESSION_TURN_MARKER_ENV_KEYS).toContain('BOTMUX_PLUGIN_CARD_ACTION_CAPABILITIES');
+ // `botmux statusline` runs inside the pane and needs the worker-computed chain
+ // command; without transport it would silently drop the user's own statusLine.
+ expect(BOTMUX_INJECTED_ENV_KEYS).toContain('BOTMUX_STATUSLINE_CHAIN');
+ expect(SESSION_TURN_MARKER_ENV_KEYS).toContain('BOTMUX_STATUSLINE_CHAIN');
});
it('keeps SSL_CERT_FILE OUT of the injected list and in its own CA-bundle list', () => {
diff --git a/test/claude-settings-hook.test.ts b/test/claude-settings-hook.test.ts
index 4f30e868e4..67f895e9b6 100644
--- a/test/claude-settings-hook.test.ts
+++ b/test/claude-settings-hook.test.ts
@@ -6,23 +6,26 @@
* 而是声明 hookInstall 写全局 ~/.claude/settings.json —— 这样 adopt 模式(botmux 接管
* 别处已启动、拿不到 --settings 的 claude 会话)也能让那条会话读到 hook(即 --settings
* 里 **不含** PreToolUse / AskUserQuestion)。
- * - 进程级 --settings 仅保留 bypassPermissions / skipDangerousMode;没有这些键时干脆不传 --settings。
+ * - 进程级 --settings 保留 bypassPermissions / skipDangerousMode,并(仅 claude-code)恒带
+ * statusLine → `botmux statusline`:statusLine 是单值、不像 hooks 按事件合并,写全局会覆盖
+ * 用户自己的,所以只能走进程级;被 wrapperCli 剥掉时卡片省略配额段即可。
* - SessionStart hook(真就绪信号 → `botmux session-ready`)**改走全局** settings.json
* (hookInstall.sessionStartCommand),不再注入进程级 --settings。原因:① wrapperCli=aiden x
* claude 会剥掉 --settings,全局是其唯一渠道;② 进程级+全局同时注入会让 Claude 等两条 hook
* 退出才渲染输入框、而 worker 在第一条信号就放行首条 prompt → 抢跑触发 paste-burst → 软换行
* `\` 字面残留。单一全局来源消除竞态。
*/
-import { describe, it, expect, vi } from 'vitest';
-import { homedir } from 'node:os';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
// Mock child_process.execSync 使 resolveCommand() 直接返回命令名。
vi.mock('node:child_process', () => ({
execSync: vi.fn(() => ''),
}));
-import { createClaudeCodeAdapter } from '../src/adapters/cli/claude-code.js';
+import { createClaudeCodeAdapter, resolveShadowedStatusLine } from '../src/adapters/cli/claude-code.js';
function settingsOf(args: string[]): any {
const idx = args.indexOf('--settings');
@@ -59,16 +62,31 @@ describe('claude-code —— hook 注入策略(adopt 兼容 + SessionStart 真
expect(parsed.skipDangerousModePermissionPrompt).toBe(true);
});
- it('disableCliBypass=true 时无 bypass 键 → 干脆不传进程级 --settings(就绪 hook 走全局)', () => {
+ it('disableCliBypass=true 时仍传 --settings(statusLine 恒在),但无 bypass 键(就绪 hook 走全局)', () => {
const args = adapter.buildArgs({ sessionId: 's', resume: false, disableCliBypass: true });
// bypass 关闭 → 不加 --dangerously-skip-permissions
expect(args).not.toContain('--dangerously-skip-permissions');
- // 没有 bypass 键、SessionStart 已移走 → 不再传 --settings
- expect(args).not.toContain('--settings');
+ // claude-code 恒传 --settings(承载 statusLine),但不含任何 bypass 键
+ const parsed = settingsOf(args);
+ expect(parsed.permissions).toBeUndefined();
+ expect(parsed.skipDangerousModePermissionPrompt).toBeUndefined();
+ expect(parsed.hooks).toBeUndefined();
+ expect(parsed.statusLine?.type).toBe('command');
// 就绪 hook 仍由全局 hookInstall 提供
expect(adapter.hookInstall?.sessionStartCommand).toContain('session-ready');
});
+ it('--settings 恒带 statusLine → `botmux statusline`,refreshInterval=60', () => {
+ const parsed = settingsOf(adapter.buildArgs({ sessionId: 's', resume: false }));
+ expect(parsed.statusLine).toMatchObject({ type: 'command', refreshInterval: 60 });
+ expect(typeof parsed.statusLine.command).toBe('string');
+ expect(parsed.statusLine.command.endsWith('statusline')).toBe(true);
+ expect(parsed.statusLine.command).toContain('cli.js');
+ expect(parsed.statusLine.command).not.toContain('index-daemon');
+ // 只走进程级:全局 hookInstall 不声明 statusLine(全局单值会覆盖用户自己的)
+ expect((adapter.hookInstall as any)?.statusLineCommand).toBeUndefined();
+ });
+
it('adapter 标记 injectsReadyHook(驱动 worker 武装 ready-gate)', () => {
expect(adapter.injectsReadyHook).toBe(true);
});
@@ -85,3 +103,52 @@ describe('claude-code —— hook 注入策略(adopt 兼容 + SessionStart 真
expect(adapter.asksViaHook).toBe(true);
});
});
+
+describe('resolveShadowedStatusLine —— 找回被进程级 --settings 遮蔽的用户 statusLine', () => {
+ const roots: string[] = [];
+ afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true }); });
+
+ function scaffold(): { workingDir: string; userSettingsPath: string } {
+ const root = mkdtempSync(join(tmpdir(), 'botmux-shadowed-statusline-'));
+ roots.push(root);
+ const workingDir = join(root, 'proj');
+ mkdirSync(join(workingDir, '.claude'), { recursive: true });
+ mkdirSync(join(root, 'home', '.claude'), { recursive: true });
+ return { workingDir, userSettingsPath: join(root, 'home', '.claude', 'settings.json') };
+ }
+ const statusLine = (command: string, extra: Record = {}) =>
+ JSON.stringify({ statusLine: { type: 'command', command, ...extra } });
+
+ it('优先级:settings.local.json > settings.json > 用户 settings', () => {
+ const { workingDir, userSettingsPath } = scaffold();
+ writeFileSync(userSettingsPath, statusLine('user-cmd'));
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({ command: 'user-cmd' });
+ writeFileSync(join(workingDir, '.claude', 'settings.json'), statusLine('project-cmd', { padding: 0 }));
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({ command: 'project-cmd', padding: 0 });
+ writeFileSync(join(workingDir, '.claude', 'settings.local.json'), statusLine('local-cmd', { refreshInterval: 5 }));
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({ command: 'local-cmd', refreshInterval: 5 });
+ });
+
+ it('坏文件 / 非 command 类型 / 空 command 视为该层无配置,继续向下找;全部没有 ⇒ {}', () => {
+ const { workingDir, userSettingsPath } = scaffold();
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({});
+ writeFileSync(join(workingDir, '.claude', 'settings.local.json'), '{ not json');
+ writeFileSync(join(workingDir, '.claude', 'settings.json'), JSON.stringify({ statusLine: { type: 'command', command: ' ' } }));
+ writeFileSync(userSettingsPath, statusLine('user-cmd'));
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({ command: 'user-cmd' });
+ // 用户层不是 command 类型 ⇒ 也视为无
+ writeFileSync(userSettingsPath, JSON.stringify({ statusLine: { type: 'static', text: 'x' } }));
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({});
+ // 顶层不是对象 / 数组也不炸
+ writeFileSync(userSettingsPath, '[1,2]');
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({});
+ // 不给 userSettingsPath 也可以
+ expect(resolveShadowedStatusLine({ workingDir })).toEqual({});
+ });
+
+ it('padding / refreshInterval 只放行有限数值', () => {
+ const { workingDir, userSettingsPath } = scaffold();
+ writeFileSync(userSettingsPath, statusLine('user-cmd', { padding: 'x', refreshInterval: Number.NaN }));
+ expect(resolveShadowedStatusLine({ workingDir, userSettingsPath })).toEqual({ command: 'user-cmd' });
+ });
+});
diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts
index 35ed1fa26c..88f77e8f39 100644
--- a/test/cli-adapters.test.ts
+++ b/test/cli-adapters.test.ts
@@ -207,13 +207,19 @@ describe('claude-code buildArgs', () => {
expect(parsed.permissions.defaultMode).toBe('bypassPermissions');
});
- it('omits dangerous permission flags/keys AND --settings entirely when disableCliBypass is true', () => {
+ it('omits dangerous permission flags/keys when disableCliBypass is true (--settings stays for statusLine only)', () => {
const args = adapter.buildArgs({ sessionId: 's', resume: false, disableCliBypass: true });
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args).toContain('--disallowed-tools');
// SessionStart 就绪 hook 改走全局 settings.json(见 hookInstall.sessionStartCommand),
- // 不再注入进程级 --settings;bypass 键也没有 → 没东西可传 → 干脆不带 --settings。
- expect(args).not.toContain('--settings');
+ // 不再注入进程级 --settings;bypass 键也没有。claude-code 仍恒传 --settings,但只承载
+ // statusLine(→ `botmux statusline`,单值不能写全局),不含任何 bypass / hooks 键。
+ const idx = args.indexOf('--settings');
+ expect(idx).toBeGreaterThanOrEqual(0);
+ const parsed = JSON.parse(args[idx + 1]);
+ expect(Object.keys(parsed)).toEqual(['statusLine']);
+ expect(parsed.statusLine.type).toBe('command');
+ expect(parsed.statusLine.command.endsWith('statusline')).toBe(true);
expect(adapter.hookInstall?.sessionStartCommand).toContain('session-ready');
});
@@ -317,6 +323,98 @@ describe('claude-code buildArgs', () => {
// Omitting the arg and passing false must be identical (no accidental gate).
expect(sysDefault).toBe(sysExplicitFalse);
expect(shellDefault).toBe(shellExplicitFalse);
+ // 适配器层 replyDelivery 缺省(省略参数)与显式 'send' 字节相同:claude-code 的
+ // 「缺省 transcript」由 daemon(effectiveReplyDelivery)算好后经 init 冻结传入,
+ // 适配器自身不补缺省(fail-closed)。
+ expect(buildBotmuxSystemPromptText({ locale: 'en', replyDelivery: 'send' })).toBe(sysDefault);
+ expect(buildBotmuxSystemPromptText({ locale: 'en', replyDelivery: 'send', solo: true })).toBe(sysDefault);
+ expect(buildBotmuxShellHints('en', false, 'send').join('\n')).toBe(shellDefault);
+ });
+
+ // ── replyDelivery=transcript(claude-code 的 daemon 缺省):最终回复由 daemon 从
+ // 转写自动转发,两条注入路径都彻底不提 botmux send——没有 send 用法、heredoc、
+ // @ 决策、附件用法;只留改口 intro、botmux history / bots list、哨兵(fallback
+ // 的抑制规则)、workflow 与防注入。
+ it('never mentions botmux send on EITHER injection path for replyDelivery=transcript, keeps the silence sentinel', () => {
+ const sys = buildBotmuxSystemPromptText({ locale: 'en', replyDelivery: 'transcript' });
+ const shell = buildBotmuxShellHints('en', false, 'transcript').join('\n');
+ for (const prompt of [sys, shell]) {
+ expect(prompt).toContain('automatically forwarded back to Lark');
+ expect(prompt).not.toContain('botmux send');
+ expect(prompt).not.toContain("<<'EOF'");
+ expect(prompt).not.toContain('--mention');
+ expect(prompt).not.toContain('--images');
+ expect(prompt).not.toContain('you MUST reply via');
+ expect(prompt).not.toContain('the only way');
+ // 以「send 是最终回复」为前提的两条提示不再注入。
+ expect(prompt).not.toContain('--response-kind final');
+ expect(prompt).not.toContain('no visible');
+ // 哨兵语义与上下文命令保留。
+ expect(prompt).toContain('BOTMUX_NOTHING_TO_SEND');
+ expect(prompt).toContain('botmux history');
+ expect(prompt).toContain('hidden runtime context');
+ }
+ const sysZh = buildBotmuxSystemPromptText({ locale: 'zh', replyDelivery: 'transcript' });
+ const shellZh = buildBotmuxShellHints('zh', false, 'transcript').join('\n');
+ for (const prompt of [sysZh, shellZh]) {
+ expect(prompt).toContain('自动转发回飞书');
+ expect(prompt).not.toContain('botmux send');
+ expect(prompt).not.toContain('唯一方式');
+ expect(prompt).toContain('BOTMUX_NOTHING_TO_SEND');
+ expect(prompt).toContain('botmux history');
+ }
+ expect(sys).not.toBe(buildBotmuxSystemPromptText({ locale: 'en' }));
+ });
+
+ it('transcript identity keeps the three routing rules minus mention_must; solo drops routing_rules; noTransport still wins', () => {
+ const solo = buildBotmuxSystemPromptText({ locale: 'en', botName: 'Bot', botOpenId: 'ou_x', replyDelivery: 'transcript', solo: true });
+ expect(solo).toContain('Bot');
+ expect(solo).toContain('ou_x');
+ expect(solo).not.toContain('');
+ expect(solo).not.toContain('botmux send');
+ const group = buildBotmuxSystemPromptText({ locale: 'en', botName: 'Bot', botOpenId: 'ou_x', replyDelivery: 'transcript', solo: false });
+ expect(group).toContain('');
+ expect(group).toContain('Route by @name and open_id');
+ expect(group).toContain('Do only your part');
+ expect(group).toContain('stay silent');
+ expect(group).toContain('Do not pull other bots in');
+ // mention_must 整句围绕 botmux send --mention,transcript 下不注入。
+ expect(group).not.toContain('you MUST `botmux send --mention');
+ expect(group).not.toContain('botmux send');
+ // noTransport 优先级最高:transcript 标志不改变 no-transport 的折叠输出。
+ const noTransport = buildBotmuxSystemPromptText({ locale: 'en', botName: 'Bot', botOpenId: 'ou_x', noTransport: true });
+ expect(buildBotmuxSystemPromptText({ locale: 'en', botName: 'Bot', botOpenId: 'ou_x', noTransport: true, replyDelivery: 'transcript', solo: true })).toBe(noTransport);
+ expect(buildBotmuxShellHints('en', true, 'transcript')).toEqual(buildBotmuxShellHints('en', true));
+ });
+
+ it('forwards replyDelivery/solo into --append-system-prompt (claude-code daemon default = transcript, no botmux send), but v3 goal-mode pins send wording', () => {
+ const args = adapter.buildArgs({ sessionId: 's', resume: false, botName: 'Bot', botOpenId: 'ou_x', replyDelivery: 'transcript', solo: true });
+ const prompt = args[args.indexOf('--append-system-prompt') + 1];
+ expect(prompt).toContain('自动转发回飞书');
+ expect(prompt).not.toContain('botmux send');
+ expect(prompt).not.toContain("<<'EOF'");
+ expect(prompt).not.toContain('--mention');
+ expect(prompt).not.toContain('');
+ expect(prompt).toContain('BOTMUX_NOTHING_TO_SEND');
+ expect(prompt).toContain('botmux history');
+ // 非 solo:identity 保留归属三条规则,仍不提 send。
+ const groupArgs = adapter.buildArgs({ sessionId: 's', resume: false, botName: 'Bot', botOpenId: 'ou_x', replyDelivery: 'transcript', solo: false });
+ const groupPrompt = groupArgs[groupArgs.indexOf('--append-system-prompt') + 1];
+ expect(groupPrompt).toContain('');
+ expect(groupPrompt).toContain('只做分给自己的部分');
+ expect(groupPrompt).not.toContain('botmux send');
+ const previous = process.env[GOAL_ENV.V3_MARKER];
+ process.env[GOAL_ENV.V3_MARKER] = '1';
+ try {
+ const v3 = adapter.buildArgs({ sessionId: 's', resume: false, botName: 'Bot', botOpenId: 'ou_x', replyDelivery: 'transcript', solo: true });
+ const v3Prompt = v3[v3.indexOf('--append-system-prompt') + 1];
+ expect(v3Prompt).toContain('必须用 `botmux send`');
+ expect(v3Prompt).not.toContain('自动转发回飞书');
+ expect(v3Prompt).toContain('');
+ } finally {
+ if (previous === undefined) delete process.env[GOAL_ENV.V3_MARKER];
+ else process.env[GOAL_ENV.V3_MARKER] = previous;
+ }
});
it('passes configured model with --model', () => {
diff --git a/test/dashboard-bot-payload.test.ts b/test/dashboard-bot-payload.test.ts
index a907a885ba..ef03a95874 100644
--- a/test/dashboard-bot-payload.test.ts
+++ b/test/dashboard-bot-payload.test.ts
@@ -32,7 +32,7 @@ describe('dashboard bot payload helpers', () => {
'substituteMode', 'feedback', 'replyStyle',
'restrictGrantCommands', 'autoGrantRequestCards', 'p2pOpen',
'grantDefaultDurationMs', 'messageQuotaDefaultLimit', 'p2pMode',
- 'envelopeInjection', 'codexAuthSync',
+ 'envelopeInjection', 'replyDelivery', 'replyDeliveryDefault', 'replyDeliverySupported', 'codexAuthSync',
'skillInjection', 'skillInjectionDefault', 'skillInjectionSupport',
'maxLiveWorkers', 'logicalSessionCount', 'residentSessionCount', 'dormantSessionCount',
'nativeSubagentRuntime',
@@ -261,6 +261,19 @@ describe('dashboard bot payload helpers', () => {
.toMatchObject({ envelopeInjection: 'off' });
});
+ it('projects reply delivery effective value + CLI default + CLI support so the dashboard toggle survives refresh', () => {
+ const daemon = { larkAppId: 'app_claude', botName: 'Claude', cliId: 'claude-code' };
+ // 纯投影:daemon 没给就回 send(生效值与缺省值都由 daemon 端算,这里不重复 CLI 判断)。
+ expect(botDefaultsPayload(daemon, {}))
+ .toMatchObject({ replyDelivery: 'send', replyDeliveryDefault: 'send', replyDeliverySupported: false });
+ expect(botDefaultsPayload(daemon, { replyDelivery: 'transcript', replyDeliveryDefault: 'transcript', replyDeliverySupported: true }))
+ .toMatchObject({ replyDelivery: 'transcript', replyDeliveryDefault: 'transcript', replyDeliverySupported: true });
+ expect(botDefaultsPayload(daemon, { replyDelivery: 'send', replyDeliveryDefault: 'transcript', replyDeliverySupported: 'yes' }))
+ .toMatchObject({ replyDelivery: 'send', replyDeliveryDefault: 'transcript', replyDeliverySupported: false });
+ expect(botDefaultsPayload(daemon, { replyDelivery: 'invalid', replyDeliveryDefault: 'invalid' }))
+ .toMatchObject({ replyDelivery: 'send', replyDeliveryDefault: 'send' });
+ });
+
it('projects the usage-display mode, defaulting to streaming and honoring legacy/off', () => {
const daemon = { larkAppId: 'app_usage', botName: 'Usage', cliId: 'codex' };
expect(botDefaultsPayload(daemon, {})).toMatchObject({ usageDisplay: 'streaming' });
diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts
index 46a60ebfb4..67a9329d0b 100644
--- a/test/dashboard-ipc.test.ts
+++ b/test/dashboard-ipc.test.ts
@@ -1891,6 +1891,96 @@ describe('PUT /api/bot-card-prefs — streaming card buttons', () => {
});
});
+describe('PUT /api/bot-reply-delivery — 最终回复投递方式', () => {
+ async function withBot(cliId: string, run: (base: string, configPath: string, appId: string) => Promise): Promise {
+ const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-reply-delivery-'));
+ const configPath = join(dir, 'bots.json');
+ const appId = `test-reply-delivery-${cliId}`;
+ const prevBotsConfig = process.env.BOTS_CONFIG;
+ try {
+ process.env.BOTS_CONFIG = configPath;
+ writeFileSync(configPath, JSON.stringify([{ larkAppId: appId, larkAppSecret: 'secret', cliId }], null, 2));
+ loadBotConfigs().forEach((c: any) => registerBot(c));
+ setLarkAppId(appId);
+ handle = await startIpcServer({ port: 0, host: '127.0.0.1' });
+ await run(`http://127.0.0.1:${handle.port}`, configPath, appId);
+ } finally {
+ if (handle) await handle.close();
+ handle = null;
+ if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG;
+ else process.env.BOTS_CONFIG = prevBotsConfig;
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }
+ const put = (base: string, replyDelivery: unknown) => fetch(`${base}/api/bot-reply-delivery`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ replyDelivery }),
+ });
+ const persisted = (configPath: string) => JSON.parse(readFileSync(configPath, 'utf-8'))[0];
+
+ it('claude-code: GET 生效值缺省 transcript(CLI 默认),PUT send / transcript 都落盘,PUT 空串 unset 回缺省', async () => {
+ await withBot('claude-code', async (base, configPath, appId) => {
+ const initial = await (await fetch(`${base}/api/bot-default-oncall`)).json();
+ expect(initial).toMatchObject({ replyDelivery: 'transcript', replyDeliveryDefault: 'transcript', replyDeliverySupported: true });
+ expect('replyDelivery' in persisted(configPath)).toBe(false);
+
+ // send:显式落盘(claude-code 退回旧行为的唯一方式)。
+ const off = await put(base, 'send');
+ expect(off.status).toBe(200);
+ expect(await off.json()).toMatchObject({ ok: true, replyDelivery: 'send', replyDeliveryDefault: 'transcript' });
+ expect(persisted(configPath).replyDelivery).toBe('send');
+ expect(getBot(appId).config.replyDelivery).toBe('send');
+ const afterOff = await (await fetch(`${base}/api/bot-default-oncall`)).json();
+ expect(afterOff).toMatchObject({ replyDelivery: 'send', replyDeliveryDefault: 'transcript', replyDeliverySupported: true });
+
+ const on = await put(base, 'transcript');
+ expect(on.status).toBe(200);
+ expect(await on.json()).toMatchObject({ ok: true, replyDelivery: 'transcript' });
+ expect(persisted(configPath).replyDelivery).toBe('transcript');
+ expect(getBot(appId).config.replyDelivery).toBe('transcript');
+
+ // '' / 未知值删 key,回 CLI 缺省(claude-code = transcript)。
+ const cleared = await put(base, '');
+ expect(cleared.status).toBe(200);
+ expect(await cleared.json()).toMatchObject({ ok: true, replyDelivery: 'transcript', replyDeliveryDefault: 'transcript' });
+ expect('replyDelivery' in persisted(configPath)).toBe(false);
+ expect(getBot(appId).config.replyDelivery).toBeUndefined();
+ });
+ });
+
+ it('cursor: GET 缺省 send/unsupported,PUT transcript 4xx reply_delivery_unsupported 且不落盘,PUT send 落盘', async () => {
+ await withBot('cursor', async (base, configPath, appId) => {
+ const initial = await (await fetch(`${base}/api/bot-default-oncall`)).json();
+ expect(initial).toMatchObject({ replyDelivery: 'send', replyDeliveryDefault: 'send', replyDeliverySupported: false });
+
+ const rejected = await put(base, 'transcript');
+ expect(rejected.status).toBe(400);
+ expect(await rejected.json()).toMatchObject({ ok: false, error: 'reply_delivery_unsupported' });
+ expect('replyDelivery' in persisted(configPath)).toBe(false);
+ expect(getBot(appId).config.replyDelivery).toBeUndefined();
+
+ // send 在不支持的 CLI 上仍可写,同样显式落盘。
+ const send = await put(base, 'send');
+ expect(send.status).toBe(200);
+ expect(await send.json()).toMatchObject({ ok: true, replyDelivery: 'send', replyDeliveryDefault: 'send' });
+ expect(persisted(configPath).replyDelivery).toBe('send');
+ });
+ });
+
+ it('bad JSON body → 400 bad_json', async () => {
+ await withBot('claude-code', async (base) => {
+ const res = await fetch(`${base}/api/bot-reply-delivery`, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: '{not json',
+ });
+ expect(res.status).toBe(400);
+ expect(await res.json()).toMatchObject({ ok: false, error: 'bad_json' });
+ });
+ });
+});
+
describe('PUT /api/bot-card-prefs — 入群 seed 文案与内置默认一致时不落盘', () => {
// 编辑态软预填把「当前生效的内置默认」直接填进输入框,所以一次顺手的保存会把
// bot 从「跟随动态默认」钉死成「锁定这一版文案」(升级不再跟上、切 locale 仍发
diff --git a/test/fs-policy.test.ts b/test/fs-policy.test.ts
index 59b88797a1..a33dfbeed6 100644
--- a/test/fs-policy.test.ts
+++ b/test/fs-policy.test.ts
@@ -316,6 +316,12 @@ describe('buildFsPolicy', () => {
// another session's marker is NOT writable (can't corrupt its send-dedup).
expect(accessForPath(p.rules, '/Users/u/.botmux/data/turn-sends/other.jsonl').access).toBe('none');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/turn-sends').access).toBe('none');
+ // statusline: `botmux statusline` atomically writes latest.json (tmp+rename needs
+ // a writable parent) → the OWN session DIRECTORY is readWrite; siblings stay 'none'.
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline/s').access).toBe('readWrite');
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline/s/latest.json').access).toBe('readWrite');
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline/other/latest.json').access).toBe('none');
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline').access).toBe('none');
// own BOT_HOME rw + own attachments ro (allow-listed elsewhere)
expect(accessForPath(p.rules, '/Users/u/.botmux/bots/cli_self/claude/x').access).toBe('readWrite');
expect(accessForPath(p.rules, '/Users/u/.botmux/data/attachments/cli_self/m/f.pdf').access).toBe('readWrite'); // botmux quoted downloads here
@@ -1141,6 +1147,15 @@ describe('no-Lark-transport credential profile (larkTransportEnabled=false)', ()
...o,
}));
+ it('grants the OWN statusline snapshot dir readWrite under no-transport too (siblings stay closed)', () => {
+ const p = noTransport({ workingDir: '/Users/u/proj' });
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline/s').access).toBe('readWrite');
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline/s/latest.json').access).toBe('readWrite');
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/statusline/other/latest.json').access).not.toBe('readWrite');
+ // parity with turn-sends: single own marker file still granted
+ expect(accessForPath(p.rules, '/Users/u/.botmux/data/turn-sends/s.jsonl').access).toBe('readWrite');
+ });
+
it('denies Feishu authority (bots.json / lark-cli stores / sibling BOT_HOME) even with workingDir=~', () => {
const p = noTransport();
expect(accessForPath(p.rules, '/Users/u/.botmux/bots.json').access).toBe('deny');
diff --git a/test/hook-command-compiled-form.test.ts b/test/hook-command-compiled-form.test.ts
index b809aab2f0..352babc030 100644
--- a/test/hook-command-compiled-form.test.ts
+++ b/test/hook-command-compiled-form.test.ts
@@ -39,6 +39,7 @@ import {
hookCommandFor,
nativeSubagentRuntimeHookCommand,
sessionReadyHookCommand,
+ statuslineHookCommand,
userPromptHookCommand,
} from '../src/adapters/hook-command.js';
@@ -58,6 +59,7 @@ describe('hook-command — compiled binary form', () => {
hookCommandFor('claude-code'),
sessionReadyHookCommand(),
userPromptHookCommand(),
+ statuslineHookCommand(),
nativeSubagentRuntimeHookCommand(),
...hookCommandParts('claude-code').args,
hookCommandParts('claude-code').cmd,
@@ -77,6 +79,8 @@ describe('hook-command — compiled binary form', () => {
expect(hookCommandFor('claude-code')).toBe(`"${process.execPath}" hook claude-code`);
expect(sessionReadyHookCommand()).toBe(`"${process.execPath}" session-ready`);
expect(userPromptHookCommand()).toBe(`"${process.execPath}" user-prompt-hook`);
+ // statusLine.command 走进程级 --settings,同样由 Claude 经 shell 执行。
+ expect(statuslineHookCommand()).toBe(`"${process.execPath}" statusline`);
expect(nativeSubagentRuntimeHookCommand()).toMatch(
/^".+[/\\]\.botmux[/\\]bin[/\\]botmux-native-subagent-runtime-hook(?:\.cmd)?"$/,
);
@@ -135,6 +139,7 @@ describe('hook-command — Node form stays byte-identical', () => {
const script = hookCommandParts('x').args[0];
expect(sessionReadyHookCommand()).toBe(`"${process.execPath}" "${script}" session-ready`);
expect(userPromptHookCommand()).toBe(`"${process.execPath}" "${script}" user-prompt-hook`);
+ expect(statuslineHookCommand()).toBe(`"${process.execPath}" "${script}" statusline`);
expect(nativeSubagentRuntimeHookCommand()).toMatch(
/^".+[/\\]\.botmux[/\\]bin[/\\]botmux-native-subagent-runtime-hook(?:\.cmd)?"$/,
);
diff --git a/test/hook-command.test.ts b/test/hook-command.test.ts
index 41a947c73c..9222a6cf5d 100644
--- a/test/hook-command.test.ts
+++ b/test/hook-command.test.ts
@@ -6,6 +6,7 @@ import {
hookCommandFor,
nativeSubagentRuntimeHookCommand,
sessionReadyHookCommand,
+ statuslineHookCommand,
} from '../src/adapters/hook-command.js';
// 回归保护:hook 命令必须指向 cli.js(有 `hook` 子命令分发),
@@ -40,6 +41,19 @@ describe('sessionReadyHookCommand', () => {
});
});
+describe('statuslineHookCommand', () => {
+ it('指向 cli.js 而非 index-daemon.js,并以 statusline 子命令结尾', () => {
+ const cmd = statuslineHookCommand();
+ expect(cmd).toContain('cli.js');
+ expect(cmd).not.toContain('index-daemon');
+ expect(cmd.endsWith(' statusline')).toBe(true);
+ });
+
+ it('Node 路径与 cli 路径均加引号(容忍空格),无 cliId 参数', () => {
+ expect(statuslineHookCommand()).toMatch(/^".+" ".+cli\.js" statusline$/);
+ });
+});
+
describe('nativeSubagentRuntimeHookCommand', () => {
it('uses the dedicated stable native-hook wrapper so a reattached pane picks up the current build', () => {
const cmd = nativeSubagentRuntimeHookCommand();
diff --git a/test/md-card.test.ts b/test/md-card.test.ts
index 6658665874..0944e68787 100644
--- a/test/md-card.test.ts
+++ b/test/md-card.test.ts
@@ -21,6 +21,7 @@ import {
brandFooterSegment,
cardUsageFooterSegment,
cardUsageRuntimeSegment,
+ contextOverCompactThreshold,
createReplyCard,
DEFAULT_BRAND_LABEL,
extractFirstReplyCardHeading,
@@ -1530,3 +1531,100 @@ describe('buildContextualReplyCard footer brand', () => {
expect(JSON.stringify(els)).not.toContain('botmux');
});
});
+
+describe('cardUsageFooterSegment — Claude Code statusline quota (ctx / 5h / 7d)', () => {
+ it('footer renders the plain-percentage form `ctx 23% · 5h 18% · 7d 5%` (no bars, no absolutes, no resets_at)', () => {
+ const seg = cardUsageFooterSegment(
+ {
+ context: null,
+ tokens: null,
+ quota: { contextPercent: 23, fiveHourPercent: 18, fiveHourResetsAtMs: 1_788_000_000_000, sevenDayPercent: 5, sevenDayResetsAtMs: 1_788_086_400_000 },
+ },
+ 'zh',
+ );
+ expect(seg).toBe('ctx 23% · 5h 18% · 7d 5%');
+ // en 同值(纯文本标签两语言一致)
+ expect(cardUsageFooterSegment(
+ { context: null, tokens: null, quota: { contextPercent: 23, fiveHourPercent: 18, sevenDayPercent: 5 } },
+ 'en',
+ )).toBe('ctx 23% · 5h 18% · 7d 5%');
+ });
+
+ it('quota overrides the transcript absolute form even when context tokens are present', () => {
+ // Claude Code 的 transcript 有 usedTokens 但没有窗口;statusline 有百分比 → 只渲染 ctx N%。
+ const seg = cardUsageFooterSegment(
+ { context: { usedTokens: 159_861 }, tokens: { in: 1, out: 2 }, quota: { contextPercent: 23, fiveHourPercent: 18, sevenDayPercent: 5 } },
+ 'zh',
+ );
+ expect(seg).toBe('ctx 23% · 5h 18% · 7d 5%');
+ expect(seg).not.toContain('159.9K');
+ expect(seg).not.toContain('上下文');
+ });
+
+ it('omits 7d when only 5h is known; omits ctx when the statusline gave no context percent', () => {
+ expect(cardUsageFooterSegment(
+ { context: null, tokens: null, quota: { contextPercent: 23, fiveHourPercent: 18 } },
+ 'zh',
+ )).toBe('ctx 23% · 5h 18%');
+ // 无 contextPercent(例如窗口已滚动只剩重置时间)→ 落回 transcript 绝对值分支
+ expect(cardUsageFooterSegment(
+ { context: { usedTokens: 159_861, windowTokens: 258_400, percentUsed: 62 }, tokens: null, quota: { sevenDayPercent: 5 } },
+ 'zh',
+ )).toBe('ctx 62% · 7d 5%');
+ expect(cardUsageFooterSegment(
+ { context: { usedTokens: 159_861 }, tokens: null, quota: { sevenDayPercent: 5 } },
+ 'zh',
+ )).toBe('上下文 159.9K · 7d 5%');
+ // quota 里一个可渲染字段都没有 → 与无 quota 相同
+ expect(cardUsageFooterSegment(
+ { context: null, tokens: null, quota: { fiveHourResetsAtMs: 1_788_000_000_000 } },
+ 'zh',
+ )).toBeNull();
+ });
+
+ it('quota null / absent renders byte-identically to today', () => {
+ const base = { context: { usedTokens: 80_700, windowTokens: 258_400, percentUsed: 31 }, tokens: { in: 1_400_000, out: 7_800 } };
+ const today = cardUsageFooterSegment(base, 'zh');
+ expect(today).toBe('上下文 80.7K/258.4K (31%)');
+ expect(cardUsageFooterSegment({ ...base, quota: null }, 'zh')).toBe(today);
+ expect(cardUsageFooterSegment({ ...base, quota: undefined }, 'zh')).toBe(today);
+ const todayStreaming = cardUsageFooterSegment(base, 'zh', 'streaming');
+ expect(todayStreaming).toBe('上下文 80.7K/258.4K (31%) · 累计 ↑1.4M ↓7.8K');
+ expect(cardUsageFooterSegment({ ...base, quota: null }, 'zh', 'streaming')).toBe(todayStreaming);
+ // 卡片级:footer 元素逐字节相同
+ const cardWithout = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', base);
+ const cardWithNull = buildMarkdownCard('hello', undefined, '', 'zh', undefined, 'filesystem', { ...base, quota: null });
+ expect(cardWithNull).toBe(cardWithout);
+ });
+
+ it('streaming variant keeps the three quota segments and appends 本轮 / 累计', () => {
+ const seg = cardUsageFooterSegment(
+ {
+ context: { usedTokens: 159_861 },
+ tokens: { in: 1_400_000, out: 7_800 },
+ turnTokens: { in: 5_000, out: 1_200 },
+ quota: { contextPercent: 23, fiveHourPercent: 18, sevenDayPercent: 5 },
+ },
+ 'zh',
+ 'streaming',
+ );
+ expect(seg).toBe('ctx 23% · 5h 18% · 7d 5% · 本轮 ↑5K ↓1.2K · 累计 ↑1.4M ↓7.8K');
+ });
+
+ it('compact hint fires from quota.contextPercent (transcript has no window)', () => {
+ const usage = { context: { usedTokens: 159_861 }, tokens: null, quota: { contextPercent: 91, fiveHourPercent: 18 } };
+ expect(contextOverCompactThreshold(usage, 90)).toBe(true);
+ expect(contextOverCompactThreshold(usage, 95)).toBe(false);
+ expect(cardUsageFooterSegment(usage, 'zh', 'streaming', { compactHintThreshold: 90 }))
+ .toBe('ctx 91% · 建议压缩 · 5h 18%');
+ expect(cardUsageFooterSegment(usage, 'zh', 'streaming', { compactHintThreshold: 95 }))
+ .toBe('ctx 91% · 5h 18%');
+ });
+
+ it('rounds and clamps quota percentages like the context percentage', () => {
+ expect(cardUsageFooterSegment(
+ { context: null, tokens: null, quota: { contextPercent: 23.6, fiveHourPercent: 140, sevenDayPercent: -1 } },
+ 'zh',
+ )).toBe('ctx 24% · 5h 100%');
+ });
+});
diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts
index 8649e56b1c..b2c4010fdd 100644
--- a/test/prompt-builder.test.ts
+++ b/test/prompt-builder.test.ts
@@ -69,6 +69,11 @@ const mockBotConfig: Record = {
vi.mock('../src/bot-registry.js', () => ({
getBot: vi.fn(() => ({ config: mockBotConfig })),
getAllBots: vi.fn(() => []),
+ // core/reply-delivery.ts 经这两个入口读 per-bot replyDelivery / owner。未显式配置
+ // 返回 undefined,由 effectiveReplyDelivery 按 CLI 补缺省:claude-code → transcript,
+ // 其它 → send(与真实 registry 语义一致)。
+ resolveReplyDelivery: vi.fn(() => mockBotConfig.replyDelivery as 'send' | 'transcript' | undefined),
+ getOwnerOpenId: vi.fn(() => undefined),
}));
vi.mock('../src/services/session-store.js', () => ({
@@ -630,12 +635,23 @@ describe('buildReforkPrompt', () => {
expect(out).toContain(`${SESSION_ID}`);
});
- it('omits for claude-code (injectsSessionContext=true) but keeps reminder', () => {
+ it('omits for claude-code (injectsSessionContext=true); reminder only under explicit send', () => {
const ds = makeDs();
+ // claude-code 缺省 transcript:无 reminder(最终回复由 daemon 从转写转发)。
const out = buildReforkPrompt(ds, 'hello', { cliId: 'claude-code' });
expect(out).not.toContain('');
expect(out).toContain('');
- expect(out).toContain('');
+ expect(out).not.toContain('');
+ // 显式 send 退回旧行为:保留 reminder。
+ mockBotConfig.replyDelivery = 'send';
+ try {
+ const sendOut = buildReforkPrompt(ds, 'hello', { cliId: 'claude-code' });
+ expect(sendOut).not.toContain('');
+ expect(sendOut).toContain('');
+ expect(sendOut).toContain('');
+ } finally {
+ delete mockBotConfig.replyDelivery;
+ }
});
it('omits botmux_reminder for Mira re-fork prompts', () => {
@@ -998,3 +1014,148 @@ describe('buildNewTopicPrompt with multi-user follow-ups', () => {
expect(body).toContain('open_id="ou_bob"');
});
});
+
+// ─── replyDelivery=transcript — 信封改口 / solo 去壳 ─────────────────────────
+
+describe('replyDelivery=transcript envelope', () => {
+ const sender = { openId: 'ou_owner', type: 'user' as const, name: 'Owner' };
+ const selfMention = { name: 'Bot', openId: 'ou_bot' };
+ const attachments = [{ type: 'image' as const, path: '/tmp/x.jpg', name: 'x.jpg' }];
+ const mentions = [{ name: 'Bot', openId: 'ou_bot' }, { name: 'Alice', openId: 'ou_alice' }];
+ const base = { larkAppId: 'app_test', chatId: 'oc_1', locale: 'zh' as const, sender };
+
+ afterEach(() => {
+ delete mockBotConfig.replyDelivery;
+ delete mockBotConfig.apiOnly;
+ });
+
+ it('缺省按 CLI:claude-code 缺省 = 显式 transcript,codex 缺省 = 显式 send(字节相同)', () => {
+ const followDefault = buildFollowUpContent('继续', 'sess-rd', { ...base, cliId: 'claude-code', attachments, mentions });
+ const topicDefault = buildNewTopicPrompt(
+ '帮我看下', 'sess-rd', 'codex', undefined, attachments, mentions, undefined, undefined,
+ { name: 'Bot', openId: 'ou_bot' }, 'zh', sender, { larkAppId: 'app_test', chatId: 'oc_1' },
+ );
+ // claude-code 缺省 transcript:无 reminder,等于显式 transcript。
+ expect(followDefault).not.toContain('');
+ mockBotConfig.replyDelivery = 'transcript';
+ expect(buildFollowUpContent('继续', 'sess-rd', { ...base, cliId: 'claude-code', attachments, mentions })).toBe(followDefault);
+ // codex 缺省 send:首轮 routing 仍是 send 版,等于显式 send。
+ expect(topicDefault).toContain('唯一方式');
+ mockBotConfig.replyDelivery = 'send';
+ const topicSend = buildNewTopicPrompt(
+ '帮我看下', 'sess-rd', 'codex', undefined, attachments, mentions, undefined, undefined,
+ { name: 'Bot', openId: 'ou_bot' }, 'zh', sender, { larkAppId: 'app_test', chatId: 'oc_1' },
+ );
+ expect(topicSend).toBe(topicDefault);
+ // 显式 send 的 claude-code 续轮退回旧行为:带 reminder;且 send 模式下 solo 标志是 no-op。
+ const followSend = buildFollowUpContent('继续', 'sess-rd', { ...base, cliId: 'claude-code', attachments, mentions });
+ expect(followSend).toContain('');
+ expect(buildFollowUpContent('继续', 'sess-rd', { ...base, cliId: 'claude-code', attachments, mentions, solo: true, selfMention })).toBe(followSend);
+ });
+
+ it('transcript + claude-code 续轮:不注入 ,非 solo 保留壳与 sender', () => {
+ mockBotConfig.replyDelivery = 'transcript';
+ const out = buildFollowUpContent('继续', 'sess-t1', { ...base, cliId: 'claude-code', attachments, mentions });
+ expect(out).not.toContain('');
+ expect(out).toContain('\n继续\n');
+ expect(out).toContain('');
+ });
+
+ it('transcript + solo 续轮:裸文本 + [附件]/[@提及] 行,自 @ 被剥、无壳无 sender', () => {
+ mockBotConfig.replyDelivery = 'transcript';
+ const out = buildFollowUpContent('@Bot 帮我看下', 'sess-t2', {
+ ...base, cliId: 'claude-code', attachments, mentions, solo: true, selfMention,
+ });
+ expect(out).not.toContain('');
+ expect(out).not.toContain('');
+ expect(out).not.toContain('');
+ expect(out.startsWith('帮我看下')).toBe(true);
+ expect(out).toContain('[附件]\n- x.jpg (/tmp/x.jpg)');
+ expect(out).toContain('[@提及]\n- @Alice');
+ expect(out).not.toContain('- @Bot');
+ });
+
+ it('transcript + solo:cursor 的 随 sender 一起消失', () => {
+ mockBotConfig.replyDelivery = 'transcript';
+ // cursor 没有转写采集 → effectiveReplyDelivery 回落 send,信封与 send 逐字相同。
+ const transcript = buildFollowUpContent('继续', 'sess-t3', { ...base, cliId: 'cursor', solo: true, selfMention });
+ mockBotConfig.replyDelivery = 'send';
+ const send = buildFollowUpContent('继续', 'sess-t3', { ...base, cliId: 'cursor', solo: true, selfMention });
+ expect(transcript).toBe(send);
+ expect(transcript).toContain('');
+ expect(transcript).toContain(' {
+ mockBotConfig.replyDelivery = 'transcript';
+ mockBotConfig.apiOnly = true;
+ const out = buildFollowUpContent('继续', 'sess-t4', { ...base, cliId: 'claude-code', solo: true, selfMention });
+ expect(out).toContain('');
+ expect(out).toContain('不要调用 botmux send,不要发飞书');
+ expect(out).toContain('');
+ expect(out).not.toContain('BOTMUX_NOTHING_TO_SEND');
+ });
+
+ it('transcript 首轮(非注入式 codex):routing 不提 botmux send、identity 无 short_routing;solo 时去壳', () => {
+ mockBotConfig.replyDelivery = 'transcript';
+ const build = (solo: boolean) => buildNewTopicPrompt(
+ '@Bot 帮我看下', 'sess-t5', 'codex', undefined, attachments, mentions, undefined, undefined,
+ { name: 'Bot', openId: 'ou_bot' }, 'zh', sender, { larkAppId: 'app_test', chatId: 'oc_1', solo, selfMention },
+ );
+ const nonSolo = build(false);
+ expect(nonSolo).toContain('');
+ expect(nonSolo).toContain('自动转发回飞书');
+ expect(nonSolo).not.toContain('唯一方式');
+ expect(nonSolo).toContain('BOTMUX_NOTHING_TO_SEND');
+ expect(nonSolo).toContain('botmux history');
+ // 整个 routing 与 identity 块都不出现 botmux send / heredoc / --mention。
+ expect(nonSolo).not.toContain('botmux send');
+ expect(nonSolo).not.toContain("<<'EOF'");
+ expect(nonSolo).not.toContain('--mention');
+ expect(nonSolo).toContain('');
+ expect(nonSolo).not.toContain('');
+ expect(nonSolo).toContain('');
+ expect(nonSolo).toContain('');
+
+ const solo = build(true);
+ expect(solo).toContain('');
+ expect(solo).toContain('');
+ expect(solo).toContain('Bot');
+ expect(solo).not.toContain('');
+ expect(solo).not.toContain('');
+ expect(solo).not.toContain('');
+ expect(solo).toContain('[附件]\n- x.jpg (/tmp/x.jpg)');
+ expect(solo).toContain('[@提及]\n- @Alice');
+ expect(solo).not.toContain('- @Bot');
+ });
+
+ it('buildReforkPrompt 读 ds.soloSession:transcript + solo 去壳,非 solo 保留壳', () => {
+ mockBotConfig.replyDelivery = 'transcript';
+ const makeDs = (soloSession?: boolean): DaemonSession => ({
+ session: {
+ sessionId: 'refork-t', chatId: 'oc_chat', rootMessageId: 'om_root', title: 'topic',
+ status: 'active', createdAt: '2026-01-01T00:00:00.000Z',
+ } as any,
+ worker: null, workerPort: null, workerToken: null,
+ larkAppId: 'app_test', chatId: 'oc_chat', chatType: 'p2p', scope: 'thread',
+ spawnedAt: 0, cliVersion: '1.0.0', lastMessageAt: 0, hasHistory: true,
+ soloSession,
+ } as DaemonSession);
+ const bare = buildReforkPrompt(makeDs(true), '@Bot 继续', { cliId: 'claude-code', selfMention, sender });
+ expect(bare).not.toContain('');
+ expect(bare).not.toContain('');
+ expect(bare).not.toContain('');
+ expect(shelled).toContain('\n@Bot 继续\n');
+ expect(shelled).toContain(' ({
const getBotMock = vi.fn(() => ({
config: { larkAppId: 'app_test', larkAppSecret: 'secret', cliId: 'claude-code', envelopeInjection: 'auto' as const },
}));
+// core/reply-delivery.ts 读 per-bot replyDelivery 的入口。本文件的 hook 注入用例都以
+// **显式 send** 为前提(claude-code 未配置时缺省已是 transcript、不注入 reminder——
+// 见下方 transcript 用例),所以 mock 缺省返回显式 'send' 而非 undefined。
+const replyDeliveryMock = vi.fn((..._args: unknown[]): 'send' | 'transcript' | undefined => 'send');
vi.mock('../src/bot-registry.js', () => ({
getBot: (...args: unknown[]) => getBotMock(...args),
getAllBots: vi.fn(() => []),
+ resolveReplyDelivery: (...args: unknown[]) => replyDeliveryMock(...args),
+ getOwnerOpenId: vi.fn(() => undefined),
}));
vi.mock('../src/services/session-store.js', () => ({
@@ -271,6 +277,61 @@ describe('buildFollowUpCliInput — hook 注入模式', () => {
expect(result.content).toContain('');
expect(claimByPrompt(SESSION_ID, TURN_ID, result.content)).toBeUndefined();
});
+
+ // ─── replyDelivery=transcript:续轮不注入 reminder,sidecar 不再承载它 ────────
+
+ it('auto + transcript + whiteboard:sidecar 含 whiteboard 与 sender,reminder 两边都没有', () => {
+ replyDeliveryMock.mockReturnValue('transcript');
+ try {
+ const result = buildFollowUpCliInput('帮我修个 bug', SESSION_ID, followUpOpts({ whiteboardId: 'wb_t' }));
+ // hook 模式下 PTY 文本只剩用户正文,外壳与 sender/mentions 都进 sidecar。
+ expect(result.content).toBe('帮我修个 bug');
+ expect(result.content).not.toContain('');
+ expect(result.content).not.toContain('');
+ // 白板末句改口:不再要求「仍必须 botmux send」。
+ expect(envelope).toContain('用户可见结论写进最终回复即可');
+ expect(envelope).not.toContain('仍必须');
+ } finally {
+ replyDeliveryMock.mockReturnValue('send');
+ }
+ });
+
+ it('auto + transcript 无 whiteboard:envelope 只剩 sender,仍走 hook(reminder 两边都没有)', () => {
+ replyDeliveryMock.mockReturnValue('transcript');
+ try {
+ const result = buildFollowUpCliInput('帮我修个 bug', SESSION_ID, followUpOpts({ whiteboardId: undefined }));
+ expect(result.content).toBe('帮我修个 bug');
+ expect(result.content).not.toContain('');
+ const envelope = claimByPrompt(SESSION_ID, TURN_ID, result.content);
+ expect(envelope).toContain('');
+ } finally {
+ replyDeliveryMock.mockReturnValue('send');
+ }
+ });
+
+ it('未显式配置 + claude-code:缺省即 transcript,与显式 transcript 字节相同(无 reminder)', () => {
+ replyDeliveryMock.mockReturnValue('transcript');
+ let explicit: string;
+ try {
+ explicit = buildFollowUpCliInput('帮我修个 bug', SESSION_ID, followUpOpts({ whiteboardId: undefined })).content;
+ } finally {
+ replyDeliveryMock.mockReturnValue(undefined);
+ }
+ try {
+ const result = buildFollowUpCliInput('帮我修个 bug', SESSION_ID, followUpOpts({ whiteboardId: undefined }));
+ expect(result.content).toBe(explicit);
+ expect(result.content).not.toContain('');
+ expect(claimByPrompt(SESSION_ID, TURN_ID, result.content)).toContain(' {
diff --git a/test/recall-frozen-cards.test.ts b/test/recall-frozen-cards.test.ts
index fdcf49e355..e00fe13130 100644
--- a/test/recall-frozen-cards.test.ts
+++ b/test/recall-frozen-cards.test.ts
@@ -427,8 +427,8 @@ describe('restoreUsageLimitRuntimeState', () => {
undefined,
// 19th arg: Codex Fast tier badge — undefined for this non-Codex fixture.
undefined,
- // 20th arg: silent-idle label flag — no deliberately-silent turn here.
- false,
+ // 20th arg: idle-card label ('silent' / 'completed') — neither here.
+ undefined,
// 21st arg: per-bot dshRuntime — undefined for this Claude fixture (only
// meaningful for cliId 'dsh', where 'tui' keeps the 🗜️ compact button).
undefined,
@@ -1054,12 +1054,40 @@ describe('parkStreamCard', () => {
expect(entry?.displayMode).toBe('screenshot');
expect(entry?.imageKey).toBe('img_key_xyz');
expect(entry?.codexServiceTierBadge).toBe('⚡ priority');
+ // 新字段 idleLabel 为准;'silent' 同时写旧字段 silentIdle 供旧版 daemon 读盘。
+ expect(entry?.idleLabel).toBe('silent');
expect(entry?.silentIdle).toBe(true);
expect(ds.parkedStreamCardNonce).toBe('nonce_live');
expect(saveFrozenCardsMock).toHaveBeenCalledTimes(1);
expect(saveFrozenCardsMock).toHaveBeenCalledWith(SESSION_ID, ds.frozenCards);
});
+ it("freezes a transcript-delivered turn with idleLabel 'completed' (no legacy silentIdle)", () => {
+ const ds = makeDs();
+ ds.streamCardId = 'om_live';
+ ds.streamCardNonce = 'nonce_live';
+ ds.lastScreenContent = 'snapshot text';
+ ds.completedIdleTurnId = 'om_live_turn';
+
+ parkStreamCard(ds);
+
+ const entry = ds.frozenCards?.get('nonce_live');
+ expect(entry?.idleLabel).toBe('completed');
+ expect(entry?.silentIdle).toBeUndefined();
+ });
+
+ it('freezes a plain idle turn with neither idleLabel nor silentIdle', () => {
+ const ds = makeDs();
+ ds.streamCardId = 'om_live';
+ ds.streamCardNonce = 'nonce_live';
+
+ parkStreamCard(ds);
+
+ const entry = ds.frozenCards?.get('nonce_live');
+ expect(entry?.idleLabel).toBeUndefined();
+ expect(entry?.silentIdle).toBeUndefined();
+ });
+
it('does not leak a stale Codex tier snapshot into a non-Codex frozen card', () => {
const ds = makeDs();
ds.streamCardId = 'om_live';
diff --git a/test/reply-delivery.test.ts b/test/reply-delivery.test.ts
new file mode 100644
index 0000000000..dc4d9362b9
--- /dev/null
+++ b/test/reply-delivery.test.ts
@@ -0,0 +1,177 @@
+/**
+ * `src/core/reply-delivery.ts` 纯函数层:solo 会话判定、transcript 支持白名单、
+ * 运行时生效值(配置 transcript 但 CLI 不支持 → fail-closed 回落 send)。
+ *
+ * Run: vitest run --project unit test/reply-delivery.test.ts
+ */
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+// registry 只 mock 本模块用到的两个读取口;其它导出不需要。resolveReplyDelivery 缺省
+// undefined = bots.json 未显式配置,由 effectiveReplyDelivery 按 CLI 补缺省。
+vi.mock('../src/bot-registry.js', () => ({
+ resolveReplyDelivery: vi.fn((): 'send' | 'transcript' | undefined => undefined),
+ getOwnerOpenId: vi.fn(() => undefined),
+}));
+
+import { getOwnerOpenId, resolveReplyDelivery } from '../src/bot-registry.js';
+import {
+ computeSoloSession,
+ computeSoloSessionForBot,
+ defaultReplyDeliveryFor,
+ effectiveReplyDelivery,
+ supportsTranscriptReplyDelivery,
+ type SoloSessionInput,
+} from '../src/core/reply-delivery.js';
+
+const OWNER = 'ou_owner';
+
+/** 基线:普通群 + 1 人 1 bot + owner 发言 → solo;每个用例只改一处。 */
+const SOLO_GROUP: SoloSessionInput = {
+ chatType: 'group',
+ chatMode: 'group',
+ stats: { userCount: 1, botCount: 1 },
+ senderType: 'user',
+ senderOpenId: OWNER,
+ ownerOpenId: OWNER,
+};
+
+describe('computeSoloSession', () => {
+ const cases: Array<{ name: string; input: Partial; expected: boolean }> = [
+ { name: 'p2p 私聊恒为 solo(不看其它字段)', input: { chatType: 'p2p', chatMode: undefined, stats: undefined, senderType: undefined, senderOpenId: undefined, ownerOpenId: undefined }, expected: true },
+ { name: '普通群 + 1/1 + owner 发言 → solo', input: {}, expected: true },
+ { name: '话题群 → 非 solo', input: { chatMode: 'topic' }, expected: false },
+ { name: 'chatMode 未知 → 非 solo', input: { chatMode: undefined }, expected: false },
+ { name: 'stats 未知 → 非 solo', input: { stats: undefined }, expected: false },
+ { name: 'userCount 2 → 非 solo', input: { stats: { userCount: 2, botCount: 1 } }, expected: false },
+ { name: 'botCount 2 → 非 solo', input: { stats: { userCount: 1, botCount: 2 } }, expected: false },
+ { name: 'API 失败的 {999,999} 哨兵 → 非 solo', input: { stats: { userCount: 999, botCount: 999 } }, expected: false },
+ { name: 'bot 发言 → 非 solo', input: { senderType: 'bot' }, expected: false },
+ { name: '非 owner 发言 → 非 solo', input: { senderOpenId: 'ou_other' }, expected: false },
+ { name: '无 owner → 非 solo', input: { ownerOpenId: undefined }, expected: false },
+ { name: '无发言者 open_id → 非 solo', input: { senderOpenId: undefined }, expected: false },
+ { name: 'chatType 未知 → 非 solo', input: { chatType: undefined }, expected: false },
+ ];
+ for (const c of cases) {
+ it(c.name, () => {
+ expect(computeSoloSession({ ...SOLO_GROUP, ...c.input })).toBe(c.expected);
+ });
+ }
+});
+
+describe('computeSoloSessionForBot', () => {
+ beforeEach(() => {
+ vi.mocked(getOwnerOpenId).mockReset();
+ vi.mocked(getOwnerOpenId).mockReturnValue(undefined);
+ });
+
+ it('owner 从 registry 取:匹配 → solo', () => {
+ vi.mocked(getOwnerOpenId).mockReturnValue(OWNER);
+ const { ownerOpenId: _omit, ...input } = SOLO_GROUP;
+ expect(computeSoloSessionForBot('app_a', input)).toBe(true);
+ expect(getOwnerOpenId).toHaveBeenCalledWith('app_a');
+ });
+
+ it('registry 无 owner → 非 solo', () => {
+ const { ownerOpenId: _omit, ...input } = SOLO_GROUP;
+ expect(computeSoloSessionForBot('app_a', input)).toBe(false);
+ });
+
+ it('registry 抛错 → fail-closed 非 solo', () => {
+ vi.mocked(getOwnerOpenId).mockImplementation(() => { throw new Error('not registered'); });
+ const { ownerOpenId: _omit, ...input } = SOLO_GROUP;
+ expect(computeSoloSessionForBot('app_a', input)).toBe(false);
+ });
+});
+
+describe('supportsTranscriptReplyDelivery', () => {
+ const cases: Array<[string | undefined, boolean]> = [
+ ['claude-code', true],
+ ['codex', true],
+ ['traex', true],
+ ['coco', true],
+ ['hermes', true],
+ ['mtr', true],
+ ['pi', true],
+ ['oh-my-pi', true],
+ ['ebsd', true],
+ ['grok', true],
+ // cursor 只在 adopt 下有转写,不算;codex-app 天然转写模式,不需要本开关。
+ ['cursor', false],
+ ['codex-app', false],
+ ['gemini', false],
+ [undefined, false],
+ ['', false],
+ ];
+ for (const [cliId, expected] of cases) {
+ it(`${cliId ?? '(undefined)'} → ${expected}`, () => {
+ expect(supportsTranscriptReplyDelivery(cliId)).toBe(expected);
+ });
+ }
+});
+
+describe('defaultReplyDeliveryFor', () => {
+ const cases: Array<[string | undefined, 'send' | 'transcript']> = [
+ ['claude-code', 'transcript'],
+ ['codex', 'send'],
+ ['hermes', 'send'],
+ ['cursor', 'send'],
+ ['codex-app', 'send'],
+ [undefined, 'send'],
+ ['', 'send'],
+ ];
+ for (const [cliId, expected] of cases) {
+ it(`${cliId ?? '(undefined)'} → ${expected}`, () => {
+ expect(defaultReplyDeliveryFor(cliId)).toBe(expected);
+ });
+ }
+});
+
+describe('effectiveReplyDelivery', () => {
+ beforeEach(() => {
+ vi.mocked(resolveReplyDelivery).mockReset();
+ vi.mocked(resolveReplyDelivery).mockReturnValue(undefined);
+ });
+
+ it('未配置 + claude-code → transcript(CLI 缺省)', () => {
+ expect(effectiveReplyDelivery('app_a', 'claude-code')).toBe('transcript');
+ expect(resolveReplyDelivery).toHaveBeenCalledWith('app_a');
+ });
+
+ it('未配置 + codex → send(CLI 缺省)', () => {
+ expect(effectiveReplyDelivery('app_a', 'codex')).toBe('send');
+ });
+
+ it('未配置 + cursor → send', () => {
+ expect(effectiveReplyDelivery('app_a', 'cursor')).toBe('send');
+ });
+
+ it('显式 send + claude-code → send(退回旧行为)', () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('send');
+ expect(effectiveReplyDelivery('app_a', 'claude-code')).toBe('send');
+ });
+
+ it('显式 transcript + claude-code → transcript', () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('transcript');
+ expect(effectiveReplyDelivery('app_a', 'claude-code')).toBe('transcript');
+ });
+
+ it('显式 transcript + codex → transcript', () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('transcript');
+ expect(effectiveReplyDelivery('app_a', 'codex')).toBe('transcript');
+ });
+
+ it('显式 transcript + cursor → 回落 send', () => {
+ vi.mocked(resolveReplyDelivery).mockReturnValue('transcript');
+ expect(effectiveReplyDelivery('app_a', 'cursor')).toBe('send');
+ });
+
+ it('无 larkAppId → send(即使是 claude-code),且不查 registry', () => {
+ expect(effectiveReplyDelivery(undefined, 'claude-code')).toBe('send');
+ expect(resolveReplyDelivery).not.toHaveBeenCalled();
+ });
+
+ it('registry 抛错 → fail-closed send(claude-code 也不补缺省)', () => {
+ vi.mocked(resolveReplyDelivery).mockImplementation(() => { throw new Error('boom'); });
+ expect(effectiveReplyDelivery('app_a', 'claude-code')).toBe('send');
+ });
+});
diff --git a/test/silent-turn-receipt.test.ts b/test/silent-turn-receipt.test.ts
index 92d3150bf7..6dab605809 100644
--- a/test/silent-turn-receipt.test.ts
+++ b/test/silent-turn-receipt.test.ts
@@ -329,8 +329,8 @@ describe('deliberate-silence closure (turn_terminal nothing_to_send)', () => {
await vi.waitFor(() => {
const calls = (buildStreamingCard as any).mock.calls;
expect(calls.length).toBeGreaterThan(0);
- // silentIdle is the last positional arg of buildStreamingCard.
- expect(calls[calls.length - 1][19]).toBe(true);
+ // idle label is the 20th positional arg of buildStreamingCard.
+ expect(calls[calls.length - 1][19]).toBe('silent');
});
});
@@ -399,14 +399,15 @@ describe('deliberate-silence closure (turn_terminal nothing_to_send)', () => {
});
/**
- * Every live-card rebuild must carry the silent-idle flag, or an unrelated
- * patch (display-mode toggle, frozen-card migration, runtime badge) silently
- * reverts 「已处理 · 判定无需回复」 back to 「等待输入」 — the exact regression
- * this feature exists to prevent. Enforced structurally because the flag is a
- * positional argument that is trivially forgotten at a NEW call site: a
- * behavioral test only covers the paths someone remembered to write.
+ * Every live-card rebuild must carry the idle-card label (silent / completed),
+ * or an unrelated patch (display-mode toggle, frozen-card migration, runtime
+ * badge) silently reverts 「已处理 · 判定无需回复」/「已完成」 back to 「等待输入」
+ * — the exact regression this feature exists to prevent. Enforced structurally
+ * because the label is a positional argument that is trivially forgotten at a
+ * NEW call site: a behavioral test only covers the paths someone remembered to
+ * write.
*/
-describe('buildStreamingCard call sites all pass silentIdleCardFlag', () => {
+describe('buildStreamingCard call sites all pass idleCardLabel', () => {
const files = [
'src/core/worker-pool.ts',
'src/daemon.ts',
@@ -438,7 +439,12 @@ describe('buildStreamingCard call sites all pass silentIdleCardFlag', () => {
const calls = callArgs(readFileSync(file, 'utf8'));
expect(calls.length).toBeGreaterThan(0);
const missing = calls
- .filter(c => !/(?:silentIdleCardFlag\(|\.silentIdle\b)/.test(c.args))
+ // Accepted forms: the live-session resolver `idleCardLabel(ds)`, the
+ // frozen-card resolver `frozenIdleLabel(frozen)`, or beginNewTurn's
+ // pre-captured `previousIdleLabel` (captured before the session flags
+ // are cleared). Legacy `silentIdleCardFlag(` / `.silentIdle` stay
+ // accepted so an older call site is not flagged as a miss.
+ .filter(c => !/(?:idleCardLabel\(|frozenIdleLabel\(|previousIdleLabel\b|silentIdleCardFlag\(|\.silentIdle\b|\.idleLabel\b)/.test(c.args))
.map(c => `${file}:${c.line}`);
expect(missing).toEqual([]);
});
diff --git a/test/statusline-cli.test.ts b/test/statusline-cli.test.ts
new file mode 100644
index 0000000000..baf32ece7b
--- /dev/null
+++ b/test/statusline-cli.test.ts
@@ -0,0 +1,155 @@
+/**
+ * `botmux statusline` — Claude Code statusLine.command 客户端的进程级边界测试。
+ *
+ * 契约(见 src/cli.ts cmdStatusline):
+ * - BOTMUX_SESSION_ID 非空 ⇒ 快照落盘 `/statusline//latest.json`;缺失 ⇒ 不落盘;
+ * - BOTMUX_STATUSLINE_CHAIN 非空 ⇒ 原始 stdin 字节原样转发给它、透传其退出码,10s 看门狗;
+ * - 无 chain ⇒ stdout 空、exit 0;非 JSON stdin ⇒ exit 0;BOTMUX_WORKFLOW=1 不被根命令白名单拒绝。
+ * 落盘与转发互不影响:chain 失败不影响落盘,落盘失败不影响转发。
+ */
+import { type ChildProcess } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { spawnTsScript } from './helpers/ts-runner.js';
+import { statuslineFilePath } from '../src/services/statusline-snapshot.js';
+
+const CLI_PATH = join(__dirname, '..', 'src', 'cli.ts');
+const SID = 'sess_statusline_test';
+const tempDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+const PAYLOAD = {
+ session_id: 'claude-sid',
+ transcript_path: '/home/u/.claude/projects/x/claude-sid.jsonl',
+ model: { id: 'claude-opus-5', display_name: 'Opus' },
+ context_window: { used_percentage: 23.4, context_window_size: 1_000_000 },
+ rate_limits: {
+ five_hour: { used_percentage: 18, resets_at: Math.floor(Date.now() / 1000) + 3600 },
+ seven_day: { used_percentage: 5, resets_at: Math.floor(Date.now() / 1000) + 86_400 },
+ },
+};
+
+interface RunOpts {
+ dataDir: string;
+ sessionId?: string;
+ chain?: string;
+ stdin: Buffer;
+ extraEnv?: Record;
+ /** 看门狗用例:孤儿子进程会一直握着 stdout 管道,改用 'ignore' 让 close 及时触发。 */
+ ignoreOutput?: boolean;
+}
+
+function runStatusline(opts: RunOpts): Promise<{ status: number | null; stdout: Buffer; stderr: string; elapsedMs: number }> {
+ return new Promise((resolve, reject) => {
+ const env: NodeJS.ProcessEnv = { ...process.env, SESSION_DATA_DIR: opts.dataDir, ...opts.extraEnv };
+ delete env.BOTMUX_SESSION_ID;
+ delete env.BOTMUX_STATUSLINE_CHAIN;
+ delete env.BOTMUX_WORKFLOW;
+ if (opts.sessionId) env.BOTMUX_SESSION_ID = opts.sessionId;
+ if (opts.chain) env.BOTMUX_STATUSLINE_CHAIN = opts.chain;
+ const started = Date.now();
+ const child = spawnTsScript(
+ CLI_PATH,
+ ['statusline'],
+ { env, stdio: ['pipe', opts.ignoreOutput ? 'ignore' : 'pipe', opts.ignoreOutput ? 'ignore' : 'pipe'] },
+ ) as ChildProcess;
+ const out: Buffer[] = [];
+ let stderr = '';
+ child.stdout?.on('data', (chunk: Buffer) => { out.push(chunk); });
+ child.stderr?.setEncoding('utf8');
+ child.stderr?.on('data', (chunk: string) => { stderr += chunk; });
+ child.once('error', reject);
+ child.once('close', status => resolve({ status, stdout: Buffer.concat(out), stderr, elapsedMs: Date.now() - started }));
+ child.stdin!.end(opts.stdin);
+ });
+}
+
+function makeDataDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'botmux-statusline-cli-'));
+ tempDirs.push(dir);
+ return dir;
+}
+
+describe('botmux statusline', () => {
+ it('① 有 BOTMUX_SESSION_ID:快照落盘、exit 0、stdout 空', async () => {
+ const dataDir = makeDataDir();
+ const r = await runStatusline({ dataDir, sessionId: SID, stdin: Buffer.from(JSON.stringify(PAYLOAD)) });
+ expect(r.status).toBe(0);
+ expect(r.stdout.length).toBe(0);
+ const file = statuslineFilePath(dataDir, SID);
+ expect(existsSync(file)).toBe(true);
+ const snap = JSON.parse(readFileSync(file, 'utf-8'));
+ expect(snap).toMatchObject({
+ contextPercent: 23.4,
+ contextWindowTokens: 1_000_000,
+ fiveHourPercent: 18,
+ sevenDayPercent: 5,
+ model: 'claude-opus-5',
+ claudeSessionId: 'claude-sid',
+ });
+ expect(typeof snap.ts).toBe('number');
+ });
+
+ it('② 无 BOTMUX_SESSION_ID:不落盘、exit 0', async () => {
+ const dataDir = makeDataDir();
+ const r = await runStatusline({ dataDir, stdin: Buffer.from(JSON.stringify(PAYLOAD)) });
+ expect(r.status).toBe(0);
+ expect(r.stdout.length).toBe(0);
+ expect(existsSync(join(dataDir, 'statusline'))).toBe(false);
+ });
+
+ it('③ chain=cat:stdout 与原始 stdin 字节完全一致(含尾部换行与非 ASCII)', async () => {
+ const dataDir = makeDataDir();
+ const raw = Buffer.from(JSON.stringify({ ...PAYLOAD, note: '中文 ✓' }) + '\n');
+ const r = await runStatusline({ dataDir, sessionId: SID, chain: 'cat', stdin: raw });
+ expect(r.status).toBe(0);
+ expect(r.stdout.equals(raw)).toBe(true);
+ // 转发不影响落盘
+ expect(existsSync(statuslineFilePath(dataDir, SID))).toBe(true);
+ });
+
+ it('④ chain 非零退出:透传退出码,且快照仍已落盘', async () => {
+ const dataDir = makeDataDir();
+ const r = await runStatusline({ dataDir, sessionId: SID, chain: 'exit 3', stdin: Buffer.from(JSON.stringify(PAYLOAD)) });
+ expect(r.status).toBe(3);
+ expect(existsSync(statuslineFilePath(dataDir, SID))).toBe(true);
+ });
+
+ it('⑤ chain 挂死(sleep 30):看门狗 ≤ 12s 内 exit 0', async () => {
+ const dataDir = makeDataDir();
+ const r = await runStatusline({
+ dataDir,
+ sessionId: SID,
+ chain: 'sleep 30',
+ stdin: Buffer.from(JSON.stringify(PAYLOAD)),
+ ignoreOutput: true,
+ });
+ expect(r.status).toBe(0);
+ expect(r.elapsedMs).toBeLessThanOrEqual(12_000);
+ }, 20_000);
+
+ it('⑥ 非 JSON stdin:不落盘、exit 0', async () => {
+ const dataDir = makeDataDir();
+ const r = await runStatusline({ dataDir, sessionId: SID, stdin: Buffer.from('not json at all') });
+ expect(r.status).toBe(0);
+ expect(r.stdout.length).toBe(0);
+ expect(existsSync(statuslineFilePath(dataDir, SID))).toBe(false);
+ });
+
+ it('⑦ BOTMUX_WORKFLOW=1:不被根命令白名单拒绝,照常落盘', async () => {
+ const dataDir = makeDataDir();
+ const r = await runStatusline({
+ dataDir,
+ sessionId: SID,
+ stdin: Buffer.from(JSON.stringify(PAYLOAD)),
+ extraEnv: { BOTMUX_WORKFLOW: '1' },
+ });
+ expect(r.status).toBe(0);
+ expect(existsSync(statuslineFilePath(dataDir, SID))).toBe(true);
+ });
+});
diff --git a/test/statusline-snapshot.test.ts b/test/statusline-snapshot.test.ts
new file mode 100644
index 0000000000..4aad2de923
--- /dev/null
+++ b/test/statusline-snapshot.test.ts
@@ -0,0 +1,150 @@
+/**
+ * statusline-snapshot — Claude Code statusline 快照的 parse / 落盘 / 读取 / 缓存 / 陈旧判定。
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import {
+ __testOnly_resetStatuslineCache,
+ parseStatuslinePayload,
+ readStatuslineSnapshot,
+ removeStatuslineDir,
+ statuslineFilePath,
+ toCardQuota,
+ writeStatuslineSnapshot,
+ STATUSLINE_STALE_MS,
+} from '../src/services/statusline-snapshot.js';
+
+const NOW = 1_788_000_000_000;
+const SAMPLE = {
+ session_id: 'claude-sid',
+ transcript_path: '/home/u/.claude/projects/x/claude-sid.jsonl',
+ model: { id: 'claude-opus-5', display_name: 'Opus' },
+ context_window: { used_percentage: 23.4, context_window_size: 1_000_000, total_input_tokens: 234_000 },
+ rate_limits: {
+ five_hour: { used_percentage: 14.000000000000002, resets_at: Math.floor(NOW / 1000) + 3600 },
+ seven_day: { used_percentage: 5, resets_at: Math.floor(NOW / 1000) + 86_400 },
+ },
+};
+
+let dataDir: string;
+beforeEach(() => {
+ dataDir = mkdtempSync(join(tmpdir(), 'bmx-statusline-'));
+ __testOnly_resetStatuslineCache();
+});
+afterEach(() => {
+ rmSync(dataDir, { recursive: true, force: true });
+});
+
+describe('parseStatuslinePayload', () => {
+ it('maps every field; resets_at seconds → ms', () => {
+ const s = parseStatuslinePayload(SAMPLE, NOW)!;
+ expect(s).toEqual({
+ ts: NOW,
+ contextPercent: 23.4,
+ contextWindowTokens: 1_000_000,
+ fiveHourPercent: 14.000000000000002,
+ fiveHourResetsAtMs: (Math.floor(NOW / 1000) + 3600) * 1000,
+ sevenDayPercent: 5,
+ sevenDayResetsAtMs: (Math.floor(NOW / 1000) + 86_400) * 1000,
+ model: 'claude-opus-5',
+ transcriptPath: SAMPLE.transcript_path,
+ claudeSessionId: 'claude-sid',
+ });
+ });
+ it('accepts resets_at already in ms', () => {
+ const s = parseStatuslinePayload({ rate_limits: { five_hour: { used_percentage: 1, resets_at: NOW + 10 } } }, NOW)!;
+ expect(s.fiveHourResetsAtMs).toBe(NOW + 10);
+ });
+ it('drops malformed fields independently; clamps percentages', () => {
+ const s = parseStatuslinePayload({
+ context_window: { used_percentage: '23', context_window_size: -1 },
+ rate_limits: { five_hour: { used_percentage: 130, resets_at: 'soon' }, seven_day: null },
+ model: 'not-an-object',
+ }, NOW)!;
+ expect(s).toEqual({ ts: NOW, fiveHourPercent: 100 });
+ });
+ it('non-object → null', () => {
+ expect(parseStatuslinePayload(null, NOW)).toBeNull();
+ expect(parseStatuslinePayload('x', NOW)).toBeNull();
+ expect(parseStatuslinePayload([1], NOW)).toBeNull();
+ });
+});
+
+describe('write + read', () => {
+ it('round-trips through disk', () => {
+ const snap = parseStatuslinePayload(SAMPLE, NOW)!;
+ writeStatuslineSnapshot(dataDir, 'sid-1', snap);
+ expect(readStatuslineSnapshot(dataDir, 'sid-1', { now: NOW })).toEqual(snap);
+ });
+ it('missing file → undefined', () => {
+ expect(readStatuslineSnapshot(dataDir, 'nope', { now: NOW })).toBeUndefined();
+ });
+ it('corrupt JSON → undefined, no throw', () => {
+ writeStatuslineSnapshot(dataDir, 'sid-2', { ts: NOW, contextPercent: 1 });
+ writeFileSync(statuslineFilePath(dataDir, 'sid-2'), '{not json');
+ expect(readStatuslineSnapshot(dataDir, 'sid-2', { now: NOW })).toBeUndefined();
+ });
+ it('stale ts → undefined', () => {
+ writeStatuslineSnapshot(dataDir, 'sid-3', { ts: NOW - STATUSLINE_STALE_MS - 1, contextPercent: 1 });
+ expect(readStatuslineSnapshot(dataDir, 'sid-3', { now: NOW })).toBeUndefined();
+ expect(readStatuslineSnapshot(dataDir, 'sid-3', { now: NOW, maxAgeMs: 24 * 3600_000 })?.contextPercent).toBe(1);
+ });
+ it('rolled window drops that bucket percent only', () => {
+ writeStatuslineSnapshot(dataDir, 'sid-4', {
+ ts: NOW,
+ fiveHourPercent: 40, fiveHourResetsAtMs: NOW - 1,
+ sevenDayPercent: 9, sevenDayResetsAtMs: NOW + 1,
+ });
+ const s = readStatuslineSnapshot(dataDir, 'sid-4', { now: NOW })!;
+ expect(s.fiveHourPercent).toBeUndefined();
+ expect(s.fiveHourResetsAtMs).toBe(NOW - 1);
+ expect(s.sevenDayPercent).toBe(9);
+ });
+ it('caches by mtime/size and re-reads when the file changes', () => {
+ writeStatuslineSnapshot(dataDir, 'sid-5', { ts: NOW, contextPercent: 10 });
+ expect(readStatuslineSnapshot(dataDir, 'sid-5', { now: NOW })?.contextPercent).toBe(10);
+ // 同 mtime、同 size 的覆盖写不会被重读(证明缓存生效)——size 相同的两位数百分比
+ const path = statuslineFilePath(dataDir, 'sid-5');
+ const stale = JSON.stringify({ ts: NOW, contextPercent: 20 });
+ writeFileSync(path, stale);
+ utimesSync(path, new Date(NOW / 1000), new Date(NOW / 1000));
+ const first = readStatuslineSnapshot(dataDir, 'sid-5', { now: NOW })!;
+ // mtime 被改到 NOW(与首次写入不同),所以会重读;这里断言的是「变了就重读」
+ expect(first.contextPercent).toBe(20);
+ // 再次读取:mtime/size 未变 → 缓存命中,不 parse(写坏文件也读到旧值)
+ writeFileSync(path, '{broken');
+ utimesSync(path, new Date(NOW / 1000), new Date(NOW / 1000));
+ // size 变了会触发重读;把内容长度补到一致以证明 size+mtime 同则命中
+ const padded = '{broken'.padEnd(stale.length, ' ');
+ writeFileSync(path, padded);
+ utimesSync(path, new Date(NOW / 1000), new Date(NOW / 1000));
+ expect(readStatuslineSnapshot(dataDir, 'sid-5', { now: NOW })?.contextPercent).toBe(20);
+ });
+ it('removeStatuslineDir clears disk and cache', () => {
+ writeStatuslineSnapshot(dataDir, 'sid-6', { ts: NOW, contextPercent: 3 });
+ expect(readStatuslineSnapshot(dataDir, 'sid-6', { now: NOW })).toBeDefined();
+ removeStatuslineDir(dataDir, 'sid-6');
+ expect(readStatuslineSnapshot(dataDir, 'sid-6', { now: NOW })).toBeUndefined();
+ });
+});
+
+describe('toCardQuota', () => {
+ it('rounds percentages and omits missing fields', () => {
+ expect(toCardQuota(parseStatuslinePayload(SAMPLE, NOW)!)).toEqual({
+ contextPercent: 23,
+ contextWindowTokens: 1_000_000,
+ fiveHourPercent: 14,
+ fiveHourResetsAtMs: (Math.floor(NOW / 1000) + 3600) * 1000,
+ sevenDayPercent: 5,
+ sevenDayResetsAtMs: (Math.floor(NOW / 1000) + 86_400) * 1000,
+ });
+ });
+ it('undefined when nothing usable', () => {
+ expect(toCardQuota(undefined)).toBeUndefined();
+ expect(toCardQuota({ ts: NOW })).toBeUndefined();
+ expect(toCardQuota({ ts: NOW, model: 'x' })).toBeUndefined();
+ });
+});
diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts
index fbed4611e9..1af73069e9 100644
--- a/test/tmux-backend-env.test.ts
+++ b/test/tmux-backend-env.test.ts
@@ -219,6 +219,16 @@ describe('buildBotmuxEnvAssignments()', () => {
expect(out).not.toContain('PATH=/usr/bin');
});
+ it('forwards BOTMUX_STATUSLINE_CHAIN so `botmux statusline` inside the pane can chain the user statusLine', () => {
+ const out = buildBotmuxEnvAssignments({
+ BOTMUX: '1',
+ BOTMUX_STATUSLINE_CHAIN: 'bash ~/.claude/statusline.sh',
+ PATH: '/usr/bin',
+ });
+ expect(out).toContain('BOTMUX_STATUSLINE_CHAIN=bash ~/.claude/statusline.sh');
+ expect(out).not.toContain('PATH=/usr/bin');
+ });
+
it('forwards only a Codex App bootstrap path and strips the retired shared-secret env', () => {
const retiredSharedSecret = 'A'.repeat(43);
const bootstrapPath = '/private/bot-home/control.bootstrap';
diff --git a/test/worker-pool-statusline-quota.test.ts b/test/worker-pool-statusline-quota.test.ts
new file mode 100644
index 0000000000..55a12533c4
--- /dev/null
+++ b/test/worker-pool-statusline-quota.test.ts
@@ -0,0 +1,69 @@
+/**
+ * getDaemonSessionUsageSnapshot 是回复卡页脚 / 流式卡用量行 / IPC `/usage` 三个读取点的
+ * 唯一汇合处:Claude Code statusline 快照(ctx / 5h / 7d)只在这里合并一次。
+ * 无快照 / 陈旧 / 非 claude-code ⇒ 原样返回 transcript 快照(同一对象,不带 quota key),
+ * 保证卡片与无 statusline 时逐字节相同。
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { config } from '../src/config.js';
+import * as costCalculator from '../src/core/cost-calculator.js';
+import { getDaemonSessionUsageSnapshot } from '../src/core/worker-pool.js';
+import {
+ __testOnly_resetStatuslineCache,
+ STATUSLINE_STALE_MS,
+ writeStatuslineSnapshot,
+} from '../src/services/statusline-snapshot.js';
+
+let dataDir: string;
+let prevDataDir: string;
+beforeEach(() => {
+ dataDir = mkdtempSync(join(tmpdir(), 'botmux-wp-statusline-'));
+ prevDataDir = config.session.dataDir;
+ config.session.dataDir = dataDir;
+ __testOnly_resetStatuslineCache();
+});
+afterEach(() => {
+ config.session.dataDir = prevDataDir;
+ rmSync(dataDir, { recursive: true, force: true });
+ vi.restoreAllMocks();
+});
+
+// 不带 larkAppId:pricing 解析短路为 undefined,且 cliId 由显式参数给出,不触碰 bot registry。
+const ds = (sessionId: string) => ({ session: { sessionId }, workingDir: '/repo' }) as any;
+
+describe('getDaemonSessionUsageSnapshot × Claude statusline quota', () => {
+ it('merges the on-disk statusline snapshot for claude-code (rounded percentages)', () => {
+ const base = { context: { usedTokens: 12_345 }, tokens: { in: 1, out: 2 } };
+ vi.spyOn(costCalculator, 'getSessionUsageSnapshot').mockReturnValue(base as any);
+ writeStatuslineSnapshot(dataDir, 's1', { ts: Date.now(), contextPercent: 23.4, fiveHourPercent: 18, sevenDayPercent: 5.4 });
+ expect(getDaemonSessionUsageSnapshot(ds('s1'), 'claude-code')).toEqual({
+ ...base,
+ quota: { contextPercent: 23, fiveHourPercent: 18, sevenDayPercent: 5 },
+ });
+ });
+
+ it('returns the transcript snapshot untouched (same object, no quota key) when no snapshot exists', () => {
+ const base = { context: { usedTokens: 12_345 }, tokens: null };
+ vi.spyOn(costCalculator, 'getSessionUsageSnapshot').mockReturnValue(base as any);
+ const out = getDaemonSessionUsageSnapshot(ds('s2'), 'claude-code');
+ expect(out).toBe(base);
+ expect('quota' in out).toBe(false);
+ });
+
+ it('ignores the snapshot for non-claude-code CLIs even when a file exists', () => {
+ const base = { context: null, tokens: { in: 1, out: 2 } };
+ vi.spyOn(costCalculator, 'getSessionUsageSnapshot').mockReturnValue(base as any);
+ writeStatuslineSnapshot(dataDir, 's3', { ts: Date.now(), fiveHourPercent: 18 });
+ expect(getDaemonSessionUsageSnapshot(ds('s3'), 'codex' as any)).toBe(base);
+ });
+
+ it('drops a stale snapshot (older than STATUSLINE_STALE_MS) → no quota key', () => {
+ const base = { context: { usedTokens: 1 }, tokens: null };
+ vi.spyOn(costCalculator, 'getSessionUsageSnapshot').mockReturnValue(base as any);
+ writeStatuslineSnapshot(dataDir, 's4', { ts: Date.now() - STATUSLINE_STALE_MS - 1, contextPercent: 50 });
+ expect(getDaemonSessionUsageSnapshot(ds('s4'), 'claude-code')).toBe(base);
+ });
+});