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
15 changes: 13 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ cache read/create tokens when the CLI reports them; Token Out is the native
output-side total. Botmux does not estimate token counts from message text.
_Avoid_: token estimate, cost estimate

**Context Usage**:
The latest valid context-window measurement reported by an **Agent CLI** or its
persisted transcript. It may decrease after compaction and is never derived
from cumulative **Token Usage**. The window size and percentage are shown only
when the Agent CLI provides enough native data; missing measurements are
omitted from card footers rather than inferred from the model name.
Each Bot may set `showUsageInCardFooter: false` to hide both Context Usage and
Token Usage from ordinary reply-card footers. This is a display preference
only; Usage Ledger accounting and other usage consumers remain active.
_Avoid_: cumulative context, estimated context window

**Usage Ledger**:
Append-only daily JSONL files under `~/.botmux/usage/` recording per-turn
**Token Usage** deltas per **Session**. Each record is a self-describing JSON
Expand All @@ -48,5 +59,5 @@ route that reply back to the same Agent CLI conversation."

Dev: "Cursor did not expose Token Usage for this Session."

Domain expert: "Then botmux should say the Token Usage is unavailable, not guess
from the visible text."
Domain expert: "Then botmux should omit Token Usage from the card footer, not
guess from the visible text."
1 change: 1 addition & 0 deletions docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ You can also add it to the corresponding bot entry directly (manual `bots.json`
| Field | Description |
|------|------|
| `brandLabel` | Branding text at the bottom of the card. `undefined` = default `botmux` link; `""` = hidden; any other string = rendered as-is (supports markdown). 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 |
| `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 |
| `silentTurnReactions` | When `true`, card-off sessions no longer add GoGoGo / DONE reactions to the triggering message. Only affects the lightweight status reactions used when `disableStreamingCard` or `noCardChats` suppresses live cards; defaults to `false` |
| `receivedReactionEmoji` | Feishu emoji_type for the "received" reaction in card-off sessions; `undefined` = default `GoGoGo` (冲!). Free-form string; a bad value just silently fails to attach (best-effort) |
Expand Down
1 change: 1 addition & 0 deletions docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
| 字段 | 说明 |
|------|------|
| `brandLabel` | 卡片底部品牌文案。`undefined`=默认 `botmux` 链接;`""`=隐藏;其它字符串=原样渲染(支持 markdown)。纯样式,不影响路由 / 权限 |
| `showUsageInCardFooter` | 回复卡片页脚是否展示 Agent CLI 原生提供的 Context / Token 用量。缺省 / `true`=展示,`false`=同时隐藏两项;单项数据缺失时仍只省略缺失项。仅控制卡片展示,不停止 Usage Ledger 或其它统计 |
| `disableStreamingCard` | `true` 时彻底不发实时流式 session 卡片(web 终端仍跑、最终答复仍经 `botmux send` 到达,只是没有自动刷新的状态卡)。给嫌实时卡吵的用户 |
| `silentTurnReactions` | `true` 时,无卡片会话不再给触发消息添加 GoGoGo / DONE reaction。只影响 `disableStreamingCard` 或 `noCardChats` 关闭实时卡片后的轻量状态提示;默认 `false` |
| `receivedReactionEmoji` | 无卡片会话「已收到」reaction 的飞书 emoji_type;`undefined`=默认 `GoGoGo`(冲!)。自由字符串,填错只是静默不加表情(best-effort) |
Expand Down
Binary file added docs/assets/card-usage-footer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/dashboard-card-usage-toggle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,12 @@ export interface BotConfig {
* routing or permissions.
*/
brandLabel?: string;
/**
* Whether ordinary reply-card footers show native Context Usage and Token
* Usage. Missing/true preserves the default display; false hides both metrics
* without disabling Usage Ledger accounting or other footer chrome.
*/
showUsageInCardFooter?: boolean;
/**
* botmux slash 可注入的 CLI 原生斜杠命令 allowlist(如 ["/compact","/model"])。
* 缺省/空 = 通用注入关闭。/cd 永远被拒(见 core/slash-inject.ts)。
Expand Down Expand Up @@ -1363,6 +1369,7 @@ export function __testOnly_resetBotRegistry(): void {
loadedConfigPath = undefined;
oncallChatCache = null;
brandLabelCache = null;
showUsageInCardFooterCache = null;
}

// Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without
Expand Down Expand Up @@ -1635,6 +1642,7 @@ export function isChatOncallBoundForAnyBot(chatId: string): boolean {
// Per-bot brand label, mtime-cached for the disk fallback. Keyed by larkAppId →
// the configured value (undefined when the bot has no brandLabel key).
let brandLabelCache: { mtimeMs: number; map: Map<string, string | undefined> } | null = null;
let showUsageInCardFooterCache: { mtimeMs: number; map: Map<string, boolean> } | null = null;

/** Resolve the bots.json path the same way loadBotConfigs does, without
* requiring the registry to have been loaded (works in one-shot CLI processes
Expand Down Expand Up @@ -1687,6 +1695,41 @@ export function resolveBrandLabel(larkAppId: string): string | undefined {
}
}

/**
* Resolve the per-bot, default-on reply-card usage visibility. A freshly loaded
* registry wins over the spawn-time env so long-lived panes observe `/botconfig`
* hot updates; sandboxed/env-only processes carry the same field in their
* synthetic registered bot and otherwise fall back to the injected env.
*/
export function resolveShowUsageInCardFooter(larkAppId: string): boolean {
const inMem = bots.get(larkAppId);
if (inMem) return inMem.config.showUsageInCardFooter !== false;
if (process.env.BOTMUX_LARK_APP_ID === larkAppId
&& 'BOTMUX_SHOW_USAGE_IN_CARD_FOOTER' in process.env) {
return process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER !== 'false';
}
const path = loadedConfigPath ?? botsConfigDiskPath();
if (!path) return true;
try {
const stat = statSync(path);
if (!showUsageInCardFooterCache || showUsageInCardFooterCache.mtimeMs !== stat.mtimeMs) {
const raw = JSON.parse(readFileSync(path, 'utf-8'));
const map = new Map<string, boolean>();
if (Array.isArray(raw)) {
for (const entry of raw) {
if (entry && typeof entry.larkAppId === 'string') {
map.set(entry.larkAppId, entry.showUsageInCardFooter !== false);
}
}
}
showUsageInCardFooterCache = { mtimeMs: stat.mtimeMs, map };
}
return showUsageInCardFooterCache.map.get(larkAppId) ?? true;
} catch {
return true;
}
}

/**
* 只读 accessor:该 bot 配置的 tuiSlashAllow allowlist(TUI 通用 slash 注入用)。
* 仅读内存态注册表,daemon 进程内使用;无需 bots.json 磁盘回退(不同于
Expand Down Expand Up @@ -2173,6 +2216,8 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
// Preserve '' distinctly from undefined: '' means "brand off", undefined
// means "use default botmux brand". Don't trim-to-undefined here.
brandLabel: typeof entry.brandLabel === 'string' ? entry.brandLabel : undefined,
// Default ON: only explicit false is meaningful/persisted.
showUsageInCardFooter: entry.showUsageInCardFooter === false ? false : undefined,
disableStreamingCard: entry.disableStreamingCard === true || undefined,
silentTurnReactions: entry.silentTurnReactions === true || undefined,
receivedReactionEmoji: typeof entry.receivedReactionEmoji === 'string' && entry.receivedReactionEmoji.trim()
Expand Down
170 changes: 138 additions & 32 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3054,6 +3054,7 @@ interface AdoptedFromData {
herdrTarget?: string;
herdrPaneId?: string;
originalCliPid?: number;
sessionId?: string;
cwd?: string;
cliId?: string;
}
Expand Down Expand Up @@ -3108,6 +3109,8 @@ interface SessionData {
// here, so they're typed loosely. Used by cmdList to avoid reporting an
// unconfirmed /adopt scratch as a crashed CLI session.
cliId?: string;
/** CLI-native resume id when it differs from botmux's Session id. */
cliSessionId?: string;
lastCliInput?: string;
adoptedFrom?: AdoptedFromData;
/** Deliberately suspended by the resident-session cap. No process/backing
Expand Down Expand Up @@ -4240,6 +4243,103 @@ function findDaemon(larkAppId?: string): {
return all[0] ?? null;
}

function normalizeCardUsageSnapshot(value: unknown): CardUsageSnapshot | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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. */
async function readCardUsageSnapshotForSend(
session: SessionData,
larkAppId: string,
): Promise<CardUsageSnapshot> {
let daemonPort: number | undefined;
try {
daemonPort =
findDaemon(larkAppId)?.ipcPort
?? resolveDaemonIpcPort(undefined, process.env.BOTMUX_DAEMON_IPC_PORT);
} catch {
// A stale/unreadable daemon registry must not prevent the reply.
}
if (daemonPort) {
try {
const path = `/api/sessions/${encodeURIComponent(session.sessionId)}/usage`;
const response = await fetchDaemonIpc(daemonPort, path, {
method: 'GET',
signal: AbortSignal.timeout(1_500),
});
if (response.ok) {
const body = await response.json() as { usage?: unknown };
const normalized = normalizeCardUsageSnapshot(body.usage);
if (normalized) return normalized;
}
} catch {
// No host secret, old daemon, timeout, or transient IPC failure: use the
// same bounded local parser below.
}
}

// Old/offline daemon fallback. Sandboxed panes receive this per-bot value
// explicitly from the worker because bots.json is intentionally unreadable.
if (!resolveShowUsageInCardFooter(larkAppId)) {
return { context: null, tokens: null };
}

try {
return getSessionUsageSnapshot({
cliId: (session.cliId ?? session.adoptedFrom?.cliId ?? 'unknown') as CliId | 'unknown',
sessionId: session.sessionId,
cliSessionId: session.cliSessionId ?? session.adoptedFrom?.sessionId,
cwd: session.workingDir ?? session.adoptedFrom?.cwd,
larkAppId,
fresh: true,
});
} catch {
return { context: null, tokens: null };
}
}

/**
* Authenticate the human who opened this exact turn against the target run,
* then return the only daemon app that may receive the mutation. Inherited
Expand Down Expand Up @@ -5836,37 +5936,40 @@ function argValues(args: string[], ...flags: string[]): string[] {
function withCustomCardMentionFooter(
card: Record<string, unknown>,
mentionOpenIds: readonly string[],
sentToLabel: string,
locale?: Locale,
): { ok: true; card: Record<string, unknown> } | { ok: false; error: string } {
if (mentionOpenIds.length === 0) return { ok: true, card };
const cloned = JSON.parse(JSON.stringify(card)) as Record<string, unknown>;
const body = cloned.body as { elements?: unknown } | undefined;
if (!body || !Array.isArray(body.elements)) {
const deduped = [...new Set(mentionOpenIds.filter(Boolean))];
const cloned = appendReplyCardFooterToV2Card(card, {
brand: '',
recipientOpenIds: deduped,
locale,
});
if (!cloned) {
return {
ok: false,
error: '自定义卡片带 --mention/--mention-back 时必须是 schema 2.0 且包含 body.elements;或改用 --no-mention 并在卡片 JSON 内自行处理展示',
error: '自定义卡片带 --mention/--mention-back 时必须是 schema 2.0、包含 body.elements,且未占用 botmux_reply_footer 元素 ID;或改用 --no-mention 并在卡片 JSON 内自行处理展示',
};
}
const deduped = [...new Set(mentionOpenIds.filter(Boolean))];
body.elements.push(
{ tag: 'hr' },
{
tag: 'markdown',
text_size: 'notation_small_v2',
content: `<font color='grey'>${sentToLabel}${deduped.map(id => `<at id=${id}></at>`).join(' ')}</font>`,
},
);
return { ok: true, card: cloned };
}

// Card v2 body builder helpers — extracted to im/lark/md-card.ts so the
// daemon's bridge fallback path can produce identical cards. cmdSend
// keeps using `buildImageCardElements` from there.
import { buildImageCardElements, brandFooterSegment, prepareCardMarkdown, type LocalHomeLinkMode } from './im/lark/md-card.js';
import {
appendReplyCardFooterToV2Card,
buildImageCardElements,
buildReplyCardFooter,
prepareCardMarkdown,
type CardUsageSnapshot,
type LocalHomeLinkMode,
} from './im/lark/md-card.js';
import { applyInlineMentions } from './im/lark/inline-mentions.js';
import { renderBrandTemplate } from './im/lark/brand-template.js';
import { resolveBrandLabel } from './bot-registry.js';
import { resolveBrandLabel, resolveShowUsageInCardFooter } from './bot-registry.js';
import { config } from './config.js';
import { getSessionUsageSnapshot } from './core/cost-calculator.js';
import {
resolveQuoteTarget,
validateMentionDecision,
Expand Down Expand Up @@ -6046,6 +6149,8 @@ async function registerSelfFromCredFile(): Promise<void> {
larkAppSecret: cred.larkAppSecret,
cliId: 'claude-code',
brand: cred.brand as 'feishu' | 'lark' | undefined,
showUsageInCardFooter:
process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER === 'false' ? false : undefined,
} as import('./bot-registry.js').BotConfig);
}

Expand Down Expand Up @@ -6110,6 +6215,8 @@ function riffModeSession(opts: { evenWithLocalSessions?: boolean } = {}): { sess
brand,
cliId: 'riff',
allowedUsers: [],
showUsageInCardFooter:
process.env.BOTMUX_SHOW_USAGE_IN_CARD_FOOTER === 'false' ? false : undefined,
} as unknown as import('./bot-registry.js').BotConfig;

const session: SessionData = {
Expand Down Expand Up @@ -7171,7 +7278,7 @@ async function cmdSend(rest: string[]): Promise<void> {
const withFooter = withCustomCardMentionFooter(
customCard,
mentionFooter,
t('card.sent_to', undefined, localeForBot(appId)),
localeForBot(appId),
);
if (!withFooter.ok) { console.error(`botmux send: ${withFooter.error}`); process.exit(2); }
customCard = withFooter.card;
Expand Down Expand Up @@ -7263,9 +7370,6 @@ async function cmdSend(rest: string[]): Promise<void> {
// Brand segment honours this bot's configured brandLabel (unset →
// default botmux, '' → suppressed, else custom). Same resolver/rule as
// the daemon's card builders so both send paths render identically.
const footerParts: string[] = [];
const brandSeg = brandFooterSegment(renderBrandTemplate(resolveBrandLabel(appId), s.workingDir));
if (brandSeg) footerParts.push(brandSeg);
// All real mentions land on one footer line: human addressee first, then
// explicit @ targets (incl. handoff bots), then cc. Ids already inlined in
// the body prose are skipped. Top-level publish keeps sendTo empty.
Expand All @@ -7275,9 +7379,13 @@ async function cmdSend(rest: string[]): Promise<void> {
cc: footerAddressing.cc,
inlinedIds: usedIds,
});
if (footerRecipients.length > 0) {
footerParts.push(`${t('card.sent_to', undefined, localeForBot(appId))}${footerRecipients.map(id => `<at id=${id}></at>`).join(' ')}`);
}
const usageSnapshot = await readCardUsageSnapshotForSend(s, appId);
const footer = buildReplyCardFooter({
brand: renderBrandTemplate(resolveBrandLabel(appId), s.workingDir),
recipientOpenIds: footerRecipients,
usage: usageSnapshot,
locale: localeForBot(appId),
});
// Footer line (brand 个性签名 + 发送给) and the optional 🔊 语音总结 button
// share ONE row: footer text on the left (weighted, fills), button pinned
// to the far right (auto width). When voice isn't configured the footer
Expand All @@ -7293,9 +7401,7 @@ async function cmdSend(rest: string[]): Promise<void> {
voiceOn = isVoiceConfigured(appId);
} catch { /* voice module/config unavailable → no button */ }
}
const footerContent = footerParts.length > 0
? `<font color='grey'>${footerParts.join(' · ')}</font>`
: '';
const footerContent = footer?.content ?? '';
if (footerContent || voiceOn) {
elements.push({ tag: 'hr' });
if (voiceOn) {
Expand All @@ -7307,7 +7413,11 @@ async function cmdSend(rest: string[]): Promise<void> {
columns: [
{
tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center',
elements: [{ tag: 'markdown', text_size: 'notation_small_v2', content: footerContent || ' ' }],
elements: [footer?.element ?? {
tag: 'markdown',
text_size: 'notation_small_v2',
content: ' ',
}],
},
{
tag: 'column', width: 'auto', vertical_align: 'center',
Expand All @@ -7324,11 +7434,7 @@ async function cmdSend(rest: string[]): Promise<void> {
],
});
} else {
elements.push({
tag: 'markdown',
text_size: 'notation_small_v2',
content: footerContent,
});
if (footer) elements.push(footer.element);
}
}

Expand Down
Loading