Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
89c46ce
feat(vc-agent): 去全局监听 bot pin + 默认行为重命名 + 修离线 bot 名字
deepcoldy Aug 13, 2026
5917e7d
chore(vc): 会议监听交互修复的 live 部署基线(重构前 checkpoint)
deepcoldy Aug 15, 2026
3dd4f56
refactor(vc): Stage1 会议 agent 改为监听群普通 chat-scope 会话(核心切)
deepcoldy Aug 15, 2026
42ed450
refactor(vc): Stage2 会议 agent 去卡片/reaction/输出抑制,普通用户 turn 本色回复
deepcoldy Aug 15, 2026
df40762
refactor(vc): Stage3 会议 agent 去 relay/fork/resume/trigger 守卫
deepcoldy Aug 15, 2026
fc6a79c
test(vc): Stage4 锁定启动恢复 Plan B 不变量(已 ledger 驱动,无需改代码)
deepcoldy Aug 15, 2026
3a93e9c
test(vc): Stage5 锁定 durable 迁移 Plan B 不变量(Stage1 key 统一已覆盖)
deepcoldy Aug 15, 2026
06a435b
test(vc): Stage6 锁定会中文字/语音发言 Plan B 保留(marker 保留即自然保留)
deepcoldy Aug 15, 2026
60798c8
fix(vc): 会中发言跨 idle 间隙授权——live origin 缺失时回退持久化投递 receipt
deepcoldy Aug 15, 2026
cb3873f
fix(vc): 会中发言与 responseMode 解耦——silent 只管监听群 auto-post,不挡会中输出
deepcoldy Aug 15, 2026
47cc48c
fix(vc): 会中发言 idle 后 CLI 从持久化账本回取投递身份(补齐 idle-gap 另半边)
deepcoldy Aug 15, 2026
8ca1aff
fix(vc): 会中发言动作门接受 completed 投递(idle-gap 第三关)
deepcoldy Aug 15, 2026
5a697d4
fix(vc): 会中发言第5关+投递响应性+主持指令响应+语音默认放行
deepcoldy Aug 15, 2026
9b35954
feat(dashboard): 会议设置页新增 per-bot 会中输出策略编辑
deepcoldy Aug 15, 2026
f603d2f
fix(vc): 会中发言回退日志区分两种触发场景
deepcoldy Aug 16, 2026
9add213
feat(vc): 会议角色预设产品化——全局共享目录+入会身份/实时语音默认+启动权限体检
deepcoldy Aug 18, 2026
e91ae8d
Merge remote-tracking branch 'origin/master' into vc-plan-b
deepcoldy Aug 18, 2026
f901a7d
feat(dashboard): 会议开关表加搜索框 + 能力警告收成单个⚠
deepcoldy Aug 18, 2026
cb5fac6
fix(vc): 修默认开翻转的两处"关不掉/复活"——normalizer保留enabled:false + seeded fallba…
deepcoldy Aug 18, 2026
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
7 changes: 7 additions & 0 deletions src/adapters/cli/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export function createRelayAdapter(pathOverride?: string): CliAdapter {
const dataDir = deriveRelayDataDir();
return createClaudeFamilyAdapter({
id: 'relay',
// Relay 是 Claude Code 的 fork,落盘 transcript 与 Claude Code 逐字同构:
// per-project JSONL 里带权威回合终态(最终 assistant `stop_reason:end_turn`
// 非工具停 + system 回合标记),与 claude-code 依赖的边界完全一致(在真实
// ~/.relay/projects/*.jsonl 上核实)。因此它能诚实兑现同一份 turn-terminal
// 契约,opt-in 让 relay 系 bot 可当会议 agent(产出受管纪要/会中发言,靠这个
// 权威边界终结投递回合、去重防同一投递外部副作用执行两次)。
reliableTurnTerminal: true,
// Relay's SuperRelay apiKey lives in `<configDir>/byted-cloud-auth.json`
// (NOT under bytedcli), and bytedcli SSO state lives under
// ~/.local/share/bytedcli — keep BOTH real + writable inside the file sandbox
Expand Down
6 changes: 6 additions & 0 deletions src/adapters/cli/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export function createSeedAdapter(pathOverride?: string): CliAdapter {
const dataDir = deriveSeedDataDir(bin);
return createClaudeFamilyAdapter({
id: 'seed',
// Seed 是 Relay 的旧发行名,同一个 Claude Code fork 血统,落盘 transcript
// 与 Claude Code 同构(per-project JSONL 带权威 `stop_reason:end_turn` 终态 +
// system 回合标记)。turn-terminal 契约随 CLII 二进制血统一致(已在 relay 3.x
// 真实 transcript 上核实,seed 共享同一 JSONL 格式),opt-in 让 seed 系 bot 也
// 能当会议 agent。
reliableTurnTerminal: true,
// Seed's SuperRelay apiKey lives in `<dataDir>/byted-cloud-auth.json` (NOT
// under bytedcli); keep it + the bytedcli SSO dir real + writable in the file
// sandbox so token refresh / login persist (a path not bound real wouldn't
Expand Down
33 changes: 31 additions & 2 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,11 @@ function normalizeVcMeetingAgentConfig(raw: unknown): VcMeetingAgentConfig | und
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const entry = raw as Record<string, unknown>;
const out: VcMeetingAgentConfig = {};
// VC 默认对每个连着飞书的 bot 生效(vcMeetingAgentConfigActive:enabled!==false),
// 所以 enabled:false 是**显式退出**,必须原样保留——只留 true 会把 false round-trip
// 成 undefined(=默认开),让"关掉这个 bot 的会议"重载即失效。
if (entry.enabled === true) out.enabled = true;
else if (entry.enabled === false) out.enabled = false;
const notificationChatId = normalizeNonEmptyString(entry.notificationChatId);
const listenerChatId = normalizeNonEmptyString(entry.listenerChatId);
const attentionTargetOpenId = normalizeNonEmptyString(entry.attentionTargetOpenId);
Expand Down Expand Up @@ -350,6 +354,18 @@ function normalizeVcMeetingConsumerConfig(raw: unknown): VcMeetingConsumerConfig
if (minBatchChars !== undefined) out.minBatchChars = minBatchChars;
if (minBatchItems !== undefined) out.minBatchItems = minBatchItems;
if (maxInjectIntervalMs !== undefined) out.maxInjectIntervalMs = maxInjectIntervalMs;
if (entry.textOutputPolicy === 'allow' || entry.textOutputPolicy === 'approval' || entry.textOutputPolicy === 'deny') {
out.textOutputPolicy = entry.textOutputPolicy;
}
if (entry.voiceOutputPolicy === 'allow' || entry.voiceOutputPolicy === 'approval' || entry.voiceOutputPolicy === 'deny') {
out.voiceOutputPolicy = entry.voiceOutputPolicy;
}

// per-bot 从共享目录挑的默认角色。与 consumerProfiles 无关(bot 继承目录、不拥有
// 预设),故无条件归一化,不触发 legacy "consumerProfiles required" resolver 门。
// 空串/空白 = 「跟随全局默认」,等同没配。
const catalogDefaultConsumerId = normalizeNonEmptyString(entry.catalogDefaultConsumerId);
if (catalogDefaultConsumerId) out.catalogDefaultConsumerId = catalogDefaultConsumerId;

if (Object.prototype.hasOwnProperty.call(entry, 'defaultProfileBootstrap')) {
const marker = entry.defaultProfileBootstrap;
Expand Down Expand Up @@ -546,7 +562,7 @@ function normalizeVcMeetingListenerDelivery(
return { placement: entry.placement as 'auto' | 'chat' | 'topic' };
}

function normalizeVcMeetingConsumerProfiles(raw: unknown): VcMeetingConsumerProfileConfig[] {
export function normalizeVcMeetingConsumerProfiles(raw: unknown): VcMeetingConsumerProfileConfig[] {
const path = 'vcMeetingAgent.meetingConsumer.consumerProfiles';
if (!Array.isArray(raw)) strictConfigError(path, 'must be an array');
return raw.map((value, index) => {
Expand Down Expand Up @@ -783,7 +799,10 @@ function normalizeVcMeetingRealtimeVoiceConfig(raw: unknown): VcMeetingRealtimeV
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const entry = raw as Record<string, unknown>;
const out: VcMeetingRealtimeVoiceConfig = {};
// 实时语音默认开启(vcMeetingRealtimeVoiceEnabled:enabled!==false),enabled:false 是
// 显式关闭,必须保留——只留 true 会让"关掉实时语音"round-trip 成 undefined(=默认开)。
if (entry.enabled === true) out.enabled = true;
else if (entry.enabled === false) out.enabled = false;
const sampleRate = normalizePositiveInt(entry.sampleRate);
const channels = normalizePositiveInt(entry.channels);
const frameMs = normalizePositiveInt(entry.frameMs);
Expand Down Expand Up @@ -1941,8 +1960,18 @@ export function vcMeetingAgentConfigActive(
cfg: Pick<BotConfig, 'apiOnly' | 'vcMeetingAgent'> | undefined,
): VcMeetingAgentConfig | undefined {
if (!cfg) return undefined;
// apiOnly (core-only) bots have no Feishu transport — a VC listener drives
// `lark-cli vc +meeting-events --as bot`, which breaks the zero-Feishu-network
// contract. This fail-close is the load-bearing invariant and must stay first.
if (cfg.apiOnly === true) return undefined;
return cfg.vcMeetingAgent?.enabled === true ? cfg.vcMeetingAgent : undefined;
// Bot-agnostic join (2026-08): any invited bot should join, so VC is active by
// default for every Feishu-connected bot. `vcMeetingAgent.enabled: false` is
// the explicit per-bot opt-out; unset/absent now means active. A bot with no
// vcMeetingAgent block at all gets an empty effective config so downstream
// reads (listenerChatId auto-create, profile provision, consumer defaults)
// work off their own fallbacks. Fleet-wide off remains the global switch.
if (cfg.vcMeetingAgent?.enabled === false) return undefined;
return cfg.vcMeetingAgent ?? {};
}

export function registerBot(cfg: BotConfig): BotState {
Expand Down
24 changes: 21 additions & 3 deletions src/cli/vc-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { loadBotConfigs, registerBot } from '../bot-registry.js';
import { config } from '../config.js';
import { listVcMeetingRuntimeSessions } from '../services/vc-meeting-runtime-store.js';
import { latestVcMeetingDeliveryForSession } from '../services/vc-meeting-delivery-store.js';
import { findOnlineDaemon } from '../utils/daemon-discovery.js';
import { resolveSessionContext } from '../core/session-marker.js';
import { fetchDaemonIpc } from '../core/daemon-ipc-auth.js';
Expand Down Expand Up @@ -299,6 +300,23 @@ async function cmdRequestOutput(args: string[]): Promise<void> {
relayDir,
process.env.BOTMUX_ORIGIN_CHANNEL_ID,
)?.capability;
// The live process-tree marker only carries turnId/dispatchAttempt WHILE a
// delivery turn is executing; both are cleared when the turn goes idle. A
// meeting agent typically decides to speak AFTER it has finished processing
// the transcript (turn already terminal), so liveOrigin is usually empty here.
// Recover the delivery identity of the turn it just processed from the durable
// ledger (keyed only by sessionId) so the daemon can re-authorize in-meeting
// output against the completed receipt. The live marker still takes precedence
// when present (an in-flight turn).
let originTurnId = liveOrigin?.turnId;
let originDispatchAttempt = liveOrigin?.dispatchAttempt;
if ((originTurnId === undefined || originDispatchAttempt === undefined) && receiverSessionId) {
const durable = latestVcMeetingDeliveryForSession(config.session.dataDir, receiverSessionId);
if (durable) {
originTurnId ??= durable.receipt.deliveryKey;
originDispatchAttempt ??= durable.receipt.dispatchAttempt;
}
}
const discoveredReceiver = receiverAppId ? findOnlineDaemon(receiverAppId) : null;
const receiverPort = Number.isSafeInteger(receiverPortRaw) && receiverPortRaw > 0
? receiverPortRaw
Expand All @@ -315,9 +333,9 @@ async function cmdRequestOutput(args: string[]): Promise<void> {
content,
...(reason ? { reason } : {}),
...(fallbackText ? { fallbackText } : {}),
...(liveOrigin?.turnId ? { originTurnId: liveOrigin.turnId } : {}),
...(liveOrigin?.dispatchAttempt !== undefined
? { originDispatchAttempt: liveOrigin.dispatchAttempt }
...(originTurnId ? { originTurnId } : {}),
...(originDispatchAttempt !== undefined
? { originDispatchAttempt }
: {}),
...(originCapability ? { originCapability } : {}),
}),
Expand Down
4 changes: 3 additions & 1 deletion src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1234,7 +1234,9 @@ export async function handleCardCommand(
}
ds.streamingCardForced = true;
const posted = await postFreshStreamingCard(ds, deps.sessionReply);
if (!posted) await reply(t('cmd.card.not_ready', undefined, loc));
if (!posted) {
await reply(t('cmd.card.not_ready', undefined, loc));
}
return;
}

Expand Down
16 changes: 4 additions & 12 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2720,18 +2720,10 @@ ipcRoute('POST', '/api/trigger', async (req, res) => {
error: `request target botId ${valid.request.target.botId} does not match daemon ${cachedLarkAppId}`,
});
}
if (valid.request.target.kind === 'turn' && valid.request.target.sessionId) {
const receiverTarget = [...activeSessions.values()].find(
(candidate) => candidate.session.sessionId === valid.request.target.sessionId,
);
if (receiverTarget?.session.vcMeetingReceiver) {
return jsonRes(res, 403, {
ok: false,
errorCode: 'managed_receiver_requires_delivery_endpoint',
error: 'dedicated meeting receiver sessions accept only fenced delivery or explicit IM routing',
});
}
}
// Plan B: a VC meeting agent is an ordinary chat-scope session, so the generic
// trigger endpoint may address it like any session (botmux send / dashboard).
// Meeting transcript deliveries still flow through their own fenced delivery
// path — this endpoint only ever carries ordinary user-initiated turns.
try {
if (valid.request.target.kind === 'workflow') {
return jsonRes(res, 410, {
Expand Down
8 changes: 7 additions & 1 deletion src/core/dashboard-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,15 @@ export function composeRowFromActive(ds: DaemonSession, opts?: { fresh?: boolean
// For every other session, process residency is authoritative: suspension
// clears ds.worker but intentionally preserves the logical active session.
// Never let a stale pre-suspend status make it look resident after hydrate.
// No screen status yet + worker init complete = the CLI is executing its
// first turn (screen updates are suppressed until the first idle prompt).
// Long first turns — e.g. meeting agents fed a transcript delivery right at
// spawn — previously sat in「启动中」for minutes; project them as working.
status: ds.session.queued
? 'idle'
: (!ds.worker || ds.worker.killed ? 'dormant' : (ds.lastScreenStatus ?? 'starting')),
: (!ds.worker || ds.worker.killed
? 'dormant'
: (ds.lastScreenStatus ?? (ds.workerReady === true ? 'working' : 'starting'))),
adopt: !!ds.adoptedFrom,
spawnedAt: sessionCreatedAtMs(ds.session) || ds.spawnedAt,
lastMessageAt: sessionLastActivityAtMs(ds.session) || ds.lastMessageAt,
Expand Down
9 changes: 5 additions & 4 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,11 @@ export async function foldableChatSessionAppIds(input: {
|| session.scope !== 'chat'
|| !session.larkAppId
|| session.chatId !== input.targetChatId
// These chat-scoped sessions deliberately use isolated routing keys and
// can never be reached through the ordinary (chatId, appId) slot.
|| session.deferredScheduleRun
|| session.vcMeetingReceiver) continue;
// A deferred scheduled run deliberately uses an isolated routing key and
// can never be reached through the ordinary (chatId, appId) slot. (A VC
// meeting agent is now an ordinary chat-scope session — Plan B — so it IS
// foldable and is intentionally NOT excluded here.)
|| session.deferredScheduleRun) continue;
candidates.add(session.larkAppId);
}

Expand Down
22 changes: 6 additions & 16 deletions src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2508,9 +2508,6 @@ export async function ensureTerminalWorkerPort(ds: DaemonSession): Promise<numbe
* a fresh thread session); refuse rather than clobber
* - 'adopt_unsupported' — adopt sessions are torn down by /close and have
* no resume semantics
* - 'vc_receiver_managed' — dedicated meeting receivers are reconstructed
* through the meeting membership/hub lifecycle; a
* manual resume could resurrect a stale member epoch
* - 'deferred_unmaterialized' — a silent fresh-topic run finished without
* publishing, so it has no conversation to resume
* - 'resume_cancelled' — a concurrent close won while resume was committing
Expand All @@ -2519,22 +2516,16 @@ export async function resumeSession(
sessionId: string,
activeSessions: Map<string, DaemonSession>,
): Promise<{ ok: true; ds: DaemonSession }
| { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'vc_receiver_managed' | 'deferred_unmaterialized' | 'resume_cancelled'; activeSessionId?: string }> {
| { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'deferred_unmaterialized' | 'resume_cancelled'; activeSessionId?: string }> {
let session = sessionStore.getSession(sessionId);
if (!session) return { ok: false, error: 'not_found' };
if (session.status !== 'closed') return { ok: false, error: 'not_closed' };

// A dedicated VC receiver is not an ordinary chat conversation. Its
// identity is fenced by (meeting, member, epoch) and its active-map slot is
// reconstructed by the meeting hub/membership lifecycle. Reactivating a
// closed receiver through the generic dashboard/card/CLI path would bypass
// that ownership check, potentially revive a stale epoch, and (before the
// dedicated-key fix) collapse it into the listener chat's ordinary slot.
// Keep it closed and let the authoritative meeting lifecycle create or
// recover the correct receiver binding.
if (session.vcMeetingReceiver) {
return { ok: false, error: 'vc_receiver_managed' };
}
// Plan B: a VC meeting agent is an ordinary chat-scope session, so a closed one
// resumes into its normal (chatId, appId) slot like any chat session — the old
// `vc_receiver_managed` refusal is gone. (Reviving into the ordinary slot is now
// the intended behavior, not a hazard; the meeting lifecycle re-stamps the
// delivery binding via ensureVcMeetingReceiverSession when the meeting is live.)

// Auto-closed invisible schedule runs are audit records, not conversations.
// Without a materialized binding, resuming one would wake a virtual chat-
Expand Down Expand Up @@ -2563,7 +2554,6 @@ export async function resumeSession(
const latest = sessionStore.getSession(sessionId);
if (!latest) return { ok: false as const, error: 'not_found' as const };
if (latest.status !== 'closed') return { ok: false as const, error: 'not_closed' as const };
if (latest.vcMeetingReceiver) return { ok: false as const, error: 'vc_receiver_managed' as const };
if (latest.deferredScheduleRun
&& !readDeferredTopicBinding(config.session.dataDir, latest.sessionId)) {
return { ok: false as const, error: 'deferred_unmaterialized' as const };
Expand Down
14 changes: 6 additions & 8 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,15 +572,13 @@ export function storedSessionAnchorId(
?? (session.scope === 'chat' ? session.chatId : session.rootMessageId);
}

/** Storage key for the daemon-owned activeSessions map. A VC receiver is a
* dedicated conversation even though its visible output route is a chat, so
* key it by its immutable session id instead of collapsing it into the normal
* `(chatId, appId)` chat-scope slot. */
/** Storage key for the daemon-owned activeSessions map. A VC meeting agent is
* now an ordinary chat-scope session in its listener group (Plan B): it is keyed
* by the normal `(chatId, appId)` slot so plain IM and meeting transcripts both
* fold into the one session. The `vcMeetingReceiver` marker is retained as pure
* delivery/meeting-output metadata and no longer affects routing. */
export function activeSessionKey(ds: DaemonSession): string {
const anchor = ds.session.vcMeetingReceiver
? `vc-receiver:${ds.session.sessionId}`
: sessionAnchorId(ds);
return sessionKey(anchor, ds.larkAppId);
return sessionKey(sessionAnchorId(ds), ds.larkAppId);
}

/** A session whose only IM surface is a Feishu document comment thread.
Expand Down
Loading
Loading