diff --git a/src/bot-registry.ts b/src/bot-registry.ts index e0802227..4532bffa 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -589,6 +589,13 @@ export interface BotState { const bots = new Map(); +export function __testOnly_resetBotRegistry(): void { + bots.clear(); + loadedConfigPath = undefined; + oncallChatCache = null; + brandLabelCache = null; +} + // Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without // a hard import cycle between `i18n` and `bot-registry`. setBotLookup((id) => bots.get(id)); diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index f1efb17e..7896e359 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -23,7 +23,7 @@ import { config } from '../config.js'; import { computeSandboxDiff, applySandboxDiff } from '../services/sandbox-land.js'; import { buildSafeInsightConversation, buildSafeInsightOverview, buildSafeInsightReport, buildSafeInsightTurnDetail } from '../services/insight/report.js'; import type { InsightConversationRole, InsightDetail, InsightSeverity, SafeSpanTag } from '../services/insight/types.js'; -import { readRawConfig, findEntryIndex, requireConfigPath } from '../services/config-store.js'; +import { readRawConfig, findEntryIndex, requireConfigPath, rmwBotEntry } from '../services/config-store.js'; import { setDefaultLocale, localeForBot, t } from '../i18n/index.js'; import { isLocale, type Locale } from '../i18n/types.js'; import { readGlobalConfig } from '../global-config.js'; @@ -56,6 +56,7 @@ import { triggerSessionTurn } from './trigger-session.js'; import { triggerWorkflowFromEnvelope } from '../workflows/trigger-from-envelope.js'; import type { TriggerInput, TriggerResult } from '../workflows/trigger-run.js'; import { validateTriggerRequest } from '../services/trigger-types.js'; +import { resolveCliSelection, selectionKeyForBot } from '../setup/cli-selection.js'; // Workflow runner is wired by the daemon (it owns the heavy triggerWorkflowRun // deps). Until set, workflow-targeted triggers report not-implemented. @@ -1192,6 +1193,17 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { const grantPrefs = grantPrefsStore.getBotGrantPrefs(cachedLarkAppId); let p2pMode: 'thread' | 'chat' = 'thread'; try { if (getBot(cachedLarkAppId).config.p2pMode === 'chat') p2pMode = 'chat'; } catch { /* default thread */ } + let cliId = ''; + let wrapperCli: string | null = null; + let model: string | null = null; + let agentSelectionKey = ''; + try { + const cfg = getBot(cachedLarkAppId).config; + cliId = cfg.cliId; + wrapperCli = typeof cfg.wrapperCli === 'string' && cfg.wrapperCli.trim() ? cfg.wrapperCli : null; + model = typeof cfg.model === 'string' && cfg.model.trim() ? cfg.model : null; + agentSelectionKey = selectionKeyForBot(cliId, wrapperCli ?? undefined); + } catch { /* no registered bot */ } let maxLiveWorkers: number | null = null; try { const m = getBot(cachedLarkAppId).config.maxLiveWorkers; @@ -1222,6 +1234,10 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { jsonRes(res, 200, { larkAppId: cachedLarkAppId, botName: getBotName(), + cliId, + wrapperCli, + model, + agentSelectionKey, defaultOncall: defaultOncall ?? { enabled: false, workingDir: '', since: 0 }, defaultWorkingDir, autoboundChatCount: autoboundChats.length, @@ -1364,6 +1380,51 @@ ipcRoute('PUT', '/api/bot-brand-label', async (req, res) => { jsonRes(res, 200, { ok: true, brandLabel: r.brandLabel }); }); +// Per-bot agent launch settings. Body `{ cliId, model }` where `cliId` is the +// dashboard selection key (plain adapter id or a wrapper option such as +// `ttadk-x-codex`). Changes affect the next spawned CLI session. +ipcRoute('PUT', '/api/bot-agent', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let body: { cliId?: unknown; model?: unknown }; + try { body = await readJsonBody<{ cliId?: unknown; model?: unknown }>(req); } + catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + + const key = typeof body.cliId === 'string' && body.cliId.trim() ? body.cliId.trim() : ''; + if (!key) return jsonRes(res, 400, { ok: false, error: 'cli_required' }); + let selected: ReturnType; + try { + selected = resolveCliSelection(key); + } catch (err: any) { + return jsonRes(res, 400, { ok: false, error: 'invalid_cli', message: err?.message ?? String(err) }); + } + const model = typeof body.model === 'string' ? body.model.trim() : ''; + + const r = await rmwBotEntry(cachedLarkAppId, (entry) => { + entry.cliId = selected.cliId; + if (selected.wrapperCli) entry.wrapperCli = selected.wrapperCli; + else delete entry.wrapperCli; + if (model) entry.model = model; + else delete entry.model; + return { write: true, result: null }; + }); + if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason }); + + const bot = getBot(cachedLarkAppId); + bot.config.cliId = selected.cliId; + if (selected.wrapperCli) bot.config.wrapperCli = selected.wrapperCli; + else bot.config.wrapperCli = undefined; + bot.config.model = model || undefined; + + const selectionKey = selectionKeyForBot(selected.cliId, selected.wrapperCli); + jsonRes(res, 200, { + ok: true, + cliId: selected.cliId, + wrapperCli: selected.wrapperCli ?? null, + model: model || null, + selectionKey, + }); +}); + // Per-bot 私聊单聊模式 p2pMode。Body `{ p2pMode: 'chat' | 'thread' }`: // • 'chat' → 私聊走扁平连续 chat-scope 会话 // • 'thread'(默认) → 清回每条 DM 独立 thread-scope 会话 diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 57fb48a9..fedb2ea2 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -190,6 +190,39 @@ function sessionCliId(ds: DaemonSession, botCfg: { cliId: CliId }): CliId { return ds.session.cliId ?? botCfg.cliId; } +function sessionAgentConfig( + ds: DaemonSession, + botCfg: { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string }, +): { cliId: CliId; cliPathOverride?: string; wrapperCli?: string; model?: string } { + // Freeze the agent launch config (cli / cliPath / wrapper / model) onto the + // session the first time a worker forks, so later bot-level edits never + // retroactively change a live session — same discipline as `sandbox`. + // + // Gated on `agentFrozen`, NOT on `resume`: a session created before these + // fields existed has `cliId` stamped historically but no frozen wrapper/model, + // yet it was launching off the live bot config — so its first post-upgrade + // resume must back-fill the still-missing fields from botCfg to keep launching + // identically (e.g. a `ttadk codex` wrapper bot must not silently drop to bare + // `codex`, losing its gateway). `??` preserves whatever is already frozen and + // only fills the gaps; the marker disambiguates "legacy, never frozen" from + // "frozen as no-wrapper", so a genuinely wrapper-less session never inherits a + // wrapper the bot gains later. + if (!ds.session.agentFrozen) { + ds.session.cliId = ds.session.cliId ?? botCfg.cliId; + ds.session.cliPathOverride = ds.session.cliPathOverride ?? botCfg.cliPathOverride; + ds.session.wrapperCli = ds.session.wrapperCli ?? botCfg.wrapperCli; + ds.session.model = ds.session.model ?? botCfg.model; + ds.session.agentFrozen = true; + sessionStore.updateSession(ds.session); + } + return { + cliId: ds.session.cliId ?? botCfg.cliId, + cliPathOverride: ds.session.cliPathOverride, + wrapperCli: ds.session.wrapperCli, + model: ds.session.model, + }; +} + function loadKnownBotOpenIdsForApp(larkAppId: string): Set { const dataDir = config.session.dataDir; let crossRef: Record = {}; @@ -1595,13 +1628,14 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v // real bmx-; without this, bmx-diag- + its .ansi file would leak. if (!ds.initConfig?.adoptMode && !ds.adoptedFrom) reclaimParkedCrashDiagnostic(ds); - ensureCliEnv(botCfg.cliId, botCfg.cliPathOverride); + const agentCfg = sessionAgentConfig(ds, botCfg); + ensureCliEnv(agentCfg.cliId, agentCfg.cliPathOverride); // Claude Code blocks on the interactive folder-trust dialog the first time // it runs in an untrusted workingDir; pre-accept it so the spawn doesn't hang. // Seed CLI (Claude Code fork) has the same dialog — drive both off the // adapter's claude-family fields, writing to each variant's own .claude.json // (`~/.claude.json` for claude, `.claude-runtime/.claude.json` for seed). - const familyAdapter = createCliAdapterSync(botCfg.cliId, botCfg.cliPathOverride); + const familyAdapter = createCliAdapterSync(agentCfg.cliId, agentCfg.cliPathOverride); if (familyAdapter.claudeStateJsonPath) ensureClaudeFolderTrust(cwd, familyAdapter.claudeStateJsonPath); let skillPluginDir: string | undefined; @@ -1609,7 +1643,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v if (!resume && prompt.trim().length > 0) { const preparedSkills = prepareSessionSkillPrompt({ sessionId: ds.session.sessionId, - cliId: botCfg.cliId, + cliId: agentCfg.cliId, workingDir: cwd, prompt, botPolicy: botCfg.skills, @@ -1683,11 +1717,11 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v chatId: ds.chatId, rootMessageId: sessionAnchorId(ds), workingDir: cwd, - cliId: botCfg.cliId, - cliPathOverride: botCfg.cliPathOverride, - wrapperCli: botCfg.wrapperCli, + cliId: agentCfg.cliId, + cliPathOverride: agentCfg.cliPathOverride, + wrapperCli: agentCfg.wrapperCli, launchShell: botCfg.launchShell, - model: botCfg.model, + model: agentCfg.model, disableCliBypass: botCfg.disableCliBypass === true, // Startup commands run on every fresh spawn (incl. resume) so session-only // settings like `/effort ultracode` are re-established. Adopt sessions are @@ -1724,8 +1758,8 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v // even after the session is closed. Do this before installing worker handlers: // a fast worker can emit `ready` immediately after init, and card rendering // must see the session-level CLI identity rather than the bot default. - if (ds.session.cliId !== botCfg.cliId) { - ds.session.cliId = botCfg.cliId; + if (ds.session.cliId !== agentCfg.cliId) { + ds.session.cliId = agentCfg.cliId; sessionStore.updateSession(ds.session); } diff --git a/src/dashboard.ts b/src/dashboard.ts index d4f37e43..d4f33817 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -549,8 +549,30 @@ function configuredCliIds(): Map { } } -function withConfiguredCliId(bot: T, ids: Map): T & { cliId?: string } { - return bot.cliId ? bot : { ...bot, cliId: ids.get(bot.larkAppId) }; +function configuredBotAgentFields(): Map { + try { + return new Map(loadBotConfigs().map(b => [b.larkAppId, { + cliId: b.cliId, + wrapperCli: b.wrapperCli, + model: b.model, + }])); + } catch { + return new Map(); + } +} + +function withConfiguredCliId( + bot: T, + ids: Map | Map, +): T & { cliId?: string; wrapperCli?: string; model?: string } { + const raw = ids.get(bot.larkAppId); + const fallback = typeof raw === 'string' ? { cliId: raw } : raw; + return { + ...bot, + cliId: bot.cliId || fallback?.cliId, + wrapperCli: bot.wrapperCli || fallback?.wrapperCli, + model: bot.model || fallback?.model, + }; } function liveBots(): { larkAppId: string; botName: string; cliId?: string }[] { @@ -1974,8 +1996,8 @@ const server = createServer(async (req, res) => { // PUT /api/bots/:appId/default-oncall — proxy to that bot's daemon if (req.method === 'GET' && url.pathname === '/api/bots') { - const cliIds = configuredCliIds(); - const onlineBots = [...registry.list()].map(b => withConfiguredCliId(b, cliIds)).sort((a, b) => a.botIndex - b.botIndex); + const agentFields = configuredBotAgentFields(); + const onlineBots = [...registry.list()].map(b => withConfiguredCliId(b, agentFields)).sort((a, b) => a.botIndex - b.botIndex); const out = await Promise.all(onlineBots.map(async d => { try { const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/bot-default-oncall`); @@ -1983,7 +2005,13 @@ const server = createServer(async (req, res) => { return botDefaultsPayload(d, undefined, `http_${r.status}`); } const j = await r.json() as any; - return botDefaultsPayload({ ...d, botName: d.botName ?? j.botName }, j); + return botDefaultsPayload({ + ...d, + botName: d.botName ?? j.botName, + cliId: j.cliId || d.cliId, + wrapperCli: j.wrapperCli || d.wrapperCli, + model: j.model || d.model, + }, j); } catch (e: any) { return botDefaultsPayload(d, undefined, e?.message ?? String(e)); } @@ -2026,6 +2054,24 @@ const server = createServer(async (req, res) => { return; } + // PUT /api/bots/:appId/agent — proxy to that bot's daemon. Body + // `{ cliId, model }`; cliId is the dashboard selection key. + let mBotAgent: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotAgent = url.pathname.match(/^\/api\/bots\/([^/]+)\/agent$/))) { + const appId = decodeURIComponent(mBotAgent[1]); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + const upstream = await proxyToDaemon(appId, `/api/bot-agent`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // PUT /api/bots/:appId/skills — proxy to that bot's daemon. Body accepts // `{ action:'attach'|'detach', name }` or `{ action:'set', policy|null }`. let mBotSkills: RegExpMatchArray | null; diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index d6d65b7e..1367cf4f 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -5,6 +5,8 @@ export interface DashboardBotDescriptor { botName?: string | null; botAvatarUrl?: string; cliId?: string; + wrapperCli?: string; + model?: string; } export function botSummaryPayload(bot: DashboardBotDescriptor) { @@ -21,6 +23,8 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: larkAppId: bot.larkAppId, botName: bot.botName, ...(bot.cliId ? { cliId: bot.cliId } : {}), + ...(bot.wrapperCli ? { wrapperCli: bot.wrapperCli } : {}), + ...(bot.model ? { model: bot.model } : {}), online: true, }; if (error) return { ...base, error }; diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index d9ad899b..2835d042 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -8,6 +8,20 @@ import { botAvatarHtml, escapeHtml, loadNameMaps, loadingHtml, t } from './ui.js let cache: { bots: any[] } = { bots: [] }; let loadError: string | null = null; +type CliOption = { + id: string; + label: string; + gateway?: 'ttadk'; + acceptsModel?: boolean; +}; +let cliOptions: CliOption[] = [ + { id: 'claude-code', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'traex', label: 'traex' }, +]; +let cliOptionsLoaded = false; +let ttadkModelDefault = 'glm-5.1'; +let ttadkModelSuggestions: string[] = []; // master-detail:左侧员工名册选中谁,右侧就渲染谁的档案 let selectedAppId: string | null = null; @@ -40,6 +54,79 @@ function cliIdOf(appId: string): string { return best?.cliId ?? ''; } +async function loadCliOptions(): Promise { + if (cliOptionsLoaded) return; + cliOptionsLoaded = true; + try { + const r = await fetch('/api/cli-options'); + const body = await r.json().catch(() => ({})); + if (r.ok && Array.isArray(body?.options)) { + cliOptions = body.options.filter((o: any): o is CliOption => + o && typeof o.id === 'string' && typeof o.label === 'string', + ); + if (typeof body.ttadkModelDefault === 'string' && body.ttadkModelDefault.trim()) { + ttadkModelDefault = body.ttadkModelDefault.trim(); + } + if (Array.isArray(body.ttadkModelSuggestions)) { + ttadkModelSuggestions = body.ttadkModelSuggestions.filter((s: unknown): s is string => typeof s === 'string'); + } + } + } catch { + // Keep the static fallback; saving still works for plain claude-code. + } +} + +function agentSelectionKey(bot: any, sessionFallback: string): string { + const explicit = typeof bot?.agentSelectionKey === 'string' && bot.agentSelectionKey ? bot.agentSelectionKey : ''; + if (explicit) return explicit; + const cli = displayCliId(bot, sessionFallback); + return cli || 'claude-code'; +} + +function selectedCliOption(key: string): CliOption | undefined { + return cliOptions.find(o => o.id === key); +} + +function modelSuggestionsForOption(opt: CliOption | undefined): string[] { + if (opt?.gateway === 'ttadk' && opt.acceptsModel !== false) return ttadkModelSuggestions; + return []; +} + +export function renderBotAgentSection(b: any, sessionFallback: string): string { + const key = agentSelectionKey(b, sessionFallback); + const optHtml = cliOptions + .map(o => ``) + .join(''); + const model = typeof b?.model === 'string' ? b.model : ''; + const suggestions = modelSuggestionsForOption(selectedCliOption(key)); + const disabled = selectedCliOption(key)?.gateway === 'ttadk' && selectedCliOption(key)?.acceptsModel === false; + return `
+

${t('botDefaults.sectionAgent')}

+
+ +
+
+ + ${t('botDefaults.agentHelp')} +
+ + +
+
+
`; +} + function pageHtml(): string { return `
@@ -106,7 +193,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { refreshBtn.disabled = true; try { botProfileRoleCache.clear(); - await loadBots(); + await Promise.all([loadBots(), loadCliOptions()]); rerender(); } finally { refreshBtn.disabled = false; } }; @@ -123,7 +210,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { // /api/bots 要逐 daemon 探活,慢——先亮 loading 占住右侧详情区。 listEl.innerHTML = loadingHtml(); - await loadBots(); + await Promise.all([loadBots(), loadCliOptions()]); function rerender() { const f = new FormData(form); @@ -210,6 +297,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) {
+ ${renderBotAgentSection(b, cli)}

${t('botDefaults.sectionWorkingDir')}

@@ -769,6 +857,78 @@ export async function renderBotDefaultsPage(root: HTMLElement) { const statusEl = card.querySelector('[data-status]'); if (!wdModeSel || !input || !saveBtn || !statusEl) return; // error card + // ── Agent CLI / model (next-session) ───────────────────────────────── + const agentCliSel = card.querySelector('select[data-input=agentCliId]'); + const agentModelInput = card.querySelector('input[data-input=agentModel]'); + const agentSaveBtn = card.querySelector('button[data-action=save-agent]'); + const agentStatusEl = card.querySelector('[data-agent-status]'); + + function syncAgentModelField(): void { + if (!agentCliSel || !agentModelInput) return; + const list = card.querySelector(`#agent-model-suggestions-${CSS.escape(appId)}`); + const opt = selectedCliOption(agentCliSel.value); + const isTtadk = opt?.gateway === 'ttadk'; + const acceptsModel = isTtadk && opt.acceptsModel !== false; + if (isTtadk && !acceptsModel) { + if (list) list.innerHTML = ''; + agentModelInput.value = ''; + agentModelInput.disabled = true; + agentModelInput.placeholder = t('botOnboarding.modelTtadkCocoPlaceholder'); + return; + } + agentModelInput.disabled = false; + if (acceptsModel) { + if (list) list.innerHTML = ttadkModelSuggestions.map(m => ``).join(''); + agentModelInput.placeholder = t('botOnboarding.modelTtadkPlaceholder').replace('{model}', ttadkModelDefault); + if (!agentModelInput.value.trim()) agentModelInput.value = ttadkModelDefault; + } else { + if (list) list.innerHTML = ''; + agentModelInput.placeholder = t('botDefaults.agentModelPlaceholder'); + if (agentModelInput.value.trim() === ttadkModelDefault) agentModelInput.value = ''; + } + } + + if (agentCliSel && agentModelInput && agentSaveBtn && agentStatusEl) { + agentCliSel.addEventListener('change', syncAgentModelField); + agentSaveBtn.addEventListener('click', async () => { + agentStatusEl.textContent = ''; + agentStatusEl.className = 'oncall-status'; + agentSaveBtn.disabled = true; + agentCliSel.disabled = true; + agentModelInput.disabled = true; + try { + const r = await fetch(`/api/bots/${encodeURIComponent(appId)}/agent`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cliId: agentCliSel.value, model: agentModelInput.value }), + }); + const body = await r.json().catch(() => ({})); + if (r.ok && body.ok) { + agentStatusEl.textContent = `✓ ${t('botDefaults.agentSaved')}`; + agentStatusEl.classList.add('hint-ok'); + const cached = cache.bots.find((bb: any) => bb.larkAppId === appId); + if (cached) { + cached.cliId = body.cliId; + cached.wrapperCli = body.wrapperCli ?? null; + cached.model = body.model ?? ''; + cached.agentSelectionKey = body.selectionKey ?? agentCliSel.value; + } + } else { + agentStatusEl.textContent = `✗ ${body.error ?? r.status}`; + agentStatusEl.classList.add('hint-warn-inline'); + } + } catch (e: any) { + agentStatusEl.textContent = `✗ ${e?.message ?? e}`; + agentStatusEl.classList.add('hint-warn-inline'); + } finally { + agentSaveBtn.disabled = false; + agentCliSel.disabled = false; + agentModelInput.disabled = false; + syncAgentModelField(); + } + }); + } + // 选「关闭」隐藏目录输入框;选其它则显示并聚焦(off 不需要目录)。 wdModeSel.addEventListener('change', () => { const off = wdModeSel.value === 'off'; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index d3406916..0c15c4f9 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -998,6 +998,13 @@ const zh: DashboardMessages = { 'botDefaults.refresh': '刷新', 'botDefaults.sectionOncall': '新群 Oncall', 'botDefaults.sectionBrand': '卡片签名', + 'botDefaults.sectionAgent': 'CLI / 模型', + 'botDefaults.agentCli': 'CLI', + 'botDefaults.agentModel': '模型', + 'botDefaults.agentModelPlaceholder': '留空使用该 CLI 默认模型', + 'botDefaults.agentHelp': '改动只影响新创建的会话;已有会话会继续使用创建时的 CLI / 模型。若要让旧对话使用新配置,请关闭后重新开始。', + 'botDefaults.agentSave': '保存 CLI / 模型', + 'botDefaults.agentSaved': '已保存(下个新会话生效)', 'botDefaults.warning': '开启后,没有 oncall binding 的群会在下次开新话题时自动绑定到该目录;手动绑定或手动解绑过的群不会被覆盖。', 'botDefaults.empty': '没有在线 bot。先 botmux restart 让 daemon 上线。', 'botDefaults.defaultOncall': '默认进入 oncall 模式', @@ -2350,6 +2357,13 @@ const en: DashboardMessages = { 'botDefaults.refresh': 'Refresh', 'botDefaults.sectionOncall': 'New-chat Oncall', 'botDefaults.sectionBrand': 'Card Signature', + 'botDefaults.sectionAgent': 'CLI / Model', + 'botDefaults.agentCli': 'CLI', + 'botDefaults.agentModel': 'Model', + 'botDefaults.agentModelPlaceholder': 'Leave empty for the CLI default model', + 'botDefaults.agentHelp': 'Changes affect only newly created sessions. Existing sessions keep the CLI / model they were created with; close and start again to use the new configuration.', + 'botDefaults.agentSave': 'Save CLI / Model', + 'botDefaults.agentSaved': 'Saved (takes effect next session)', 'botDefaults.warning': 'When enabled, chats without an oncall binding auto-bind to this directory on their next new topic. Manually bound or unbound chats are preserved.', 'botDefaults.empty': 'No bots online. Run botmux restart first.', 'botDefaults.defaultOncall': 'Default to oncall mode', diff --git a/src/types.ts b/src/types.ts index 6ffcb963..ef51f98f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -121,8 +121,24 @@ export interface Session { * session probes 'missing'. Cleared once a live worker is re-established. */ suspendedColdResume?: boolean; - /** CLI used to spawn this session — stamped on every save so closed sessions retain it. */ + /** CLI used to spawn this session, frozen at creation so bot-level CLI edits only affect new sessions. */ cliId?: import('./adapters/cli/types.js').CliId; + /** Optional CLI binary override frozen with `cliId`; used when no wrapper launcher is set. */ + cliPathOverride?: string; + /** Optional wrapper launcher frozen at creation, e.g. `ttadk codex` or `aiden x claude`. */ + wrapperCli?: string; + /** Optional model frozen at creation so historical sessions resume with their original model. */ + model?: string; + /** + * True once `cliId`/`cliPathOverride`/`wrapperCli`/`model` have been frozen for + * this session (see `sessionAgentConfig`). Gates the one-time freeze so it runs + * exactly once — on a fresh start, or on the first resume of a session created + * before these fields existed (back-filling the still-missing ones from the live + * bot config). The marker disambiguates "legacy, never frozen" from "frozen as + * no-wrapper", so a genuinely wrapper-less session never inherits a wrapper the + * bot gains later. + */ + agentFrozen?: boolean; /** * Sandbox decision RECORDED AT SESSION CREATION (overlay file-isolation). The * live bot flag (BotConfig.sandbox) can be toggled later, but a session's diff --git a/test/dashboard-bot-defaults-cliid.test.ts b/test/dashboard-bot-defaults-cliid.test.ts index 2fedc394..81624392 100644 --- a/test/dashboard-bot-defaults-cliid.test.ts +++ b/test/dashboard-bot-defaults-cliid.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { displayCliId } from '../src/dashboard/web/bot-defaults.js'; +import { displayCliId, renderBotAgentSection } from '../src/dashboard/web/bot-defaults.js'; describe('bot defaults cli label', () => { it('prefers /api/bots cliId before session fallback', () => { @@ -7,4 +7,16 @@ describe('bot defaults cli label', () => { expect(displayCliId({ larkAppId: 'cli_traex' }, 'codex')).toBe('codex'); expect(displayCliId({ larkAppId: 'cli_traex', cliId: '' }, '')).toBe(''); }); + + it('renders an editable CLI and model section from /api/bots values', () => { + const html = renderBotAgentSection( + { larkAppId: 'cli_traex', cliId: 'traex', model: 'glm-5.1' }, + 'codex', + ); + expect(html).toContain('data-input="agentCliId"'); + expect(html).toContain('