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
7 changes: 7 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,13 @@ export interface BotState {

const bots = new Map<string, BotState>();

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));
Expand Down
63 changes: 62 additions & 1 deletion src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof resolveCliSelection>;
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 会话
Expand Down
52 changes: 43 additions & 9 deletions src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const dataDir = config.session.dataDir;
let crossRef: Record<string, string> = {};
Expand Down Expand Up @@ -1595,21 +1628,22 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v
// real bmx-<sid>; without this, bmx-diag-<sid> + 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;
let skillReadonlyRoots: string[] | undefined;
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down
56 changes: 51 additions & 5 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,8 +549,30 @@ function configuredCliIds(): Map<string, string> {
}
}

function withConfiguredCliId<T extends { larkAppId: string; cliId?: string }>(bot: T, ids: Map<string, string>): T & { cliId?: string } {
return bot.cliId ? bot : { ...bot, cliId: ids.get(bot.larkAppId) };
function configuredBotAgentFields(): Map<string, { cliId?: string; wrapperCli?: string; model?: string }> {
try {
return new Map(loadBotConfigs().map(b => [b.larkAppId, {
cliId: b.cliId,
wrapperCli: b.wrapperCli,
model: b.model,
}]));
} catch {
return new Map();
}
}

function withConfiguredCliId<T extends { larkAppId: string; cliId?: string; wrapperCli?: string; model?: string }>(
bot: T,
ids: Map<string, string> | Map<string, { cliId?: string; wrapperCli?: string; model?: string }>,
): 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 }[] {
Expand Down Expand Up @@ -1974,16 +1996,22 @@ 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`);
if (!r.ok) {
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));
}
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/bot-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface DashboardBotDescriptor {
botName?: string | null;
botAvatarUrl?: string;
cliId?: string;
wrapperCli?: string;
model?: string;
}

export function botSummaryPayload(bot: DashboardBotDescriptor) {
Expand All @@ -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 };
Expand Down
Loading