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
14 changes: 13 additions & 1 deletion src/adapters/cli/shared-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
import { t, type Locale } from '../../i18n/index.js';
import { whiteboardEnabled } from '../../services/whiteboard-store.js';
import { config } from '../../config.js';

/** Keep Workflow discoverable even when the full skill catalog is not injected. */
function workflowDiscoveryHint(locale?: Locale): string {
Expand All @@ -37,6 +38,11 @@ export function buildBotmuxShellHints(locale?: Locale): string[] {
t('ai.shell.heredoc_example', undefined, locale),
t('ai.shell.helpers', undefined, locale),
t('ai.shell.when_to_send', undefined, 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.
...(config.noVisibleOutputHint ? [t('ai.shell.no_visible_output_ok', undefined, locale)] : []),
t('ai.shell.mention_gate', undefined, locale),
workflowDiscoveryHint(locale),
hiddenContextDefense(locale),
Expand All @@ -48,7 +54,9 @@ export function buildBotmuxShellHints(locale?: Locale): string[] {
}

/** @deprecated Use `buildBotmuxShellHints(locale)` instead. Kept for any external callers.
* Static legacy value must not read runtime config at module import time. */
* Static legacy value must not read runtime config at module import time — so the
* experimental `no_visible_output_ok` line (gated on config.noVisibleOutputHint) is
* intentionally absent here; only the live `buildBotmuxShellHints` path carries it. */
export const BOTMUX_SHELL_HINTS: string[] = [
t('ai.shell.intro'),
t('ai.shell.commands_are_shell'),
Expand Down Expand Up @@ -120,6 +128,10 @@ export function buildBotmuxSystemPromptText(opts: {
'<botmux_routing>',
t('ai.routing.intro', undefined, locale),
t('ai.routing.must_use_botmux', undefined, locale),
// Experimental anti-resend guidance — opt-in via dashboard Settings
// (dashboard.noVisibleOutputHint). Default OFF ⇒ this block is byte-for-byte
// the pre-feature baseline. Live-read so a toggle applies to the next session.
...(config.noVisibleOutputHint ? [t('ai.routing.no_visible_output_ok', undefined, locale)] : []),
'',
t('ai.routing.usage_heading', undefined, locale),
t('ai.routing.usage_send_when', undefined, locale),
Expand Down
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,14 @@ export const config = {
// ON. A per-bot codexRpcInput:true still force-enables; the dashboard toggle
// sets this global explicitly.
get codexRpcInputDefault(): boolean { return readGlobalConfig().dashboard?.codexRpcInput === true; },
// Live getter (like codexRpcInputDefault): re-reads the experimental global
// toggle that gates the "no visible output" anti-resend guidance in the botmux
// routing hints, so a Settings change takes effect on the next session without
// a daemon restart. Default OFF (absent ⇒ disabled): the guidance mainly helps
// when Claude Code (≥2.1.212) drives a non-Claude backend model that misreads a
// thinking-only nudge as a send failure; it is harmless but unnecessary for the
// common all-Claude setup, so operators opt in explicitly.
get noVisibleOutputHint(): boolean { return readGlobalConfig().dashboard?.noVisibleOutputHint === true; },
};

// allowedUsers is mutable — daemon resolves email prefixes to open_ids at startup
Expand Down
6 changes: 5 additions & 1 deletion src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,9 +835,13 @@ export function buildFollowUpContent(
if (!skipSessionId) parts.push(`<session_id>${xmlEscape(sessionId)}</session_id>`);
if (roleBlock) parts.push(roleBlock);
if (opts?.cliId !== 'mira') {
// Non-hermes CLIs get the anti-resend variant only when the experimental
// dashboard toggle is on (config.noVisibleOutputHint, default OFF); otherwise
// the reminder is byte-for-byte the pre-feature baseline. Live-read so a
// Settings flip applies to the next follow-up turn without a daemon restart.
const reminder = opts?.cliId === 'hermes'
? hermesFollowupReminder(opts?.locale)
: t('ai.followup.reminder', undefined, opts?.locale);
: t(config.noVisibleOutputHint ? 'ai.followup.reminder_no_resend' : 'ai.followup.reminder', undefined, opts?.locale);
parts.push(`<botmux_reminder>${reminder}</botmux_reminder>`);
}
if (whiteboardBlock) parts.push(whiteboardBlock);
Expand Down
3 changes: 3 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,8 @@ interface ResolvedDashboardSettings {
workerOnline: boolean;
lastError: { at: string; message: string; retryAt: string } | null;
};
/** Experimental anti-resend guidance in botmux routing hints. Default OFF. */
noVisibleOutputHint: boolean;
/** Machine-wide VC meeting listener kill-switch. Default ON. */
vcMeetingAgent: {
enabled: boolean;
Expand Down Expand Up @@ -1046,6 +1048,7 @@ function resolveDashboardSettings(): ResolvedDashboardSettings {
workerOnline: isCodexNotifierWorkerStateFresh(codexNotifierState),
lastError: codexNotifierState?.lastError ?? null,
},
noVisibleOutputHint: dashboard.noVisibleOutputHint === true, // default OFF; opt-in anti-resend guidance
vcMeetingAgent: {
enabled: global.vcMeetingAgent?.enabled !== false,
listenerBotAppId: global.vcMeetingAgent?.listenerBotAppId ?? null,
Expand Down
8 changes: 8 additions & 0 deletions src/dashboard/settings-write-applier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface ResolvedDashboardSettingsView {
workerOnline?: boolean;
lastError?: { at: string; message: string; retryAt: string } | null;
};
noVisibleOutputHint: boolean;
vcMeetingAgent: {
enabled: boolean;
listenerBotAppId?: string | null;
Expand Down Expand Up @@ -199,6 +200,7 @@ export type ApplySettingsWriteError =
| 'codexNotifier_platform_unsupported'
| 'codexNotifier_hook_install_failed'
| 'codexNotifier_mixed_patch_unsupported'
| 'invalid_noVisibleOutputHint'
| 'invalid_repoPickerMode'
| 'invalid_remoteAccess'
| 'invalid_vcMeetingAgent'
Expand Down Expand Up @@ -360,6 +362,12 @@ export async function applySettingsWrite(
}
patch.codexRpcInput = obj.codexRpcInput;
}
if ('noVisibleOutputHint' in obj) {
if (typeof obj.noVisibleOutputHint !== 'boolean') {
return { ok: false, error: 'invalid_noVisibleOutputHint' };
}
patch.noVisibleOutputHint = obj.noVisibleOutputHint;
}

let codexNotifierPatch: import('../global-config.js').CodexNotifierGlobalConfig | undefined;
if ('codexNotifier' in obj) {
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,8 @@ const zh: DashboardMessages = {
'settings.codexNotifierPending': '当前有 {count} 条完成消息等待投递。',
'settings.codexNotifierWorkerPending': 'Hook 已安装,但通知 worker 尚未运行或心跳已过期。',
'settings.codexNotifierLastError': '最近一次投递失败:{error}',
'settings.noVisibleOutputHint': '「无可见输出」防重发提示',
'settings.noVisibleOutputHintHelp': '实验性,默认关闭。在 botmux 路由提示里加一段纠偏:告诉模型 botmux send 成功即已送达、本轮无可见文本地结束是正常的、看到「无可见输出请继续」是底层 CLI(Claude Code ≥2.1.212)的误判不要重发。主要用于 Claude Code 驱动非 Claude 后端模型时(易把 thinking-only nudge 误读为发送失败而重复回复);对纯 Claude 场景无害但通常无需开启。开关翻动下一个会话即生效,无需重启。',
'settings.sectionVcMeetingAgent': '会议监听',
'settings.vcMeetingAgent': '允许本机使用会议监听能力',
'settings.vcMeetingAgentHelp': '默认开启。控制本机 bot 是否接收新的会议监听事件。关闭后不再恢复监听;已进行中的会议通常自然结束。',
Expand Down Expand Up @@ -3286,6 +3288,8 @@ const en: DashboardMessages = {
'settings.codexNotifierPending': '{count} completion message(s) are waiting for delivery.',
'settings.codexNotifierWorkerPending': 'The Hook is installed, but the notification worker is not running or its heartbeat is stale.',
'settings.codexNotifierLastError': 'Most recent delivery failure: {error}',
'settings.noVisibleOutputHint': '"No visible output" anti-resend hint',
'settings.noVisibleOutputHintHelp': 'Experimental, off by default. Adds a note to the botmux routing hints telling the model that a successful `botmux send` is already delivered, that ending a turn with no visible terminal text is normal, and that a "no visible output, please continue" prompt is a false alarm from the underlying CLI (Claude Code ≥2.1.212) — do not resend. Mainly helps when Claude Code drives a non-Claude backend model (which tends to misread the thinking-only nudge as a send failure and reply repeatedly); harmless but usually unnecessary for an all-Claude setup. Takes effect on the next session, no restart needed.',
'settings.sectionVcMeetingAgent': 'Meeting Listener',
'settings.vcMeetingAgent': 'Allow meeting listener features on this machine',
'settings.vcMeetingAgentHelp': 'On by default. Controls whether bots on this host accept new meeting listener events. Off also skips listener restore; running meetings usually finish naturally.',
Expand Down
11 changes: 10 additions & 1 deletion src/dashboard/web/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface DashboardSettings {
workerOnline: boolean;
lastError: { at: string; message: string; retryAt: string } | null;
};
noVisibleOutputHint: boolean;
vcMeetingAgent: {
enabled: boolean;
listenerBotAppId: string | null;
Expand Down Expand Up @@ -158,6 +159,7 @@ function parseSettings(s: any): DashboardSettings {
? s.codexNotifier.lastError
: null,
},
noVisibleOutputHint: s?.noVisibleOutputHint === true,
vcMeetingAgent: {
enabled: s?.vcMeetingAgent?.enabled !== false,
listenerBotAppId: typeof s?.vcMeetingAgent?.listenerBotAppId === 'string' ? s.vcMeetingAgent.listenerBotAppId : null,
Expand Down Expand Up @@ -555,7 +557,7 @@ function SettingsBody(props: {
const autoUpdateDisabled = !canWrite || settings.localDevInstall || !settings.autoUpdateSupported;
const autoRestartDisabled = !canWrite || settings.maintenance.autoUpdate?.enabled !== true;

const saveBoolean = (key: 'publicReadOnly' | 'openTerminalInFeishu' | 'enableLocalCliOpen' | 'chatBotDiscovery' | 'codexRpcInput' | 'remoteAccess', value: boolean) => {
const saveBoolean = (key: 'publicReadOnly' | 'openTerminalInFeishu' | 'enableLocalCliOpen' | 'chatBotDiscovery' | 'codexRpcInput' | 'noVisibleOutputHint' | 'remoteAccess', value: boolean) => {
void props.onSave(key, { [key]: value }, s => ({ ...s, [key]: value }));
};
const saveHerdrTraexPlugin = (patch: Partial<Pick<DashboardSettings['herdrTraexPlugin'], 'enabled' | 'source' | 'ref'>>) => {
Expand Down Expand Up @@ -699,6 +701,13 @@ function SettingsBody(props: {
saving={savingKey === 'codexNotifier'}
onSave={saveCodexNotifier}
/>
<ToggleRow
title={tr('settings.noVisibleOutputHint')}
help={tr('settings.noVisibleOutputHintHelp')}
checked={settings.noVisibleOutputHint}
disabled={dis || savingKey === 'noVisibleOutputHint'}
onChange={value => saveBoolean('noVisibleOutputHint', value)}
/>
</SettingsBlock>
<SettingsBlock title={tr('settings.sectionWhiteboard')}>
<ToggleRow
Expand Down
8 changes: 8 additions & 0 deletions src/global-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ export interface DashboardGlobalConfig {
* see config.ts `codexRpcInputDefault`. A per-bot `codexRpcInput: true` still
* force-enables regardless of this global default. */
codexRpcInput?: boolean;
/** Experimental: inject the "no visible output" anti-resend guidance into the
* botmux routing hints. Counters Claude Code (≥2.1.212) thinking-only nudges
* that make a model resend after a silent `botmux send`-only turn. Default OFF
* (absent ⇒ off): mainly helps when Claude Code drives a non-Claude backend
* model; harmless but unnecessary otherwise. Read live — see config.ts
* `noVisibleOutputHint`. */
noVisibleOutputHint?: boolean;
}

/** Loosely validate a `voice` block: keep it only if it's an object with a
Expand Down Expand Up @@ -329,6 +336,7 @@ function readDashboard(raw: unknown): DashboardGlobalConfig | undefined {
const herdrTraexPlugin = readHerdrTraexPlugin(d.herdrTraexPlugin);
if (herdrTraexPlugin) out.herdrTraexPlugin = herdrTraexPlugin;
if (typeof d.codexRpcInput === 'boolean') out.codexRpcInput = d.codexRpcInput;
if (typeof d.noVisibleOutputHint === 'boolean') out.noVisibleOutputHint = d.noVisibleOutputHint;
return Object.keys(out).length > 0 ? out : undefined;
}

Expand Down
3 changes: 3 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ export const messages: Record<string, string> = {
// ─── AI system prompt (Claude Code: --append-system-prompt) ──────────────
'ai.routing.intro': 'You are connected to a Lark (Feishu) topic group. The user is reading on Lark and CANNOT see your terminal output.',
'ai.routing.must_use_botmux': 'To make the user see something, you MUST send it via the `botmux send` command. Terminal output does NOT reach the chat.',
'ai.routing.no_visible_output_ok': 'IMPORTANT: a successful `botmux send` (exit code 0 / returns `{"success":true,...}`) means the message was DELIVERED to the user. So ending a turn with NO visible terminal text is entirely normal and expected here — not a failure. If you later see a note like "your previous response had no visible output, please continue", that is a false alarm from the underlying CLI: do NOT resend. Only retry when `botmux send` itself failed (non-zero exit or printed a send error).',
'ai.routing.usage_heading': 'How to use it:',
'ai.routing.usage_send_when': '- Use `botmux send` for: key conclusions, plans (wait for user approval before acting), final results, progress updates. If no reply is needed, do not explain the silence and do not call `botmux send`; the final assistant message must be exactly `BOTMUX_NO_REPLY`.',
'ai.routing.usage_send_text': '- Plain text is fine: `botmux send "your message"`. Formatting is auto-handled.',
Expand Down Expand Up @@ -601,6 +602,7 @@ export const messages: Record<string, string> = {
'ai.shell.heredoc_example': "Correct multi-line example:\n```bash\nbotmux send <<'EOF'\nline 1\nline 2\nEOF\n```",
'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 <message_id>` (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': 'When to send: key conclusions, plans (wait for user approval before acting), final results, progress updates. A bare `print`/`echo` does NOT count as a reply. If no reply is needed, do not explain the silence and do not call `botmux send`; the final assistant message must be exactly `BOTMUX_NO_REPLY`.',
'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.',
'ai.shell.mention_gate': '@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention <open_id:name>` (name a person/bot; REQUIRED to communicate or collaborate with another bot) / `--mention-back` (@ the sender of the message you are replying to) / `--no-mention` (none). Choose by VALUE: substantive conclusion the other party should read/confirm/decide → --mention-back; pure record / low-priority / short ack → --no-mention; a contentless "got it" is better not sent. Do not default to --no-mention, and do not @ people for nothing.',

// ─── AI prompt blocks (session-manager) ──────────────────────────────────
Expand All @@ -610,6 +612,7 @@ export const messages: Record<string, string> = {
'ai.available_bots.hint_collapsed': 'To communicate or collaborate with another bot, first run `botmux bots list` to get its open_id, then --mention it. Without --mention the other bot receives nothing.',
'ai.available_bots.collapsed_line': 'There are {count} collaborator bots in this chat: {names}.',
'ai.followup.reminder': 'When a reply is needed, use `botmux send`; when none is needed, do not explain the silence and make the final exactly BOTMUX_NO_REPLY.',
'ai.followup.reminder_no_resend': 'When a reply is needed, use `botmux send`; when none is needed, do not explain the silence and make the final exactly BOTMUX_NO_REPLY. A successful send is already delivered; ending a turn with no visible text is normal, so do not resend on a "no visible output" nudge.',
'ai.cursor.sender_note': 'The sender tag is metadata identifying the current speaker — never copy its open_id or name (e.g. ou_xxx:Alice) into your botmux send body or opening line; to @ the triggerer use botmux send --mention-back.',
'ai.bridge.attachments_label': '[Attachments]',
'ai.bridge.mentions_label': '[@Mentions]',
Expand Down
Loading