diff --git a/infra/tenants.yaml b/infra/tenants.yaml index 1168353..071cf90 100644 --- a/infra/tenants.yaml +++ b/infra/tenants.yaml @@ -18,8 +18,7 @@ platform: shared: services: - postgresql - - clawdie - - clawdie_hostd + - mevy_hostd - cms - web-service - code-service diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 23f54b9..33cc054 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -71,6 +71,51 @@ import { } from '../telegram-commands.js'; import { classifyReportIntent } from '../report-intent.js'; +type TelegramBotCommand = { + command: string; + description: string; +}; + +const PRIVATE_CHAT_COMMANDS: TelegramBotCommand[] = [ + { command: 'help', description: 'Show command help' }, + { command: 'status', description: 'Show system status summary' }, + { command: 'report', description: 'Show structured system report' }, + { command: 'disk', description: 'Show disk and snapshot report' }, + { command: 'tasks', description: 'Show controlplane task report' }, + { command: 'publishreport', description: 'Show tenant publish report' }, + { command: 'testreport', description: 'Show test and build report' }, + { command: 'whoami', description: 'Show your Telegram identity' }, + { command: 'model', description: 'Set provider/model for this chat' }, + { command: 'new', description: 'Start a fresh session' }, +]; + +const OPS_CHAT_COMMANDS: TelegramBotCommand[] = [ + { command: 'help', description: 'Show command help' }, + { command: 'status', description: 'Show system status summary' }, + { command: 'policy', description: 'Show runtime policy and cooldowns' }, + { command: 'usage', description: 'Show agent token budgets' }, + { command: 'tokens', description: 'Show runtime token burn by agent' }, + { command: 'report', description: 'Show structured system report' }, + { command: 'tasks', description: 'Show controlplane task report' }, + { command: 'budgetreport', description: 'Show structured budget report' }, + { command: 'publishreport', description: 'Show tenant publish report' }, + { command: 'testreport', description: 'Show test and build report' }, + { command: 'updates', description: 'Show FreeBSD update status' }, + { command: 'budgetreset', description: 'Reset an agent token budget' }, + { command: 'clearcooldown', description: 'Clear provider cooldown' }, + { command: 'audit', description: 'Show platform ownership audit' }, + { command: 'schedule', description: 'Manage scheduled tasks' }, +]; + +function getOpsChatId(): number | null { + if (!TELEGRAM_OPS_CHAT_ID) return null; + const raw = TELEGRAM_OPS_CHAT_ID.startsWith('tg:') + ? TELEGRAM_OPS_CHAT_ID.slice(3) + : TELEGRAM_OPS_CHAT_ID; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; +} + export interface TelegramChannelOpts { onMessage: OnInboundMessage; onChatMetadata: OnChatMetadata; @@ -91,6 +136,7 @@ export class TelegramChannel implements Channel { async connect(): Promise { this.bot = new Bot(this.botToken); + await this.publishCommandMenus(); // Command to get chat ID (useful for registration) this.bot.command('chatid', (ctx) => { @@ -234,74 +280,57 @@ export class TelegramChannel implements Channel { }); this.bot.command('policy', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handlePolicyCommand(ctx, chatJid); }); this.bot.command('budget', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleBudgetCommand(ctx, chatJid); }); - this.bot.command('tokens', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; - await handleTokensCommand(ctx, chatJid); - }); - this.bot.command('audit', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleAuditCommand(ctx, chatJid); }); this.bot.command('publishreport', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handlePublishReportCommand(ctx, chatJid); }); this.bot.command('disk', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleDiskCommand(ctx, chatJid); }); this.bot.command('report', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleReportCommand(ctx, chatJid); }); this.bot.command('tasks', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleTasksCommand(ctx, chatJid); }); this.bot.command('budgetreport', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleBudgetReportCommand(ctx, chatJid); }); this.bot.command('testreport', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleTestReportCommand(ctx, chatJid); }); this.bot.command('snapshots', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleSnapshotsCommand(ctx, chatJid); }); this.bot.command('scrub', async (ctx) => { - const chatJid = await requireRegistered(ctx); - if (!chatJid) return; + const chatJid = `tg:${ctx.chat.id}`; await handleScrubCommand(ctx, chatJid); }); @@ -807,6 +836,25 @@ export class TelegramChannel implements Channel { }); } + private async publishCommandMenus(): Promise { + if (!this.bot) return; + + try { + await this.bot.api.setMyCommands(PRIVATE_CHAT_COMMANDS, { + scope: { type: 'all_private_chats' }, + }); + + const opsChatId = getOpsChatId(); + if (opsChatId !== null) { + await this.bot.api.setMyCommands(OPS_CHAT_COMMANDS, { + scope: { type: 'chat', chat_id: opsChatId }, + }); + } + } catch (err) { + logger.warn({ err }, 'Failed to publish Telegram command menus'); + } + } + async sendMessage(jid: string, text: string): Promise { if (!this.bot) { logger.warn('Telegram bot not initialized'); diff --git a/src/controlplane-heartbeat.test.ts b/src/controlplane-heartbeat.test.ts index b2423cb..f565e8c 100644 --- a/src/controlplane-heartbeat.test.ts +++ b/src/controlplane-heartbeat.test.ts @@ -200,7 +200,7 @@ describe('runAgentHeartbeat — capability gate', () => { return Promise.resolve({ rows: [{ id: 'task-git-push', - title: 'Push to Codeberg', + title: 'git-push-upstream', assigned_to: 'git-admin', status: 'pending', priority: 'high', diff --git a/src/controlplane-heartbeat.ts b/src/controlplane-heartbeat.ts index 6cf9597..976d6b6 100644 --- a/src/controlplane-heartbeat.ts +++ b/src/controlplane-heartbeat.ts @@ -48,6 +48,7 @@ import { loadSkillsCatalog, matchTaskToSkill } from './skills-discovery.js'; import { checkAgentTaskCapability } from './agent-capabilities.js'; import { syncModelCatalog, formatModelDiff } from './model-catalog.js'; import { logger } from './logger.js'; +import { applyFallback } from './provider-fallback.js'; import { CONTROLPLANE_AIDER_BIN, CONTROLPLANE_AIDER_FLAGS, @@ -161,6 +162,8 @@ async function runPiTask(opts: { sessionCwd: string; wakeReason: WakeReason; apiKey: string; + provider?: string; + model?: string; }): Promise<{ error: string | null; tokensUsed: number; @@ -176,6 +179,8 @@ async function runPiTask(opts: { sessionCwd: opts.sessionCwd, wakeReason: opts.wakeReason, prompt: opts.prompt, + provider: opts.provider, + model: opts.model, }); return await new Promise((resolve) => { @@ -416,9 +421,36 @@ export async function runAgentHeartbeat( let output: string | null = null; let actualProvider: string | null = null; let actualModel: string | null = null; + const preferredProvider = PI_TUI_PROVIDER || undefined; + const preferredModel = PI_TUI_MODEL || undefined; + const effectiveRuntime = applyFallback({ + provider: preferredProvider, + model: preferredModel, + }); + const effectiveProvider = effectiveRuntime.provider; + const effectiveModel = effectiveRuntime.model; + + if (effectiveRuntime.fallbackActive) { + logger.warn( + { + agentId, + taskId: effectiveTaskId, + originalProvider: effectiveRuntime.originalProvider, + originalModel: effectiveRuntime.originalModel, + fallbackProvider: effectiveProvider, + fallbackModel: effectiveModel, + cooldownUntil: effectiveRuntime.cooldown?.until.toISOString(), + }, + 'Controlplane provider fallback active', + ); + } const jailName = resolveAgentJail(agentId); - const capCheck = checkAgentTaskCapability(jailName, skillName); + const capChecks = [ + checkAgentTaskCapability(jailName, skillName), + checkAgentTaskCapability(jailName, taskDescription.trim()), + ]; + const capCheck = capChecks.find((check) => !check.ok) ?? capChecks[0]; if (!capCheck.ok) { await insertActivity(pool, { agent_id: agentId, @@ -473,6 +505,8 @@ export async function runAgentHeartbeat( prompt: promptWithContext, systemPrompt: identityContent ?? undefined, extensionPath: jailExtPath, + provider: effectiveProvider, + model: effectiveModel, env: jailEnv, }); tokensUsed = jailResult.tokensUsed; @@ -527,6 +561,8 @@ export async function runAgentHeartbeat( sessionCwd, wakeReason, prompt: promptWithContext, + provider: effectiveProvider, + model: effectiveModel, }); tokensUsed = piResult.tokensUsed; error = piResult.error; @@ -554,6 +590,8 @@ export async function runAgentHeartbeat( action_taken: 'logged', configured_provider: PI_TUI_PROVIDER || null, configured_model: PI_TUI_MODEL || null, + effective_provider: effectiveProvider || null, + effective_model: effectiveModel || null, actual_provider: actualProvider, actual_model: actualModel, }, @@ -595,6 +633,8 @@ export async function runAgentHeartbeat( skill: skillName, configured_provider: PI_TUI_PROVIDER || null, configured_model: PI_TUI_MODEL || null, + effective_provider: effectiveProvider || null, + effective_model: effectiveModel || null, actual_provider: actualProvider, actual_model: actualModel, // Store the tail so we keep the most user-relevant part (often the final answer), diff --git a/src/controlplane-runner.test.ts b/src/controlplane-runner.test.ts index ecde606..5f3c3dc 100644 --- a/src/controlplane-runner.test.ts +++ b/src/controlplane-runner.test.ts @@ -145,6 +145,21 @@ describe('Control Plane Runner — env injection', () => { const lastArg = result.args[result.args.length - 1]; expect(lastArg).toContain('interval_elapsed'); }); + + it('passes explicit provider and model when supplied', () => { + const result = buildControlplaneRunCommand( + makeOpts({ + provider: 'openrouter', + model: 'meta-llama/llama-3.3-70b-instruct:free', + }), + ); + expect(result.args).toContain('--provider'); + expect(result.args).toContain('openrouter'); + expect(result.args).toContain('--model'); + expect(result.args).toContain( + 'meta-llama/llama-3.3-70b-instruct:free', + ); + }); }); describe('wake reason', () => { @@ -260,7 +275,7 @@ describe('resolveAgentJail', () => { it('uses the platform service name in resolved jail names', () => { const result = resolveAgentJail('db-admin'); if (result !== null) { - expect(result.startsWith('clawdie_')).toBe(true); + expect(result.endsWith('_db_worker')).toBe(true); } }); }); diff --git a/src/controlplane-runner.ts b/src/controlplane-runner.ts index 86a6185..7834fe6 100644 --- a/src/controlplane-runner.ts +++ b/src/controlplane-runner.ts @@ -18,7 +18,7 @@ import { CONTROLPLANE_API_PORT, PLATFORM_SERVICE_NAME, } from './config.js'; -import { getFirstLlmKey } from './env.js'; +import { getFirstLlmKey, getLlmKeyForProvider } from './env.js'; // ── Types ────────────────────────────────────────────────────────────────── @@ -34,6 +34,8 @@ export interface ControlplaneRunOptions { wakeReason: WakeReason; identityFile?: string; prompt?: string; + provider?: string; + model?: string; } export interface RunResult { @@ -116,6 +118,8 @@ export function buildControlplaneRunCommand( const identityFile = opts.identityFile ?? resolveIdentityFile(agentId, agentName, workspaceCwd); const prompt = opts.prompt ?? `Control plane wake: ${wakeReason}`; + const provider = opts.provider?.trim() || ''; + const model = opts.model?.trim() || ''; // Build system prompt: identity + scoped skill index from library.yaml. // Pi's built-in skill discovery is disabled (--no-skills) — we inject only @@ -139,6 +143,12 @@ export function buildControlplaneRunCommand( '--session', sessionFile, ]; + if (provider) { + args.push('--provider', provider); + } + if (model) { + args.push('--model', model); + } // Load the harness extension so agents get tools (hostd, skills_search, etc.) const harnessExt = path.join( @@ -170,7 +180,7 @@ export function buildControlplaneRunCommand( // Inject the LLM API key so the child pi process can authenticate. // readEnvFile() deliberately keeps secrets out of process.env for security, // so we read .env directly here and pass only the needed key. - const llmKey = getFirstLlmKey(); + const llmKey = getLlmKeyForProvider(provider) ?? getFirstLlmKey(); if (llmKey) { env[llmKey.key] = llmKey.value; } diff --git a/src/env.ts b/src/env.ts index 0651890..1d75d0f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -50,6 +50,25 @@ const LLM_KEY_VARS = [ 'GEMINI_API_KEY', ]; +function providerKeyVar(provider: string): string | null { + const normalized = provider.trim().toLowerCase(); + if (!normalized) return null; + switch (normalized) { + case 'zai': + return 'ZAI_API_KEY'; + case 'openrouter': + return 'OPENROUTER_API_KEY'; + case 'openai': + return 'OPENAI_API_KEY'; + case 'anthropic': + return 'ANTHROPIC_API_KEY'; + case 'gemini': + return 'GEMINI_API_KEY'; + default: + return null; + } +} + /** * Read the first available LLM API key from the .env file. * Returns { key, value } or null if none found. @@ -92,3 +111,43 @@ export function getFirstLlmKey(): { key: string; value: string } | null { } return null; } + +export function getLlmKeyForProvider( + provider: string | undefined, +): { key: string; value: string } | null { + const envFile = path.join(process.cwd(), '.env'); + let content: string; + try { + content = fs.readFileSync(envFile, 'utf-8'); + } catch { + return null; + } + + const wantedKey = provider ? providerKeyVar(provider) : null; + const vars: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const k = trimmed.slice(0, eqIdx).trim(); + if (!LLM_KEY_VARS.includes(k)) continue; + let v = trimmed.slice(eqIdx + 1).trim(); + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + if (v) vars[k] = v; + } + + if (wantedKey && vars[wantedKey]) { + return { key: wantedKey, value: vars[wantedKey] }; + } + + for (const k of LLM_KEY_VARS) { + if (vars[k]) return { key: k, value: vars[k] }; + } + return null; +} diff --git a/src/jail-exec-runner.test.ts b/src/jail-exec-runner.test.ts index 000a678..fd66b90 100644 --- a/src/jail-exec-runner.test.ts +++ b/src/jail-exec-runner.test.ts @@ -443,6 +443,24 @@ describe('jailPi', () => { expect(shellCmd).toContain('--no-skills'); expect(shellCmd).not.toContain('--skill '); }); + + it('passes explicit provider and model flags when provided', async () => { + mockSpawn.mockReturnValue( + makeMockProc({ stdout: '{}', exitCode: 0 }) as any, + ); + await jailPi({ + jailName: 'test-jail', + prompt: 'hello', + provider: 'openrouter', + model: 'meta-llama/llama-3.3-70b-instruct:free', + }); + const args = mockSpawn.mock.calls[0][1] as string[]; + const shellCmd = args[args.length - 1] as string; + expect(shellCmd).toContain('--provider openrouter'); + expect(shellCmd).toContain( + '--model meta-llama/llama-3.3-70b-instruct:free', + ); + }); }); // ── jailAider ────────────────────────────────────────────────────────────── diff --git a/src/jail-exec-runner.ts b/src/jail-exec-runner.ts index 5b6ed65..fe28e60 100644 --- a/src/jail-exec-runner.ts +++ b/src/jail-exec-runner.ts @@ -328,6 +328,8 @@ export interface JailPiOptions { sessionId?: string; sessionDir?: string; extensionPath?: string; + provider?: string; + model?: string; env?: Record; timeoutMs?: number; } @@ -350,6 +352,12 @@ export async function jailPi(opts: JailPiOptions): Promise { if (opts.extensionPath) { args.push('-e', opts.extensionPath); } + if (opts.provider?.trim()) { + args.push('--provider', opts.provider.trim()); + } + if (opts.model?.trim()) { + args.push('--model', opts.model.trim()); + } // systemPrompt is handled by jailExec via file + $(cat) in buildShellCommand return jailExec({ diff --git a/src/reports/system-report.test.ts b/src/reports/system-report.test.ts index a29979b..f9cc5f2 100644 --- a/src/reports/system-report.test.ts +++ b/src/reports/system-report.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildSystemReport, classifyControlplaneAuth, + getControlplaneProbeHost, parseBastilleList, parseServiceStatus, renderSystemReport, @@ -38,6 +39,13 @@ describe('classifyControlplaneAuth', () => { }); }); +describe('getControlplaneProbeHost', () => { + it('avoids using wildcard bind addresses as probe targets', () => { + expect(getControlplaneProbeHost()).not.toBe('0.0.0.0'); + expect(getControlplaneProbeHost()).not.toBe('::'); + }); +}); + describe('system report rendering', () => { it('renders observed, interpretation, and operator notes deterministically', () => { const jails = parseBastilleList( diff --git a/src/reports/system-report.ts b/src/reports/system-report.ts index 2f8649f..23fd45a 100644 --- a/src/reports/system-report.ts +++ b/src/reports/system-report.ts @@ -4,6 +4,7 @@ import { CONTROLPLANE_API_PORT, CONTROLPLANE_BIND_HOST, CONTROLPLANE_SHARED_SECRET, + BETTER_AUTH_URL, } from '../config.js'; import { formatDisplayDate } from '../display-date.js'; import type { JailInfo } from '../system-state.js'; @@ -65,7 +66,7 @@ function cpGetStatusCode( return new Promise((resolve, reject) => { const req = http.get( { - hostname: CONTROLPLANE_BIND_HOST, + hostname: getControlplaneProbeHost(), port: CONTROLPLANE_API_PORT, path, timeout: 2000, @@ -84,6 +85,22 @@ function cpGetStatusCode( }); } +export function getControlplaneProbeHost(): string { + const bindHost = (CONTROLPLANE_BIND_HOST || '').trim(); + if (bindHost && bindHost !== '0.0.0.0' && bindHost !== '::') { + return bindHost; + } + try { + const url = new URL(BETTER_AUTH_URL); + if (url.hostname && url.hostname !== '0.0.0.0' && url.hostname !== '::') { + return url.hostname; + } + } catch { + // ignore invalid BETTER_AUTH_URL and fall back to localhost + } + return '127.0.0.1'; +} + export function parseServiceStatus(output: string): ServiceStatus { const normalized = output.trim().toLowerCase(); if (!normalized) return 'unknown'; diff --git a/src/system-state.test.ts b/src/system-state.test.ts index 4e74091..2c4ef2a 100644 --- a/src/system-state.test.ts +++ b/src/system-state.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from 'vitest'; -import { parseSize } from './system-state.js'; +import { parseSize, summarizeZfsRows } from './system-state.js'; describe('parseSize', () => { it('parses bare number as bytes', () => { @@ -59,3 +59,25 @@ describe('parseSize', () => { expect(parseSize(' 1G ')).toBe(1e9); }); }); + +describe('summarizeZfsRows', () => { + it('filters snapshots and deep datasets from the compact status view', () => { + const rows = summarizeZfsRows( + [ + 'NAME USED AVAIL REFER MOUNTPOINT', + 'zroot 68G 30G 96K /', + 'zroot@pre-jail-isolation-1776341120 0B - 1G -', + 'zroot/ROOT 22G 25G 96K /ROOT', + 'zroot/ROOT/default 22G 25G 96K /', + 'zroot/home 12G 11G 96K /home', + 'zroot/home@pre-jail-isolation-1776341120 100M - 1G -', + ].join('\n'), + ); + + expect(rows).toEqual([ + { pool: 'zroot', usedPct: 69 }, + { pool: 'zroot/ROOT', usedPct: 47 }, + { pool: 'zroot/home', usedPct: 52 }, + ]); + }); +}); diff --git a/src/system-state.ts b/src/system-state.ts index 5a3ae32..e69f1ac 100644 --- a/src/system-state.ts +++ b/src/system-state.ts @@ -12,6 +12,7 @@ import http from 'http'; import net from 'net'; +import { parseBastilleList as parseBastilleRows } from './bastille-list.js'; import { CONTROLPLANE_SHARED_SECRET } from './config.js'; import { SOCKET_PATH } from './hostd/types.js'; @@ -148,21 +149,32 @@ function callHostd(op: string, params: Record = {}): Promise l.trim() && !/^\s*(JID|---)/i.test(l)) - .map((line) => { - const parts = line.trim().split(/\s+/); - const jid = parts[0] ?? '-'; - const ip = parts[1] ?? '-'; - const name = parts[2] ?? '-'; - return { - name, - ip, - jid, - state: (jid && jid !== '0' && jid !== '-' ? 'running' : 'stopped') as JailInfo['state'], - }; - }); + return parseBastilleRows(output).map((row) => ({ + name: row.name, + state: row.state, + ip: row.ip, + jid: row.jid, + })); +} + +export function summarizeZfsRows(output: string): ZfsSummary[] { + const zfs: ZfsSummary[] = []; + for (const line of output.split('\n').filter(Boolean)) { + if (/^NAME/i.test(line)) continue; + const parts = line.trim().split(/\s+/); + const name = parts[0] ?? ''; + const usedRaw = parts[1] ?? '0'; + const availRaw = parts[2] ?? '0'; + if (!name || name.includes('@')) continue; + // Keep the view compact: top-level pool plus first-level datasets. + if (name.split('/').length > 2) continue; + const used = parseSize(usedRaw); + const avail = parseSize(availRaw); + const total = used + avail; + const usedPct = total > 0 ? Math.round((used / total) * 100) : 0; + zfs.push({ pool: name, usedPct }); + } + return zfs; } // --------------------------------------------------------------------------- @@ -220,24 +232,9 @@ export async function collectSnapshot(): Promise { ? parseBastilleList(jailResp.output ?? '') : []; - // parse ZFS — pick the pool root lines - const zfs: ZfsSummary[] = []; - if (zfsResp.ok) { - for (const line of (zfsResp.output ?? '').split('\n').filter(Boolean)) { - if (/^NAME/i.test(line)) continue; - const parts = line.trim().split(/\s+/); - const name = parts[0] ?? ''; - const usedRaw = parts[1] ?? '0'; - const availRaw = parts[2] ?? '0'; - // Only top-level pools (no '/' in name after first segment) - if (name.split('/').length > 2) continue; - const used = parseSize(usedRaw); - const avail = parseSize(availRaw); - const total = used + avail; - const usedPct = total > 0 ? Math.round((used / total) * 100) : 0; - zfs.push({ pool: name, usedPct }); - } - } + const zfs: ZfsSummary[] = zfsResp.ok + ? summarizeZfsRows(zfsResp.output ?? '') + : []; // PF status const pfEnabled: boolean | null = pfResp.ok diff --git a/src/telegram-commands.ts b/src/telegram-commands.ts index 3f96fd5..7e7d637 100644 --- a/src/telegram-commands.ts +++ b/src/telegram-commands.ts @@ -961,10 +961,18 @@ export async function handleBudgetResetCallback( return; } const ok = await resetBudgetForAgent(getPool(), agentId); - await ctxArg.answerCallbackQuery({ - text: ok ? `Budget reset: ${agentId}` : `Reset failed: ${agentId}`, - show_alert: false, - }); + const message = ok + ? `Budget reset: ${agentId}. Use /usage to refresh the snapshot.` + : `Budget reset failed: ${agentId}`; + try { + await ctxArg.answerCallbackQuery({ + text: ok ? `Budget reset: ${agentId}` : `Reset failed: ${agentId}`, + show_alert: false, + }); + } catch (err) { + logger.warn({ err, agentId, chatJid }, 'Budget reset callback ack failed'); + } + await ctxArg.reply(message); } // ── /clearcooldown ─────────────────────────────────────────────────────── @@ -1991,9 +1999,12 @@ export async function handleStatusCommand( lines.push('Jails', ...jailLines, ''); } if (snap.zfs.length > 0) { - const zfsLines = snap.zfs.map( + const zfsLines = snap.zfs.slice(0, 8).map( (z) => `${z.pool}: ${z.usedPct}% used`, ); + if (snap.zfs.length > zfsLines.length) { + zfsLines.push(`… ${snap.zfs.length - zfsLines.length} more dataset(s) hidden`); + } lines.push('ZFS', ...zfsLines, ''); } if (snap.pfEnabled !== null) {