Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sender type="user\|bot" open_id="ou_…" name="…" email="…" />` 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 `<identity>` 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 `<botmux_reminder>` 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 `<user_message>` wrapper and the `<sender/>` 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 / `<sender/>`) 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 `<user_message>` / `<sender>` 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 `<sender_note>` anti-echo hint, which disappears together with the tag), or when you do not want per-message identity written into the CLI transcript.

Expand Down
25 changes: 25 additions & 0 deletions docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,31 @@ Dashboard 的「Bot 配置 → 消息卡片 → 实时卡片按钮」提供同
|------|------|
| `senderTag` | 布尔,默认 `true`(开)。每轮转发给 CLI 的消息是否附带一个 `<sender type="user\|bot" open_id="ou_…" name="…" email="…" />` 标签,告诉模型这句话是谁说的。只有显式 `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 / @ 决策 / 附件用法、`<identity>` 里「协作必须 `botmux send --mention`」的规则一并去掉,只保留 `botmux history` / `botmux bots list` 与 `BOTMUX_NOTHING_TO_SEND` 沉默哨兵。附件、跨 bot @ 等确实需要 `botmux send` 的场景,模型可通过内置 skill(`--plugin-dir` 里的 `botmux-send`)按需自行发现;
2. **不再逐轮注入 `<botmux_reminder>`**(每轮 prompt 少一段提醒);
3. **solo 会话去壳**:私聊、或只有 owner 和本 bot 两个参与者的 1v1 普通群,每轮消息去掉 `<user_message>` 壳与 `<sender/>` 标签,模型看到的就是裸文本。话题群、多人群、非 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 / 壳 / `<sender/>`)从下一轮起生效;系统提示是 spawn 时注入的,已在跑的会话要 `/restart` 才换新值,新会话直接用新值。
- **观测代价**:solo 会话的裸文本形态没有 `<user_message>` / `<sender>` 结构,`/adopt` 不再把这类会话识别为 botmux 自产会话(与 `senderTag: false` 同类代价)。
- dashboard「回复投递 → 转写回复模式」开关保存的就是这个字段;当前 CLI 不支持时开关禁用并说明。

### `senderTag: false`

关掉后模型看不到发言人身份:多人会话里无法区分谁说的、也无法按人称呼。适合模型会把标签内容抄进回复正文的 CLI(如 cursor,见 `<sender_note>` 反抄写提示——标签关掉后该提示也一并消失),或不希望把每条消息的身份写进 CLI 记录的场景。

Expand Down
76 changes: 72 additions & 4 deletions src/adapters/cli/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -715,6 +715,56 @@ function resolveClaudeChatKeybindings(keybindingsPath: string): ClaudeChatKeybin
* across multiple adapter instances shares the warmup state. */
const claudeFirstWriteSeen = new WeakSet<PtyHandle>();

/** 用户自己配置的 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 非空的):
* `<cwd>/.claude/settings.local.json` > `<cwd>/.claude/settings.json` > `userSettingsPath`
* (后者通常是 `~/.claude/settings.json`;read-isolation 下是 `<BOT_HOME>/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<string, unknown>).statusLine;
if (!sl || typeof sl !== 'object' || Array.isArray(sl)) continue;
const o = sl as Record<string, unknown>;
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/<pid>.json`
* pid-state, `tasks/` fd locks, keybindings.json, settings.json hooks) but
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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` 落盘到 `<DATA_DIR>/statusline/<sid>/`,
// 卡片用量段据此渲染 `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) {
Expand All @@ -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;
},

Expand Down
9 changes: 9 additions & 0 deletions src/adapters/cli/fs-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sessionId>/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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading