Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ This option addresses one narrow gap: Codex running through Botmux's app-server

| Field | Description |
|------|------|
| `brandLabel` | Branding text at the bottom of the card (rendered on final replies and top-level broadcasts only). `undefined` = default `Powered by [botmux](https://github.com/deepcoldy/botmux) with :LOVE:`; `""` = hidden; any other string = rendered as-is (supports markdown). Purely cosmetic, does not affect routing / permissions |
| `brandLabel` | Branding text at the bottom of the card (rendered on final replies and top-level broadcasts only). `undefined` = default `Powered by [botmux](https://github.com/deepcoldy/botmux) with :LOVE:`; `""` = hidden; any other string = rendered as-is (supports markdown). Also gated by the machine-wide `dashboard.cardBrandLabel` switch (Dashboard "Settings → Feishu Cards", on by default): when off, no bot shows a footer signature and this field is greyed out on the bot config page. Purely cosmetic, does not affect routing / permissions |
| `showUsageInCardFooter` | Whether reply-card footers show native Context / Token usage from the Agent CLI. Missing / `true` = show; `false` = hide both metrics. A missing individual metric is still omitted independently. This controls card display only and does not disable the Usage Ledger or other accounting |
| `modelBackendVariant` display | A frozen TraeX backend variant appears only in the runtime identity on the live streaming session card. Reply-card footers show Context / Token usage only; they do not show the variant |
| `disableStreamingCard` | When `true`, no real-time streaming session card is sent at all (the Web Terminal still runs and the final reply still arrives via `botmux send`, there's just no auto-refreshing status card). For users who find the real-time card noisy |
Expand Down
2 changes: 1 addition & 1 deletion docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ Dashboard 保存后无需重启 daemon。模型、思考强度分别选择“继

| 字段 | 说明 |
|------|------|
| `brandLabel` | 卡片底部品牌文案(仅在最终回答与顶层广播卡片中展示)。`undefined`=默认 `Powered by [botmux](https://github.com/deepcoldy/botmux) with :LOVE:`;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。纯样式,不影响路由 / 权限 |
| `brandLabel` | 卡片底部品牌文案(仅在最终回答与顶层广播卡片中展示)。`undefined`=默认 `Powered by [botmux](https://github.com/deepcoldy/botmux) with :LOVE:`;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。另受机器级总开关 `dashboard.cardBrandLabel` 约束(Dashboard「设置 → 飞书卡片」,缺省开):关闭后所有 Bot 一律不显示页脚签名,本字段在 Bot 配置页置灰。纯样式,不影响路由 / 权限 |
| `showUsageInCardFooter` | 回复卡片页脚是否展示 Agent CLI 原生提供的 Context / Token 用量。缺省 / `true`=展示,`false`=同时隐藏两项;单项数据缺失时仍只省略缺失项。仅控制卡片展示,不停止 Usage Ledger 或其它统计 |
| `modelBackendVariant` 显示 | 已冻结的 TraeX 后端变体只显示在实时流式 session 卡片的运行时标识中;回复卡片页脚只显示 Context / Token 用量,不展示该变体 |
| `disableStreamingCard` | `true` 时彻底不发实时流式 session 卡片(web 终端仍跑、最终答复仍经 `botmux send` 到达,只是没有自动刷新的状态卡)。给嫌实时卡吵的用户 |
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cli/codex-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter {

supportsTypeAhead: true,
completionPattern: undefined,
readyPattern: /›/,
readyPattern: /[›»]/,
systemHints: [],
injectsSessionContext: true,
altScreen: false,
Expand Down
36 changes: 13 additions & 23 deletions src/adapters/cli/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ import { parseDebugModelsJson } from './model-catalog-json.js';
import type { CliAdapter, PtyHandle } from './types.js';
import { codexHistoryPath, codexHome, codexSessionsRoot } from '../../services/codex-paths.js';
import { findCodexRolloutSetByPid } from '../../services/codex-transcript.js';
import { prepareCodexTerminalStatusLine, refreshCodexTerminalSession } from '../../services/codex-terminal-session.js';
import { refreshCodexTerminalSession } from '../../services/codex-terminal-session.js';
import { discoverRolloutSessions } from '../../services/resumable-session-discovery.js';
import { delay, scaleMs } from '../../utils/timing.js';
import { t } from '../../i18n/index.js';
import { codexStatusLineSetupNotice } from '../../services/codex-statusline-config.js';

const CODEX_ACTIVE_BUSY_PATTERN = /Working[^\r\n]{0,160}esc to interrupt/i;
const CODEX_STARTUP_READY_PATTERN = /│[ \t]+model:[ \t]+(?!loading\b)[^│\s][^│\r\n]*│[ \t\r\n]*│[ \t]+directory:[ \t]+(?!loading\b)[^│\s][^│\r\n]*│/;
Expand Down Expand Up @@ -66,10 +64,10 @@ function restoredCodexHistoryReady(history: string): boolean {
const banner = history.match(/^\s*╭[^\r\n]*╮\r?\n[\s\S]*?╰[^\r\n]*╯/)?.[0];
const initialized = !!banner && banner.includes('>_ OpenAI Codex') && CODEX_STARTUP_READY_PATTERN.test(banner);
const lines = history.trimEnd().split(/\r?\n/);
const fromBottom = [...lines].reverse().findIndex(line => /^\s*›(?:\s|$)/.test(line));
const fromBottom = [...lines].reverse().findIndex(line => /^\s*[›»](?:\s|$)/.test(line));
if (fromBottom < 0) return false;
const prompt = lines.length - 1 - fromBottom;
if (!/^\s*›\s*(?:Ask Codex to do anything)?\s*$/.test(lines[prompt])) return false;
if (!/^\s*[›»]\s*(?:Ask Codex to do anything)?\s*$/.test(lines[prompt])) return false;
const footer = lines.slice(prompt + 1).filter(line => line.trim());
if (footer.length !== 1) return false;
const restoredReady = (restored || initialized)
Expand All @@ -89,10 +87,10 @@ function restoredCodexHistoryReady(history: string): boolean {
function resumedCodexPromptReady(screen: string): boolean {
if (/(?:model|directory):\s*loading\b|Resuming session|esc to interrupt|Queued for capacity/i.test(screen)) return false;
const lines = screen.trimEnd().split('\n');
const fromBottom = [...lines].reverse().findIndex(line => /^\s*›(?:\s|$)/.test(line));
const fromBottom = [...lines].reverse().findIndex(line => /^\s*[›»](?:\s|$)/.test(line));
if (fromBottom < 0) return false;
const prompt = lines.length - 1 - fromBottom;
if (!/^\s*›\s*(?:Ask Codex to do anything)?\s*$/.test(lines[prompt])) return false;
if (!/^\s*[›»]\s*(?:Ask Codex to do anything)?\s*$/.test(lines[prompt])) return false;
// The composer must be the bottom input surface, followed only by its
// initialized model/path footer. Pickers, review dialogs and history alone
// cannot satisfy this shape. Do not depend on a particular model name.
Expand Down Expand Up @@ -120,10 +118,10 @@ interface HistoryMatch {
ownershipProven?: boolean;
}

function historyMatchResult(match: HistoryMatch): { submitted: true; cliSessionId?: string; ownershipProven?: true } {
function historyMatchResult(match: HistoryMatch, requireOwnership: boolean): { submitted: true; cliSessionId?: string; ownershipProven?: true } {
return {
submitted: true,
...(match.cliSessionId ? { cliSessionId: match.cliSessionId } : {}),
...(match.cliSessionId && (!requireOwnership || match.ownershipProven) ? { cliSessionId: match.cliSessionId } : {}),
...(match.ownershipProven ? { ownershipProven: true } : {}),
};
}
Expand Down Expand Up @@ -454,12 +452,6 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {

async writeInput(pty: PtyHandle, content: string) {
const terminalSession = await refreshCodexTerminalSession(pty);
if (terminalSession.kind === 'unavailable') {
const setup = prepareCodexTerminalStatusLine(pty);
return { submitted: false, failureReason: setup
? `${t('worker.codex_terminal_message_not_written')}\n${codexStatusLineSetupNotice(setup)}`
: t('worker.codex_terminal_identity_unavailable') };
}
// Codex's input mode treats every literal \n as Enter. The old path
// (`send-keys -l` with the whole multi-line blob) therefore submitted
// each line as its own turn — a single Lark message fragmented into
Expand Down Expand Up @@ -514,15 +506,13 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
? (sid) => {
if (!sid) return false;
const owned = findCodexRolloutSetByPid(cliPid);
// set unavailable (enumeration failed) → don't block the submit
// confirmation; the worker attach gate re-checks ownership.
if (!owned) return true;
if (!owned || owned.size === 0) return true;
return owned.has(sid.toLowerCase());
}
: undefined;
// Positive ownership only: the explicit expected thread, or an owned
// rollout set that is available AND contains the line's session. The
// enumeration-unavailable and unfiltered acceptances above keep their
// empty/unavailable-set and unfiltered acceptances above keep their
// submit semantics but never prove that this pane consumed the input.
const proveSid: HistorySidProof | undefined = expectedRemoteSid
? (sid) => !!sid && sid.toLowerCase() === expectedRemoteSid.toLowerCase()
Expand Down Expand Up @@ -552,18 +542,18 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {

for (let attempt = 0; attempt < 3; attempt++) {
const match = await waitForHistoryAppend(historyPath, baseByte, content, 800, acceptSid, proveSid);
if (match.found) return historyMatchResult(match);
if (match.found) return historyMatchResult(match, cliPid !== undefined);
if (!trySendEnter()) return { submitted: false };
}
const match = await waitForHistoryAppend(historyPath, baseByte, content, 800, acceptSid, proveSid);
if (match.found) return historyMatchResult(match);
if (match.found) return historyMatchResult(match, cliPid !== undefined);
// In-band budget exhausted. Hand the worker a recheck closure: a
// slow-startup Codex (or one whose first turn is delayed by a heavy
// initial prompt) may still append our marker after the retries gave
// up, and the worker re-scans on a delay before warning the user.
const recheck = () => {
const late = matchHistoryDelta(historyPath, baseByte, content, acceptSid, proveSid);
return late.found ? historyMatchResult(late) : false;
return late.found ? historyMatchResult(late, cliPid !== undefined) : false;
};
return { submitted: false, recheck };
},
Expand All @@ -579,7 +569,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
// the update. Keep accepting the composer marker anywhere in a TUI redraw,
// but reject numbered menu choices. This remains necessary for wrappers
// such as Aiden that cannot forward the startup-update config override.
readyPattern: /›(?!\s*\d+\.)|\d+% left/,
readyPattern: /[›»](?!\s*\d+\.)|\d+% left/,
// 0.153.x paints a skeleton composer before thread initialization. The
// `›` and two seconds of silence do not prove it can submit yet; history
// can remain empty throughout bootstrap even when a TUI input is queued.
Expand Down
6 changes: 0 additions & 6 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ export interface PtyHandle {
/** Working directory the CLI was spawned in; cross-checked against the pid file's
* cwd field to reject pid reuse / unrelated processes. */
cliCwd?: string;
/**
* Whether this session is an adopted external pane (via /adopt).
* Owned sessions directly managed by botmux set this to false, allowing
* legacy submit without requiring a visible statusline thread ID.
*/
isAdopt?: boolean;
}

export type SubmitRecheckResult = boolean | {
Expand Down
28 changes: 28 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseGroupCreationDefaults, type GroupCreationDefaults } from './services/group-creation-options.js';
import { readGlobalConfig } from './global-config.js';
import { parseSandboxNetworkPolicy } from './core/sandbox-network-policy.js';
import * as Lark from '@larksuiteoapi/node-sdk';
import { normalizeCodexInstancePool, registerCodexInstanceBot, clearCodexInstanceBots, validateCodexInstanceRoster } from './services/codex-instance-pool.js';
Expand Down Expand Up @@ -2914,14 +2915,41 @@ function botsConfigDiskPath(): string | null {
return existsSync(r) ? r : null;
}

/**
* Machine-wide switch for the reply-card footer brand signature
* (`~/.botmux/config.json` → `dashboard.cardBrandLabel`). Default ON; only an
* explicit stored `false` disables. Read live (readGlobalConfig has a 2s TTL)
* so a Dashboard Settings flip reaches the next card render without a restart.
*
* A sandboxed / one-shot child (`botmux send`) can't read config.json
* (deny-by-default → EPERM, readGlobalConfig then sees `{}` = enabled), so the
* worker bridges the resolved value as `BOTMUX_CARD_BRAND_ENABLED`; it's only
* honoured for THIS bot's own child process — the same own-appId gate used for
* `BOTMUX_BRAND_LABEL` — never on a value inherited from an unrelated process.
*/
export function isCardBrandLabelEnabled(larkAppId?: string): boolean {
if (larkAppId !== undefined
&& process.env.BOTMUX_LARK_APP_ID === larkAppId
&& process.env.BOTMUX_CARD_BRAND_ENABLED === 'false') {
return false;
}
return readGlobalConfig().dashboard?.cardBrandLabel !== false;
}

/**
* The configured brand label for a bot, or `undefined` when unset (`''` = off
* is preserved). Prefers the in-memory registry (daemon hot path); falls back
* to a mtime-cached read of bots.json so the CLI process — which never loads
* the registry — still resolves the sending bot's brand. Callers feed the
* result into {@link brandFooterSegment} for the unset→default / ''→off rule.
*
* The machine-wide {@link isCardBrandLabelEnabled} switch is applied HERE, the
* single choke point every card builder funnels through: when off it returns
* `''` for every bot — a custom label included — so callers suppress the brand
* rather than falling back to the default botmux link (`undefined`).
*/
export function resolveBrandLabel(larkAppId: string): string | undefined {
if (!isCardBrandLabelEnabled(larkAppId)) return '';
// A sandboxed one-shot `botmux send` can't read bots.json (deny-by-default),
// so it has no in-memory registry and would fall through to a bots.json read
// that EPERMs → role footer lost. The worker injects THIS bot's resolved
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,16 @@ export const config = {
// `!disableCliBypass` before handing it to the adapter (see worker init).
get bypassCodexHookTrust(): boolean { return readGlobalConfig().dashboard?.bypassCodexHookTrust !== false; },
get hideCodexRateLimitModelNudge(): boolean { return readGlobalConfig().dashboard?.hideCodexRateLimitModelNudge !== false; },
// Live getter: machine-wide switch for the reply-card footer brand signature
// (default botmux link / per-bot custom brandLabel). Default ON (absent ⇒
// shown); an explicit stored false suppresses the brand segment on EVERY bot's
// final/broadcast cards (usage/duration/recipient lines are unaffected). Read
// live (readGlobalConfig has a 2s TTL) so a Dashboard Settings flip applies to
// the next card render without restarting the daemon. Resolved in
// bot-registry.resolveBrandLabel, which is the single choke point all card
// builders feed; sandboxed one-shot `botmux send` children receive the value
// via the worker's env bridge.
get cardBrandLabelEnabled(): boolean { return readGlobalConfig().dashboard?.cardBrandLabel !== false; },
};

// allowedUsers is mutable — daemon resolves email prefixes to open_ids at startup
Expand Down
Loading
Loading