From 514b85ba330816cfc57e239743e6e31615fdd793 Mon Sep 17 00:00:00 2001 From: Kasper Neist Date: Thu, 8 Jan 2026 22:02:42 +0100 Subject: [PATCH 01/13] feat(system-prompt): add messaging guidance section Adds a brief 'Messaging' section to the system prompt to guide agents on: - Reply in session = auto-routes to source provider - Cross-session = use sessions_send - Never use bash/curl for provider messaging This helps prevent agents from using shell workarounds for messaging when Clawdbot already handles routing internally. --- src/agents/system-prompt.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index f393188d9..df003eaf7 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -224,6 +224,11 @@ export function buildAgentSystemPrompt(params: { "- [[reply_to:]] replies to a specific message id when you have it.", "Tags are stripped before sending; support depends on the current provider config.", "", + "## Messaging", + "- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)", + "- Cross-session messaging → use sessions_send(sessionKey, message)", + "- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.", + "", ]; if (extraSystemPrompt) { From 3b78870f333a6a75b93767727e135fff0644785e Mon Sep 17 00:00:00 2001 From: Kasper Neist Date: Thu, 8 Jan 2026 22:02:42 +0100 Subject: [PATCH 02/13] feat(system-prompt): add messaging guidance section Adds a brief 'Messaging' section to the system prompt to guide agents on: - Reply in session = auto-routes to source provider - Cross-session = use sessions_send - Never use bash/curl for provider messaging This helps prevent agents from using shell workarounds for messaging when Clawdbot already handles routing internally. --- src/agents/system-prompt.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index f393188d9..df003eaf7 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -224,6 +224,11 @@ export function buildAgentSystemPrompt(params: { "- [[reply_to:]] replies to a specific message id when you have it.", "Tags are stripped before sending; support depends on the current provider config.", "", + "## Messaging", + "- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)", + "- Cross-session messaging → use sessions_send(sessionKey, message)", + "- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.", + "", ]; if (extraSystemPrompt) { From 362f62f325bb0655b998212e8d2274f53b1b82bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:00:33 +0100 Subject: [PATCH 03/13] docs: update changelog and clawtributors for #526 --- CHANGELOG.md | 1 + README.md | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aec03170b..5cdfb012a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. +- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist ## 2026.1.8 diff --git a/README.md b/README.md index f6e8ce38c..a15d2bcf4 100644 --- a/README.md +++ b/README.md @@ -459,11 +459,11 @@ Thanks to all clawtributors: daveonkels Eng. Juan Combetto Mariano Belinky julianengel sreekaransrinath dbhurley gupsammy nachoiacovino Vasanth Rao Naik Sabavat jeffersonwarrior claude scald andranik-sahakyan nachx639 sircrumpet rafaelreis-r meaningfool ratulsarna lutr0 abhisekbasu1 emanuelst osolmaz kiranjd thewilloftheshadow CashWilliams manuelhettich minghinmatthewlam buddyh sheeek timkrase - mcinteerj azade-c imfing petter-b RandyVentures jalehman obviyus Yurii Chukhlib dan-dr iamadig - manmal VACInc zats Django Navarro pcty-nextgen-service-account Syhids erik-agens fcatuhe jayhickey jverdi - mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons Asleep123 cash-echo-bot - Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Keith the Silly Goose - Kit kitze kkarimi loukotal mrdbstn MSch nexty5870 ngutman onutc prathamdby - reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo Erik Manuel Maly - Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock pasogott ogulcancelik + mcinteerj azade-c imfing petter-b RandyVentures Yurii Chukhlib jalehman obviyus dan-dr iamadig + manmal ogulcancelik VACInc zats Django Navarro L36 Server pcty-nextgen-service-account Syhids erik-agens fcatuhe + jayhickey jonasjancarik Jonathan D. Rhyne (DJ-D) jverdi mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC + alejandro maza antons Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna + Jarvis Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch neist nexty5870 + ngutman onutc prathamdby RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo + Erik Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres reeltimeapps Tobias Bischoff William Stock

From 8a3e100ad134060cfbf2a83be0d4684611a7a382 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:04:02 +0100 Subject: [PATCH 04/13] test: update google-shared expectations --- src/cli/logs-cli.ts | 10 ++++-- src/commands/providers/logs.ts | 2 +- src/logging/parse-log-line.test.ts | 6 ++-- src/logging/parse-log-line.ts | 4 +-- src/providers/google-shared.test.ts | 50 ++++++++++++----------------- 5 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index e55222396..242aa6b9a 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -52,7 +52,10 @@ async function fetchLogs( return payload as LogsTailPayload; } -function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") { +function formatLogTimestamp( + value?: string, + mode: "pretty" | "plain" = "plain", +) { if (!value) return ""; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; @@ -70,7 +73,10 @@ function formatLogLine( const parsed = parseLogLine(raw); if (!parsed) return raw; const label = parsed.subsystem ?? parsed.module ?? ""; - const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain"); + const time = formatLogTimestamp( + parsed.time, + opts.pretty ? "pretty" : "plain", + ); const level = parsed.level ?? ""; const levelLabel = level.padEnd(5).trim(); const message = parsed.message || parsed.raw; diff --git a/src/commands/providers/logs.ts b/src/commands/providers/logs.ts index 642597248..7ea2801a8 100644 --- a/src/commands/providers/logs.ts +++ b/src/commands/providers/logs.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import { getResolvedLoggerSettings } from "../../logging.js"; import { parseLogLine } from "../../logging/parse-log-line.js"; +import { getResolvedLoggerSettings } from "../../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; diff --git a/src/logging/parse-log-line.test.ts b/src/logging/parse-log-line.test.ts index 09da3a554..20a72e707 100644 --- a/src/logging/parse-log-line.test.ts +++ b/src/logging/parse-log-line.test.ts @@ -20,7 +20,9 @@ describe("parseLogLine", () => { expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z"); expect(parsed?.level).toBe("info"); expect(parsed?.subsystem).toBe("gateway/providers/whatsapp"); - expect(parsed?.message).toBe("{\"subsystem\":\"gateway/providers/whatsapp\"} connected"); + expect(parsed?.message).toBe( + '{"subsystem":"gateway/providers/whatsapp"} connected', + ); expect(parsed?.raw).toBe(line); }); @@ -28,7 +30,7 @@ describe("parseLogLine", () => { const line = JSON.stringify({ 0: "hello", _meta: { - name: "{\"subsystem\":\"gateway\"}", + name: '{"subsystem":"gateway"}', logLevelName: "WARN", date: "2026-01-09T02:10:00.000Z", }, diff --git a/src/logging/parse-log-line.ts b/src/logging/parse-log-line.ts index 658d27213..be99ac803 100644 --- a/src/logging/parse-log-line.ts +++ b/src/logging/parse-log-line.ts @@ -21,9 +21,7 @@ function extractMessage(value: Record): string { return parts.join(" "); } -function parseMetaName( - raw?: unknown, -): { subsystem?: string; module?: string } { +function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } { if (typeof raw !== "string") return {}; try { const parsed = JSON.parse(raw) as Record; diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 80d7f3889..738c8ca28 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -46,12 +46,12 @@ describe("google-shared convertTools", () => { converted?.[0]?.functionDeclarations?.[0]?.parameters, ); - expect(params.type).toBeUndefined(); + expect(params.type).toBe("object"); expect(params.properties).toBeDefined(); expect(params.required).toEqual(["action"]); }); - it("keeps unsupported JSON Schema keywords intact", () => { + it("drops unsupported JSON Schema keywords", () => { const tools = [ { name: "example", @@ -93,11 +93,11 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params).toHaveProperty("patternProperties"); - expect(params).toHaveProperty("additionalProperties"); - expect(mode).toHaveProperty("const"); - expect(options).toHaveProperty("anyOf"); - expect(items).toHaveProperty("const"); + expect(params).not.toHaveProperty("patternProperties"); + expect(params).not.toHaveProperty("additionalProperties"); + expect(mode).not.toHaveProperty("const"); + expect(options).not.toHaveProperty("anyOf"); + expect(items).not.toHaveProperty("const"); expect(params.required).toEqual(["mode"]); }); @@ -147,7 +147,7 @@ describe("google-shared convertTools", () => { }); describe("google-shared convertMessages", () => { - it("keeps thinking blocks when provider/model match", () => { + it("drops thinking blocks for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -184,13 +184,7 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); - expect(contents[0].role).toBe("model"); - expect(contents[0].parts).toHaveLength(1); - expect(contents[0].parts?.[0]).toMatchObject({ - thought: true, - thoughtSignature: "sig", - }); + expect(contents).toHaveLength(0); }); it("keeps thought signatures for Claude models", () => { @@ -238,7 +232,7 @@ describe("google-shared convertMessages", () => { }); }); - it("does not merge consecutive user messages for Gemini", () => { + it("merges consecutive user messages for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -254,12 +248,12 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); }); - it("does not merge consecutive user messages for non-Gemini Google models", () => { + it("merges consecutive user messages for non-Gemini Google models", () => { const model = makeModel("claude-3-opus"); const context = { messages: [ @@ -275,12 +269,12 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); }); - it("does not merge consecutive model messages for Gemini", () => { + it("merges consecutive model messages for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -338,10 +332,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[2].role).toBe("model"); + expect(contents[1].parts).toHaveLength(2); }); it("handles user message after tool result without model response in between", () => { @@ -398,11 +392,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(4); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); - expect(contents[3].role).toBe("user"); const toolResponsePart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, @@ -476,11 +469,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[2].role).toBe("model"); - const toolCallPart = contents[2].parts?.find( + const toolCallPart = contents[1].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); From 468889abef0352dc7b7fcb0c9589852956e80e04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:09:50 +0100 Subject: [PATCH 05/13] fix: refine status usage and elevated directives --- CHANGELOG.md | 5 ++ src/auto-reply/reply.directive.test.ts | 34 ++++++++++ src/auto-reply/reply.triggers.test.ts | 76 ++++++++++++++++++++++ src/auto-reply/reply.ts | 20 +++++- src/auto-reply/reply/commands.ts | 2 + src/auto-reply/reply/directive-handling.ts | 32 ++++++++- src/auto-reply/status.test.ts | 4 +- src/auto-reply/status.ts | 4 +- 8 files changed, 168 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdfb012a..fe365ca12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne +- Status: show Verbose/Elevated only when enabled. +- Status: filter usage summary to the active model provider. +- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. +- Commands: keep multi-directive messages from clearing directive handling. +- Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index a690ad505..32c25f4d9 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -472,6 +472,40 @@ describe("directive behavior", () => { }); }); + it("warns when elevated is used in direct runtime", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + sandbox: { mode: "off" }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Runtime is direct; sandboxing does not apply."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("rejects invalid elevated level", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index fb7ad374d..371da1a8c 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -14,6 +14,16 @@ vi.mock("../agents/pi-embedded.js", () => ({ isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), })); +const usageMocks = vi.hoisted(() => ({ + loadProviderUsageSummary: vi.fn().mockResolvedValue({ + updatedAt: 0, + providers: [], + }), + formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"), +})); + +vi.mock("../infra/provider-usage.js", () => usageMocks); + import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -66,6 +76,30 @@ afterEach(() => { }); describe("trigger handling", () => { + it("filters usage summary to the current model provider", async () => { + await withTempHome(async (home) => { + usageMocks.loadProviderUsageSummary.mockClear(); + + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("📊 Usage: Claude 80% left"); + expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["anthropic"] }), + ); + }); + }); + it("aborts even with timestamp prefix", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( @@ -383,6 +417,48 @@ describe("trigger handling", () => { }); }); + it("allows elevated off in groups without mention", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1000"] }, + }, + }, + whatsapp: { + allowFrom: ["+1000"], + groups: { "*": { requireMention: false } }, + }, + session: { store: join(home, "sessions.json") }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + }); + }); + it("allows elevated directive in groups when mentioned", async () => { await withTempHome(async (home) => { const cfg = { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 5248fb5c6..de038bdfb 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -329,11 +329,19 @@ export async function getReplyFromConfig( .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); - const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true; let parsedDirectives = parseInlineDirectives(rawBody, { modelAliases: configuredAliases, - disableElevated: disableElevatedInGroup, }); + if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasElevatedDirective) { + if (parsedDirectives.elevatedLevel !== "off") { + parsedDirectives = { + ...parsedDirectives, + hasElevatedDirective: false, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + }; + } + } const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || @@ -348,7 +356,13 @@ export async function getReplyFromConfig( ? stripMentions(stripped, ctx, cfg, agentId) : stripped; if (noMentions.trim().length > 0) { - parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + const directiveOnlyCheck = parseInlineDirectives(noMentions, { + modelAliases: configuredAliases, + disableElevated: disableElevatedInGroup, + }); + if (directiveOnlyCheck.cleaned.trim().length > 0) { + parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + } } } const directives = commandAuthorized diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index ba61e5c0b..4435ef5c9 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -37,6 +37,7 @@ import { normalizeCommandBody, shouldHandleTextCommands, } from "../commands-registry.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; import { normalizeGroupActivation, parseActivationCommand, @@ -424,6 +425,7 @@ export async function handleCommands(params: { try { const usageSummary = await loadProviderUsageSummary({ timeoutMs: 3500, + providers: [normalizeProviderId(provider)], }); usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); } catch { diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 30c71ccf1..6aea3a980 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -24,7 +24,12 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { ClawdbotConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { + resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { shortenHomePath } from "../../utils.js"; import { extractModelDirective } from "../model.js"; @@ -57,6 +62,8 @@ const SYSTEM_MARK = "⚙️"; const formatOptionsLine = (options: string) => `Options: ${options}.`; const withOptions = (line: string, options: string) => `${line}\n${formatOptionsLine(options)}`; +const formatElevatedRuntimeHint = () => + `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; const maskApiKey = (value: string): string => { const trimmed = value.trim(); @@ -350,6 +357,21 @@ export async function handleDirectiveOnly(params: { currentReasoningLevel, currentElevatedLevel, } = params; + const runtimeIsSandboxed = (() => { + const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off"; + if (sandboxMode === "off") return false; + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey) return false; + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const mainKey = resolveAgentMainSessionKey({ + cfg: params.cfg, + agentId, + }); + if (sandboxMode === "all") return true; + return sessionKey !== mainKey; + })(); + const shouldHintDirectRuntime = + directives.hasElevatedDirective && !runtimeIsSandboxed; if (directives.hasModelDirective) { const modelDirective = directives.rawModelDirective?.trim().toLowerCase(); @@ -463,7 +485,12 @@ export async function handleDirectiveOnly(params: { } const level = currentElevatedLevel ?? "off"; return { - text: withOptions(`Current elevated level: ${level}.`, "on, off"), + text: [ + withOptions(`Current elevated level: ${level}.`, "on, off"), + shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, + ] + .filter(Boolean) + .join("\n"), }; } return { @@ -681,6 +708,7 @@ export async function handleDirectiveOnly(params: { ? `${SYSTEM_MARK} Elevated mode disabled.` : `${SYSTEM_MARK} Elevated mode enabled.`, ); + if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); } if (modelSelection) { const label = `${modelSelection.provider}/${modelSelection.model}`; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 36ef4e848..7ac76b62c 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -74,8 +74,8 @@ describe("buildStatusMessage", () => { expect(text).toContain("Session: agent:main:main"); expect(text).toContain("updated 10m ago"); expect(text).toContain("Think: medium"); - expect(text).toContain("Verbose: off"); - expect(text).toContain("Elevated: on"); + expect(text).not.toContain("Verbose"); + expect(text).toContain("Elevated"); expect(text).toContain("Queue: collect"); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a6a5c8588..fc374d83a 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -267,9 +267,9 @@ export function buildStatusMessage(args: StatusArgs): string { const optionParts = [ `Runtime: ${runtime.label}`, `Think: ${thinkLevel}`, - `Verbose: ${verboseLevel}`, + verboseLevel === "on" ? "Verbose" : null, reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, - `Elevated: ${elevatedLevel}`, + elevatedLevel === "on" ? "Elevated" : null, ]; const optionsLine = optionParts.filter(Boolean).join(" · "); const activationParts = [ From 84f668f9c551bca307e83cc34638720022e08d5d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:12:20 +0100 Subject: [PATCH 06/13] test: align google-shared expectations with pi-ai 0.40.0 --- src/providers/google-shared.test.ts | 50 +++++++++++++++++------------ 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 738c8ca28..80d7f3889 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -46,12 +46,12 @@ describe("google-shared convertTools", () => { converted?.[0]?.functionDeclarations?.[0]?.parameters, ); - expect(params.type).toBe("object"); + expect(params.type).toBeUndefined(); expect(params.properties).toBeDefined(); expect(params.required).toEqual(["action"]); }); - it("drops unsupported JSON Schema keywords", () => { + it("keeps unsupported JSON Schema keywords intact", () => { const tools = [ { name: "example", @@ -93,11 +93,11 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params).not.toHaveProperty("patternProperties"); - expect(params).not.toHaveProperty("additionalProperties"); - expect(mode).not.toHaveProperty("const"); - expect(options).not.toHaveProperty("anyOf"); - expect(items).not.toHaveProperty("const"); + expect(params).toHaveProperty("patternProperties"); + expect(params).toHaveProperty("additionalProperties"); + expect(mode).toHaveProperty("const"); + expect(options).toHaveProperty("anyOf"); + expect(items).toHaveProperty("const"); expect(params.required).toEqual(["mode"]); }); @@ -147,7 +147,7 @@ describe("google-shared convertTools", () => { }); describe("google-shared convertMessages", () => { - it("drops thinking blocks for Gemini", () => { + it("keeps thinking blocks when provider/model match", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -184,7 +184,13 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(0); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe("model"); + expect(contents[0].parts).toHaveLength(1); + expect(contents[0].parts?.[0]).toMatchObject({ + thought: true, + thoughtSignature: "sig", + }); }); it("keeps thought signatures for Claude models", () => { @@ -232,7 +238,7 @@ describe("google-shared convertMessages", () => { }); }); - it("merges consecutive user messages for Gemini", () => { + it("does not merge consecutive user messages for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -248,12 +254,12 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); - expect(contents[0].parts).toHaveLength(2); + expect(contents[1].role).toBe("user"); }); - it("merges consecutive user messages for non-Gemini Google models", () => { + it("does not merge consecutive user messages for non-Gemini Google models", () => { const model = makeModel("claude-3-opus"); const context = { messages: [ @@ -269,12 +275,12 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); - expect(contents[0].parts).toHaveLength(2); + expect(contents[1].role).toBe("user"); }); - it("merges consecutive model messages for Gemini", () => { + it("does not merge consecutive model messages for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -332,10 +338,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[1].parts).toHaveLength(2); + expect(contents[2].role).toBe("model"); }); it("handles user message after tool result without model response in between", () => { @@ -392,10 +398,11 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(4); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); + expect(contents[3].role).toBe("user"); const toolResponsePart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, @@ -469,10 +476,11 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - const toolCallPart = contents[1].parts?.find( + expect(contents[2].role).toBe("model"); + const toolCallPart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); From 1a295d94604251658742436946adfc5122621832 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:18:41 +0100 Subject: [PATCH 07/13] fix: improve status usage filtering and directives --- CHANGELOG.md | 1 + src/auto-reply/reply.directive.test.ts | 33 ++++++++++++++++++++++++++ src/auto-reply/reply.ts | 7 ++++-- src/auto-reply/reply/commands.ts | 32 ++++++++++++++++++++----- src/auto-reply/status.test.ts | 18 ++++++++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe365ca12..e2fb77924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne - Status: show Verbose/Elevated only when enabled. - Status: filter usage summary to the active model provider. +- Status: map model providers to usage sources so unrelated usage doesn’t appear. - Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. - Commands: keep multi-directive messages from clearing directive handling. - Commands: warn when /elevated runs in direct (unsandboxed) runtime. diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 32c25f4d9..46b9eba51 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -538,6 +538,39 @@ describe("directive behavior", () => { }); }); + it("handles multiple directives in a single message", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off\n/verbose on", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Verbose logging enabled."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("acks queue directive and persists override", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index de038bdfb..a1fed6633 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -332,7 +332,11 @@ export async function getReplyFromConfig( let parsedDirectives = parseInlineDirectives(rawBody, { modelAliases: configuredAliases, }); - if (isGroup && ctx.WasMentioned !== true && parsedDirectives.hasElevatedDirective) { + if ( + isGroup && + ctx.WasMentioned !== true && + parsedDirectives.hasElevatedDirective + ) { if (parsedDirectives.elevatedLevel !== "off") { parsedDirectives = { ...parsedDirectives, @@ -358,7 +362,6 @@ export async function getReplyFromConfig( if (noMentions.trim().length > 0) { const directiveOnlyCheck = parseInlineDirectives(noMentions, { modelAliases: configuredAliases, - disableElevated: disableElevatedInGroup, }); if (directiveOnlyCheck.cleaned.trim().length > 0) { parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 4435ef5c9..fba52f153 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -6,6 +6,7 @@ import { getCustomProviderApiKey, resolveEnvApiKey, } from "../../agents/model-auth.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -23,6 +24,7 @@ import { logVerbose } from "../../globals.js"; import { formatUsageSummaryLine, loadProviderUsageSummary, + type UsageProviderId, } from "../../infra/provider-usage.js"; import { scheduleGatewaySigusr1Restart, @@ -37,7 +39,6 @@ import { normalizeCommandBody, shouldHandleTextCommands, } from "../commands-registry.js"; -import { normalizeProviderId } from "../../agents/model-selection.js"; import { normalizeGroupActivation, parseActivationCommand, @@ -63,6 +64,22 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; import { incrementCompactionCount } from "./session-updates.js"; +const usageProviderMap: Record = { + anthropic: "anthropic", + "github-copilot": "github-copilot", + "google-antigravity": "google-antigravity", + "google-gemini-cli": "google-gemini-cli", + google: "google-gemini-cli", + openai: "openai-codex", + "openai-codex": "openai-codex", + zai: "zai", +}; + +function resolveUsageProviderId(provider: string): UsageProviderId | undefined { + const normalized = normalizeProviderId(provider); + return usageProviderMap[normalized]; +} + function resolveSessionEntryForKey( store: Record | undefined, sessionKey: string | undefined, @@ -423,11 +440,14 @@ export async function handleCommands(params: { } let usageLine: string | null = null; try { - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - providers: [normalizeProviderId(provider)], - }); - usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + const usageProvider = resolveUsageProviderId(provider); + if (usageProvider) { + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + providers: [usageProvider], + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + } } catch { usageLine = null; } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 7ac76b62c..fe6035c88 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -79,6 +79,24 @@ describe("buildStatusMessage", () => { expect(text).toContain("Queue: collect"); }); + it("shows verbose/elevated labels only when enabled", () => { + const text = buildStatusMessage({ + agent: { model: "anthropic/claude-opus-4-5" }, + sessionEntry: { sessionId: "v1", updatedAt: 0 }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + resolvedThink: "low", + resolvedVerbose: "on", + resolvedElevated: "on", + queue: { mode: "collect", depth: 0 }, + }); + + expect(text).toContain("Verbose"); + expect(text).toContain("Elevated"); + expect(text).not.toContain("Verbose:"); + expect(text).not.toContain("Elevated:"); + }); + it("prefers model overrides over last-run model", () => { const text = buildStatusMessage({ agent: { From dfbee1037733c73bf0f8c5acdbc51d2036014663 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:23:36 +0100 Subject: [PATCH 08/13] docs: clarify sandbox non-main behavior --- docs/concepts/agent-workspace.md | 2 ++ docs/gateway/configuration.md | 1 + docs/gateway/sandboxing.md | 2 ++ docs/gateway/troubleshooting.md | 13 +++++++++++++ docs/multi-agent-sandbox-tools.md | 9 +++++++++ docs/start/getting-started.md | 17 +++++++++++++++++ 6 files changed, 44 insertions(+) diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 2b0ea5924..edf43e889 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -16,6 +16,8 @@ sessions. resolve relative paths against the workspace, but absolute paths can still reach elsewhere on the host unless sandboxing is enabled. If you need isolation, use [`agent.sandbox`](/gateway/sandboxing) (and/or per‑agent sandbox config). +When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate +inside a sandbox workspace under `~/.clawdbot/sandboxes`, not your host workspace. ## Default location diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index abad5bb7a..3125780b4 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1473,6 +1473,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto Fields: - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. + - Sandbox note: `agent.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 7f2e6ba24..93a629bbe 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -31,6 +31,8 @@ Not sandboxed: - `"off"`: no sandboxing. - `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host). - `"all"`: every session runs in a sandbox. +Note: `"non-main"` is based on `session.mainKey` (default `"main"`), not agent id. +Group/channel sessions use their own keys, so they count as non-main and will be sandboxed. ## Scope `agent.sandbox.scope` controls **how many containers** are created: diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 7d663ccae..43b636892 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -122,6 +122,19 @@ or state drift because only one workspace is active. **Fix:** keep a single active workspace and archive/remove the rest. See [Agent workspace](/concepts/agent-workspace#legacy-workspace-folders). +### Main chat running in a sandbox workspace + +Symptoms: `pwd` or file tools show `~/.clawdbot/sandboxes/...` even though you +expected the host workspace. + +**Why:** `agent.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`). +Group/channel sessions use their own keys, so they are treated as non-main and +get sandbox workspaces. + +**Fix options:** +- If you want host workspaces for an agent: set `routing.agents..sandbox.mode: "off"`. +- If you want host workspace access inside sandbox: set `workspaceAccess: "rw"` for that agent. + ### "Agent was aborted" The agent was interrupted mid-response. diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 58d47cee7..d17ee98f2 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -252,6 +252,15 @@ The global `agent.workspace` and `agent.sandbox` are still supported for backwar --- +## Common Pitfall: "non-main" + +`sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), +not the agent id. Group/channel sessions always get their own keys, so they +are treated as non-main and will be sandboxed. If you want an agent to never +sandbox, set `routing.agents..sandbox.mode: "off"`. + +--- + ## Testing After configuring multi-agent sandbox and tools: diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 4104da617..d523ba71c 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -19,6 +19,23 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security). +Sandboxing note: `agent.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`), +so group/channel sessions are sandboxed. If you want the main agent to always +run on host, set an explicit per-agent override: + +```json +{ + "routing": { + "agents": { + "main": { + "workspace": "~/clawd", + "sandbox": { "mode": "off" } + } + } + } +} +``` + ## 0) Prereqs - Node `>=22` From 151523f47b9b0ef9e2d63cbcdcc6621d91b60dfc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 02:21:17 +0000 Subject: [PATCH 09/13] feat: add usage cost reporting --- CHANGELOG.md | 1 + README.md | 15 +- .../ClawdbotProtocol/GatewayModels.swift | 55 +++++- docs/concepts/usage-tracking.md | 3 +- docs/docs.json | 1 + docs/token-use.md | 72 ++++++++ docs/tools/skills.md | 17 ++ docs/tools/slash-commands.md | 2 + docs/tui.md | 1 + docs/web/tui.md | 1 + scripts/clawtributors-map.json | 1 + src/agents/model-auth.ts | 32 ++++ src/auto-reply/commands-registry.ts | 7 + src/auto-reply/reply.triggers.test.ts | 2 +- src/auto-reply/reply/agent-runner.ts | 122 +++++++++++-- src/auto-reply/reply/commands.ts | 90 +++++----- src/auto-reply/reply/session.ts | 1 + src/auto-reply/status.test.ts | 58 +++---- src/auto-reply/status.ts | 164 ++++++++---------- src/auto-reply/thinking.ts | 14 ++ src/config/sessions.ts | 1 + src/gateway/protocol/schema.ts | 3 + src/gateway/server-methods/sessions.ts | 24 +++ src/gateway/session-utils.ts | 4 + src/tui/commands.ts | 9 + src/tui/gateway-chat.ts | 4 + src/tui/tui.ts | 47 ++++- src/utils/usage-format.test.ts | 60 +++++++ src/utils/usage-format.ts | 69 ++++++++ 29 files changed, 696 insertions(+), 184 deletions(-) create mode 100644 docs/token-use.md create mode 100644 src/utils/usage-format.test.ts create mode 100644 src/utils/usage-format.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fb77924..74de86ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123 - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj +- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). diff --git a/README.md b/README.md index a15d2bcf4..52fe98fc7 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): -- `/status` — health + session info (group shows activation mode) +- `/status` — compact session status (model + tokens, cost when available) - `/new` or `/reset` — reset the session - `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high - `/verbose on|off` +- `/cost on|off` — append per-response token/cost usage lines - `/restart` — restart the gateway (owner-only in groups) - `/activation mention|always` — group activation toggle (groups only) @@ -460,10 +461,10 @@ Thanks to all clawtributors: claude scald andranik-sahakyan nachx639 sircrumpet rafaelreis-r meaningfool ratulsarna lutr0 abhisekbasu1 emanuelst osolmaz kiranjd thewilloftheshadow CashWilliams manuelhettich minghinmatthewlam buddyh sheeek timkrase mcinteerj azade-c imfing petter-b RandyVentures Yurii Chukhlib jalehman obviyus dan-dr iamadig - manmal ogulcancelik VACInc zats Django Navarro L36 Server pcty-nextgen-service-account Syhids erik-agens fcatuhe - jayhickey jonasjancarik Jonathan D. Rhyne (DJ-D) jverdi mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC - alejandro maza antons Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna - Jarvis Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch neist nexty5870 - ngutman onutc prathamdby RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo - Erik Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres reeltimeapps Tobias Bischoff William Stock + manmal VACInc zats Django Navarro L36 Server pcty-nextgen-service-account Syhids erik-agens fcatuhe jayhickey + Jonathan D. Rhyne (DJ-D) jverdi mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons + Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik + Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch neist nexty5870 ngutman + onutc prathamdby reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo + Erik latitudeki5223 Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock

diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 9a4761215..713239414 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -664,19 +664,22 @@ public struct SessionsListParams: Codable, Sendable { public let includeglobal: Bool? public let includeunknown: Bool? public let spawnedby: String? + public let agentid: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, - spawnedby: String? + spawnedby: String?, + agentid: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown self.spawnedby = spawnedby + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case limit @@ -684,6 +687,7 @@ public struct SessionsListParams: Codable, Sendable { case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" case spawnedby = "spawnedBy" + case agentid = "agentId" } } @@ -692,6 +696,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let thinkinglevel: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? + public let responseusage: AnyCodable? public let elevatedlevel: AnyCodable? public let model: AnyCodable? public let spawnedby: AnyCodable? @@ -703,6 +708,7 @@ public struct SessionsPatchParams: Codable, Sendable { thinkinglevel: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, + responseusage: AnyCodable?, elevatedlevel: AnyCodable?, model: AnyCodable?, spawnedby: AnyCodable?, @@ -713,6 +719,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.thinkinglevel = thinkinglevel self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel + self.responseusage = responseusage self.elevatedlevel = elevatedlevel self.model = model self.spawnedby = spawnedby @@ -724,6 +731,7 @@ public struct SessionsPatchParams: Codable, Sendable { case thinkinglevel = "thinkingLevel" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" + case responseusage = "responseUsage" case elevatedlevel = "elevatedLevel" case model case spawnedby = "spawnedBy" @@ -1100,6 +1108,51 @@ public struct WebLoginWaitParams: Codable, Sendable { } } +public struct AgentSummary: Codable, Sendable { + public let id: String + public let name: String? + + public init( + id: String, + name: String? + ) { + self.id = id + self.name = name + } + private enum CodingKeys: String, CodingKey { + case id + case name + } +} + +public struct AgentsListParams: Codable, Sendable { +} + +public struct AgentsListResult: Codable, Sendable { + public let defaultid: String + public let mainkey: String + public let scope: AnyCodable + public let agents: [AgentSummary] + + public init( + defaultid: String, + mainkey: String, + scope: AnyCodable, + agents: [AgentSummary] + ) { + self.defaultid = defaultid + self.mainkey = mainkey + self.scope = scope + self.agents = agents + } + private enum CodingKeys: String, CodingKey { + case defaultid = "defaultId" + case mainkey = "mainKey" + case scope + case agents + } +} + public struct ModelChoice: Codable, Sendable { public let id: String public let name: String diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 9921567c5..84329a656 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -11,7 +11,8 @@ read_when: - No estimated costs; only the provider-reported windows. ## Where it shows up -- `/status` in chats: adds a short “Usage” line (only if available). +- `/status` in chats: compact one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available. +- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: “Usage” section under Context (only if available). diff --git a/docs/docs.json b/docs/docs.json index 84c3179aa..b74b08aab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -556,6 +556,7 @@ "concepts/agent", "concepts/agent-loop", "concepts/system-prompt", + "token-use", "concepts/oauth", "concepts/agent-workspace", "concepts/multi-agent", diff --git a/docs/token-use.md b/docs/token-use.md new file mode 100644 index 000000000..d142dcfc4 --- /dev/null +++ b/docs/token-use.md @@ -0,0 +1,72 @@ +--- +summary: "How Clawdbot builds prompt context and reports token usage + costs" +read_when: + - Explaining token usage, costs, or context windows + - Debugging context growth or compaction behavior +--- +# Token use & costs + +Clawdbot tracks **tokens**, not characters. Tokens are model-specific, but most +OpenAI-style models average ~4 characters per token for English text. + +## How the system prompt is built + +Clawdbot assembles its own system prompt on every run. It includes: + +- Tool list + short descriptions +- Skills list (only metadata; instructions are loaded on demand with `read`) +- Self-update instructions +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new) +- Time (UTC + user timezone) +- Reply tags + heartbeat behavior +- Runtime metadata (host/OS/model/thinking) + +See the full breakdown in [System Prompt](/concepts/system-prompt). + +## What counts in the context window + +Everything the model receives counts toward the context limit: + +- System prompt (all sections listed above) +- Conversation history (user + assistant messages) +- Tool calls and tool results +- Attachments/transcripts (images, audio, files) +- Compaction summaries and pruning artifacts +- Provider wrappers or safety headers (not visible, but still counted) + +## How to see current token usage + +Use these in chat: + +- `/status` → **compact one‑liner** with the session model, context usage, + last response input/output tokens, and **estimated cost** (API key only). +- `/cost on|off` → appends a **per-response usage line** to every reply. + - Persists per session (stored as `responseUsage`). + - OAuth auth **hides cost** (tokens only). + +Other surfaces: + +- **TUI/Web TUI:** `/status` + `/cost` are supported. +- **CLI:** `clawdbot status --usage` and `clawdbot providers list` show + provider quota windows (not per-response costs). + +## Cost estimation (when shown) + +Costs are estimated from your model pricing config: + +``` +models.providers..models[].cost +``` + +These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and +`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens +never show dollar cost. + +## Tips for reducing token pressure + +- Use `/compact` to summarize long sessions. +- Trim large tool outputs in your workflows. +- Keep skill descriptions short (skill list is injected into the prompt). +- Prefer smaller models for verbose, exploratory work. + +See [Skills](/tools/skills) for the exact skill list overhead formula. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 4d6e04654..f5f4e4374 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -163,6 +163,23 @@ This is **scoped to the agent run**, not a global shell environment. Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session. +## Token impact (skills list) + +When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic: + +- **Base overhead (only when ≥1 skill):** 195 characters. +- **Per skill:** 97 characters + the length of the XML-escaped ``, ``, and `` values. + +Formula (characters): + +``` +total = 195 + Σ (97 + len(name_escaped) + len(description_escaped) + len(location_escaped)) +``` + +Notes: +- XML escaping expands `& < > " '` into entities (`&`, `<`, etc.), increasing length. +- Token counts vary by model tokenizer. A rough OpenAI-style estimate is ~4 chars/token, so **97 chars ≈ 24 tokens** per skill plus your actual field lengths. + ## Managed skills lifecycle Clawdbot ships a baseline set of skills as **bundled skills** as part of the diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 7e06c4abb..6b306b798 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -35,6 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` - `/status` +- `/cost on|off` (toggle per-response usage line) - `/stop` - `/restart` - `/activation mention|always` (groups only) @@ -52,6 +53,7 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). +- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. diff --git a/docs/tui.md b/docs/tui.md index c164ec129..6b5207d76 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -77,6 +77,7 @@ Session controls: - `/think ` - `/verbose ` - `/reasoning ` +- `/cost ` - `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/docs/web/tui.md b/docs/web/tui.md index b4daa0e5a..5135a4cf6 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -53,6 +53,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/think ` - `/verbose ` - `/reasoning ` (stream = Telegram draft only) +- `/cost ` - `/elevated ` - `/elev ` - `/activation ` diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index bafdd13f1..5d75a5e8a 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -1,6 +1,7 @@ { "ensureLogins": [ "jdrhyne", + "latitudeki5223", "manmal" ], "seedCommit": "d6863f87", diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1716f7800..4390747ac 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -100,6 +100,7 @@ export async function resolveApiKeyForProvider(params: { } export type EnvApiKeyResult = { apiKey: string; source: string }; +export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown"; export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const applied = new Set(getShellEnvAppliedKeys()); @@ -143,6 +144,37 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return pick(envVar); } +export function resolveModelAuthMode( + provider?: string, + cfg?: ClawdbotConfig, + store?: AuthProfileStore, +): ModelAuthMode | undefined { + const resolved = provider?.trim(); + if (!resolved) return undefined; + + const authStore = store ?? ensureAuthProfileStore(); + const profiles = listProfilesForProvider(authStore, resolved); + if (profiles.length > 0) { + const modes = new Set( + profiles + .map((id) => authStore.profiles[id]?.type) + .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), + ); + if (modes.has("oauth") && modes.has("api_key")) return "mixed"; + if (modes.has("oauth")) return "oauth"; + if (modes.has("api_key")) return "api-key"; + } + + const envKey = resolveEnvApiKey(resolved); + if (envKey?.apiKey) { + return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; + } + + if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; + + return "unknown"; +} + export async function getApiKeyForModel(params: { model: Model; cfg?: ClawdbotConfig; diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 8fbbe611e..80dd5b892 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -27,6 +27,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ description: "Show current status.", textAliases: ["/status"], }, + { + key: "cost", + nativeName: "cost", + description: "Toggle per-response usage line.", + textAliases: ["/cost"], + acceptsArgs: true, + }, { key: "stop", nativeName: "stop", diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 371da1a8c..da3a52c33 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -212,7 +212,7 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("ClawdBot"); + expect(text).toContain("status"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 1225491b2..076b92fa2 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -2,12 +2,13 @@ import crypto from "node:crypto"; import fs from "node:fs"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { queueEmbeddedPiMessage, runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; -import { hasNonzeroUsage } from "../../agents/usage.js"; +import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; import { loadSessionStore, resolveSessionTranscriptPath, @@ -18,6 +19,12 @@ import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, + resolveModelCostConfig, +} from "../../utils/usage-format.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; @@ -62,6 +69,65 @@ const formatBunFetchSocketError = (message: string) => { ].join("\n"); }; +const formatResponseUsageLine = (params: { + usage?: NormalizedUsage; + showCost: boolean; + costConfig?: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +}): string | null => { + const usage = params.usage; + if (!usage) return null; + const input = usage.input; + const output = usage.output; + if (typeof input !== "number" && typeof output !== "number") return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + const cost = + params.showCost && typeof input === "number" && typeof output === "number" + ? estimateUsageCost({ + usage: { + input, + output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + }, + cost: params.costConfig, + }) + : undefined; + const costLabel = params.showCost ? formatUsd(cost) : undefined; + const suffix = costLabel ? ` · est ${costLabel}` : ""; + return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`; +}; + +const appendUsageLine = ( + payloads: ReplyPayload[], + line: string, +): ReplyPayload[] => { + let index = -1; + for (let i = payloads.length - 1; i >= 0; i -= 1) { + if (payloads[i]?.text) { + index = i; + break; + } + } + if (index === -1) return [...payloads, { text: line }]; + const existing = payloads[index]; + const existingText = existing.text ?? ""; + const separator = existingText.endsWith("\n") ? "" : "\n"; + const next = { + ...existing, + text: `${existingText}${separator}${line}`, + }; + const updated = payloads.slice(); + updated[index] = next; + return updated; +}; + const withTimeout = async ( promise: Promise, timeoutMs: number, @@ -191,6 +257,7 @@ export async function runReplyAgent(params: { replyToChannel, ); const applyReplyToMode = createReplyToModeFilter(replyToMode); + const cfg = followupRun.run.config; if (shouldSteer && isStreaming) { const steered = queueEmbeddedPiMessage( @@ -242,6 +309,7 @@ export async function runReplyAgent(params: { let didLogHeartbeatStrip = false; let autoCompactionCompleted = false; + let responseUsageLine: string | undefined; try { const runId = crypto.randomUUID(); if (sessionKey) { @@ -641,20 +709,20 @@ export async function runReplyAgent(params: { await typingSignals.signalRunStart(); } - if (sessionStore && sessionKey) { - const usage = runResult.meta.agentMeta?.usage; - const modelUsed = - runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; - const providerUsed = - runResult.meta.agentMeta?.provider ?? - fallbackProvider ?? - followupRun.run.provider; - const contextTokensUsed = - agentCfgContextTokens ?? - lookupContextTokens(modelUsed) ?? - sessionEntry?.contextTokens ?? - DEFAULT_CONTEXT_TOKENS; + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = + runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; + const providerUsed = + runResult.meta.agentMeta?.provider ?? + fallbackProvider ?? + followupRun.run.provider; + const contextTokensUsed = + agentCfgContextTokens ?? + lookupContextTokens(modelUsed) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + if (sessionStore && sessionKey) { if (hasNonzeroUsage(usage)) { const entry = sessionEntry ?? sessionStore[sessionKey]; if (entry) { @@ -694,6 +762,29 @@ export async function runReplyAgent(params: { } } + const responseUsageEnabled = + (sessionEntry?.responseUsage ?? + (sessionKey + ? sessionStore?.[sessionKey]?.responseUsage + : undefined)) === "on"; + if (responseUsageEnabled && hasNonzeroUsage(usage)) { + const authMode = resolveModelAuthMode(providerUsed, cfg); + const showCost = authMode === "api-key"; + const costConfig = showCost + ? resolveModelCostConfig({ + provider: providerUsed, + model: modelUsed, + config: cfg, + }) + : undefined; + const formatted = formatResponseUsageLine({ + usage, + showCost, + costConfig, + }); + if (formatted) responseUsageLine = formatted; + } + // If verbose is enabled and this is a new session, prepend a session hint. let finalPayloads = replyPayloads; if (autoCompactionCompleted) { @@ -717,6 +808,9 @@ export async function runReplyAgent(params: { ...finalPayloads, ]; } + if (responseUsageLine) { + finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); + } return finalizeWithFollowup( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index fba52f153..14289f6c9 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,11 +1,5 @@ -import { - ensureAuthProfileStore, - listProfilesForProvider, -} from "../../agents/auth-profiles.js"; -import { - getCustomProviderApiKey, - resolveEnvApiKey, -} from "../../agents/model-auth.js"; +import crypto from "node:crypto"; +import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, @@ -55,8 +49,10 @@ import type { ElevatedLevel, ReasoningLevel, ThinkLevel, + UsageDisplayLevel, VerboseLevel, } from "../thinking.js"; +import { normalizeUsageDisplay } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import type { InlineDirectives } from "./directive-handling.js"; @@ -109,36 +105,6 @@ export type CommandContext = { to?: string; }; -function resolveModelAuthLabel( - provider?: string, - cfg?: ClawdbotConfig, -): string | undefined { - const resolved = provider?.trim(); - if (!resolved) return undefined; - - const store = ensureAuthProfileStore(); - const profiles = listProfilesForProvider(store, resolved); - if (profiles.length > 0) { - const modes = new Set( - profiles - .map((id) => store.profiles[id]?.type) - .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), - ); - if (modes.has("oauth") && modes.has("api_key")) return "mixed"; - if (modes.has("oauth")) return "oauth"; - if (modes.has("api_key")) return "api-key"; - } - - const envKey = resolveEnvApiKey(resolved); - if (envKey?.apiKey) { - return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; - } - - if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; - - return "unknown"; -} - function extractCompactInstructions(params: { rawBody?: string; ctx: MsgContext; @@ -468,6 +434,7 @@ export async function handleCommands(params: { defaultGroupActivation()) : undefined; const statusText = buildStatusMessage({ + config: cfg, agent: { ...cfg.agent, model: { @@ -488,7 +455,7 @@ export async function handleCommands(params: { resolvedVerbose: resolvedVerboseLevel, resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg), + modelAuth: resolveModelAuthMode(provider, cfg), usageLine: usageLine ?? undefined, queue: { mode: queueSettings.mode, @@ -503,6 +470,51 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: statusText } }; } + const costRequested = + command.commandBodyNormalized === "/cost" || + command.commandBodyNormalized.startsWith("/cost "); + if (allowTextCommands && costRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /cost from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + const rawArgs = command.commandBodyNormalized.slice("/cost".length).trim(); + const normalized = + rawArgs.length > 0 ? normalizeUsageDisplay(rawArgs) : undefined; + if (rawArgs.length > 0 && !normalized) { + return { + shouldContinue: false, + reply: { text: "⚙️ Usage: /cost on|off" }, + }; + } + const current: UsageDisplayLevel = + sessionEntry?.responseUsage === "on" ? "on" : "off"; + const next = normalized ?? (current === "on" ? "off" : "on"); + if (sessionStore && sessionKey) { + const entry = sessionEntry ?? + sessionStore[sessionKey] ?? { + sessionId: crypto.randomUUID(), + updatedAt: Date.now(), + }; + if (next === "off") delete entry.responseUsage; + else entry.responseUsage = next; + entry.updatedAt = Date.now(); + sessionStore[sessionKey] = entry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + return { + shouldContinue: false, + reply: { + text: + next === "on" ? "⚙️ Usage line enabled." : "⚙️ Usage line disabled.", + }, + }; + } + const stopRequested = command.commandBodyNormalized === "/stop"; if (allowTextCommands && stopRequested) { if (!command.isAuthorizedSender) { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 65744c62e..5cf3bd8cc 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -194,6 +194,7 @@ export async function initSessionState(params: { // Persist previously stored thinking/verbose levels when present. thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, + responseUsage: baseEntry?.responseUsage, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, sendPolicy: baseEntry?.sendPolicy, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index fe6035c88..093690ea3 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -63,20 +63,18 @@ describe("buildStatusMessage", () => { resolvedThink: "medium", resolvedVerbose: "off", queue: { mode: "collect", depth: 0 }, - now: 10 * 60_000, // 10 minutes later + modelAuth: "api-key", }); - expect(text).toContain("🦞 ClawdBot"); - expect(text).toContain("🧠 Model:"); - expect(text).toContain("Runtime: direct"); - expect(text).toContain("Context: 16k/32k (50%)"); - expect(text).toContain("🧹 Compactions: 2"); - expect(text).toContain("Session: agent:main:main"); - expect(text).toContain("updated 10m ago"); - expect(text).toContain("Think: medium"); - expect(text).not.toContain("Verbose"); - expect(text).toContain("Elevated"); - expect(text).toContain("Queue: collect"); + expect(text).toContain("status agent:main:main"); + expect(text).toContain("model anthropic/pi:opus (api-key)"); + expect(text).toContain("Context 16k/32k (50%)"); + expect(text).toContain("compactions 2"); + expect(text).toContain("think medium"); + expect(text).toContain("verbose off"); + expect(text).toContain("reasoning off"); + expect(text).toContain("elevated on"); + expect(text).toContain("queue collect"); }); it("shows verbose/elevated labels only when enabled", () => { @@ -91,10 +89,8 @@ describe("buildStatusMessage", () => { queue: { mode: "collect", depth: 0 }, }); - expect(text).toContain("Verbose"); - expect(text).toContain("Elevated"); - expect(text).not.toContain("Verbose:"); - expect(text).not.toContain("Elevated:"); + expect(text).toContain("verbose on"); + expect(text).toContain("elevated on"); }); it("prefers model overrides over last-run model", () => { @@ -115,9 +111,10 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:main", sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model: openai/gpt-4.1-mini"); + expect(text).toContain("model openai/gpt-4.1-mini"); }); it("keeps provider prefix from configured model", () => { @@ -127,21 +124,23 @@ describe("buildStatusMessage", () => { }, sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5"); + expect(text).toContain("model google-antigravity/claude-sonnet-4-5"); }); it("handles missing agent config gracefully", () => { const text = buildStatusMessage({ agent: {}, sessionScope: "per-sender", - webLinked: false, + queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model:"); - expect(text).toContain("Context:"); - expect(text).toContain("Queue:"); + expect(text).toContain("model"); + expect(text).toContain("Context"); + expect(text).toContain("queue collect"); }); it("includes group activation for group sessions", () => { @@ -156,9 +155,10 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:whatsapp:group:123@g.us", sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("Activation: always"); + expect(text).toContain("activation always"); }); it("shows queue details when overridden", () => { @@ -175,10 +175,11 @@ describe("buildStatusMessage", () => { dropPolicy: "old", showDetails: true, }, + modelAuth: "api-key", }); expect(text).toContain( - "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", + "queue collect (depth 3 · debounce 2s · cap 5 · drop old)", ); }); @@ -190,12 +191,10 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, usageLine: "📊 Usage: Claude 80% left (5h)", + modelAuth: "api-key", }); - const lines = text.split("\n"); - const contextIndex = lines.findIndex((line) => line.startsWith("📚 ")); - expect(contextIndex).toBeGreaterThan(-1); - expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)"); + expect(text).toContain("📊 Usage: Claude 80% left (5h)"); }); it("prefers cached prompt tokens from the session log", async () => { @@ -255,9 +254,10 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, includeTranscriptUsage: true, + modelAuth: "api-key", }); - expect(text).toContain("Context: 1.0k/32k"); + expect(text).toContain("Context 1.0k/32k"); } finally { restoreHomeEnv(previousHome); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index fc374d83a..5ad5ec8b9 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -6,6 +6,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; +import { resolveModelAuthMode } from "../agents/model-auth.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { derivePromptTokens, @@ -14,13 +15,16 @@ import { } from "../agents/usage.js"; import type { ClawdbotConfig } from "../config/config.js"; import { - resolveMainSessionKey, resolveSessionFilePath, type SessionEntry, type SessionScope, } from "../config/sessions.js"; -import { resolveCommitHash } from "../infra/git-commit.js"; -import { VERSION } from "../version.js"; +import { + estimateUsageCost, + formatTokenCount as formatTokenCountShared, + formatUsd, + resolveModelCostConfig, +} from "../utils/usage-format.js"; import type { ElevatedLevel, ReasoningLevel, @@ -30,6 +34,8 @@ import type { type AgentConfig = NonNullable; +export const formatTokenCount = formatTokenCountShared; + type QueueStatus = { mode?: string; depth?: number; @@ -40,6 +46,7 @@ type QueueStatus = { }; type StatusArgs = { + config?: ClawdbotConfig; agent: AgentConfig; sessionEntry?: SessionEntry; sessionKey?: string; @@ -53,37 +60,20 @@ type StatusArgs = { usageLine?: string; queue?: QueueStatus; includeTranscriptUsage?: boolean; - now?: number; }; -const formatAge = (ms?: number | null) => { - if (!ms || ms < 0) return "unknown"; - const minutes = Math.round(ms / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 48) return `${hours}h ago`; - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - -const formatKTokens = (value: number) => - `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; - -export const formatTokenCount = (value: number) => formatKTokens(value); - const formatTokens = ( total: number | null | undefined, contextTokens: number | null, ) => { const ctx = contextTokens ?? null; if (total == null) { - const ctxLabel = ctx ? formatKTokens(ctx) : "?"; - return `unknown/${ctxLabel}`; + const ctxLabel = ctx ? formatTokenCount(ctx) : "?"; + return `?/${ctxLabel}`; } const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null; - const totalLabel = formatKTokens(total); - const ctxLabel = ctx ? formatKTokens(ctx) : "?"; + const totalLabel = formatTokenCount(total); + const ctxLabel = ctx ? formatTokenCount(ctx) : "?"; return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; }; @@ -171,8 +161,15 @@ const readUsageFromSessionLog = ( } }; +const formatUsagePair = (input?: number | null, output?: number | null) => { + if (input == null && output == null) return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + return `usage ${inputLabel} in / ${outputLabel} out`; +}; + export function buildStatusMessage(args: StatusArgs): string { - const now = args.now ?? Date.now(); const entry = args.sessionEntry; const resolved = resolveConfiguredModelRef({ cfg: { agent: args.agent ?? {} }, @@ -188,6 +185,8 @@ export function buildStatusMessage(args: StatusArgs): string { lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; + let inputTokens = entry?.inputTokens; + let outputTokens = entry?.outputTokens; let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0); @@ -205,6 +204,8 @@ export function buildStatusMessage(args: StatusArgs): string { if (!contextTokens && logUsage.model) { contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; } + if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input; + if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output; } } @@ -218,33 +219,6 @@ export function buildStatusMessage(args: StatusArgs): string { args.agent?.elevatedDefault ?? "on"; - const runtime = (() => { - const sandboxMode = args.agent?.sandbox?.mode ?? "off"; - if (sandboxMode === "off") return { label: "direct" }; - const sessionScope = args.sessionScope ?? "per-sender"; - const mainKey = resolveMainSessionKey({ - session: { scope: sessionScope }, - }); - const sessionKey = args.sessionKey?.trim(); - const sandboxed = sessionKey - ? sandboxMode === "all" || sessionKey !== mainKey.trim() - : false; - const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; - return { - label: `${runtime}/${sandboxMode}`, - }; - })(); - - const updatedAt = entry?.updatedAt; - const sessionLine = [ - `Session: ${args.sessionKey ?? "unknown"}`, - typeof updatedAt === "number" - ? `updated ${formatAge(now - updatedAt)}` - : "no activity", - ] - .filter(Boolean) - .join(" • "); - const isGroupSession = entry?.chatType === "group" || entry?.chatType === "room" || @@ -255,52 +229,66 @@ export function buildStatusMessage(args: StatusArgs): string { ? (args.groupActivation ?? entry?.groupActivation ?? "mention") : undefined; - const contextLine = [ - `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, - `🧹 Compactions: ${entry?.compactionCount ?? 0}`, - ] - .filter(Boolean) - .join(" · "); + const authMode = + args.modelAuth ?? resolveModelAuthMode(provider, args.config); + const showCost = authMode === "api-key"; + const costConfig = showCost + ? resolveModelCostConfig({ + provider, + model, + config: args.config, + }) + : undefined; + const hasUsage = + typeof inputTokens === "number" || typeof outputTokens === "number"; + const cost = + showCost && hasUsage + ? estimateUsageCost({ + usage: { + input: inputTokens ?? undefined, + output: outputTokens ?? undefined, + }, + cost: costConfig, + }) + : undefined; + const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; + + const parts: Array = []; + parts.push(`status ${args.sessionKey ?? "unknown"}`); + + const modelLabel = model ? `${provider}/${model}` : "unknown"; + const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : ""; + parts.push(`model ${modelLabel}${authLabel}`); + + const usagePair = formatUsagePair(inputTokens, outputTokens); + if (usagePair) parts.push(usagePair); + if (costLabel) parts.push(`cost ${costLabel}`); + + const contextSummary = formatContextUsageShort( + totalTokens && totalTokens > 0 ? totalTokens : null, + contextTokens ?? null, + ); + parts.push(contextSummary); + parts.push(`compactions ${entry?.compactionCount ?? 0}`); + parts.push(`think ${thinkLevel}`); + parts.push(`verbose ${verboseLevel}`); + parts.push(`reasoning ${reasoningLevel}`); + parts.push(`elevated ${elevatedLevel}`); + if (groupActivationValue) parts.push(`activation ${groupActivationValue}`); const queueMode = args.queue?.mode ?? "unknown"; const queueDetails = formatQueueDetails(args.queue); - const optionParts = [ - `Runtime: ${runtime.label}`, - `Think: ${thinkLevel}`, - verboseLevel === "on" ? "Verbose" : null, - reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, - elevatedLevel === "on" ? "Elevated" : null, - ]; - const optionsLine = optionParts.filter(Boolean).join(" · "); - const activationParts = [ - groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null, - `🪢 Queue: ${queueMode}${queueDetails}`, - ]; - const activationLine = activationParts.filter(Boolean).join(" · "); + parts.push(`queue ${queueMode}${queueDetails}`); - const modelLabel = model ? `${provider}/${model}` : "unknown"; - const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : ""; - const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; - const commit = resolveCommitHash(); - const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; + if (args.usageLine) parts.push(args.usageLine); - return [ - versionLine, - modelLine, - `📚 ${contextLine}`, - args.usageLine, - `🧵 ${sessionLine}`, - `⚙️ ${optionsLine}`, - activationLine, - ] - .filter(Boolean) - .join("\n"); + return parts.filter(Boolean).join(" · "); } export function buildHelpMessage(): string { return [ "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink", - "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model ", + "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off", ].join("\n"); } diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1550fde76..90ac4ff44 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type VerboseLevel = "off" | "on"; export type ElevatedLevel = "off" | "on"; export type ReasoningLevel = "off" | "on" | "stream"; +export type UsageDisplayLevel = "off" | "on"; // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel( @@ -46,6 +47,19 @@ export function normalizeVerboseLevel( return undefined; } +// Normalize response-usage display flags used to toggle cost/token lines. +export function normalizeUsageDisplay( + raw?: string | null, +): UsageDisplayLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) + return "off"; + if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) + return "on"; + return undefined; +} + // Normalize elevated flags used to toggle elevated bash permissions. export function normalizeElevatedLevel( raw?: string | null, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 93e4c0d93..6dfa7b3be 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -87,6 +87,7 @@ export type SessionEntry = { verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; + responseUsage?: "on" | "off"; providerOverride?: string; modelOverride?: string; authProfileOverride?: string; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index ac11af14c..4e2e98700 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -325,6 +325,9 @@ export const SessionsPatchParamsSchema = Type.Object( thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + responseUsage: Type.Optional( + Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Null()]), + ), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 3e86dfdb1..d3cc38f04 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -19,6 +19,7 @@ import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; import { normalizeReasoningLevel, normalizeThinkLevel, + normalizeUsageDisplay, normalizeVerboseLevel, } from "../../auto-reply/thinking.js"; import { loadConfig } from "../../config/config.js"; @@ -234,6 +235,28 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } + if ("responseUsage" in p) { + const raw = p.responseUsage; + if (raw === null) { + delete next.responseUsage; + } else if (raw !== undefined) { + const normalized = normalizeUsageDisplay(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid responseUsage (use "on"|"off")', + ), + ); + return; + } + if (normalized === "off") delete next.responseUsage; + else next.responseUsage = normalized; + } + } + if ("model" in p) { const raw = p.model; if (raw === null) { @@ -394,6 +417,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, + responseUsage: entry?.responseUsage, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index dd3bb0024..6df2cf9e5 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -51,6 +51,8 @@ export type GatewaySessionRow = { inputTokens?: number; outputTokens?: number; totalTokens?: number; + responseUsage?: "on" | "off"; + modelProvider?: string; model?: string; contextTokens?: number; lastProvider?: SessionEntry["lastProvider"]; @@ -503,6 +505,8 @@ export function listSessionsFromStore(params: { inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, totalTokens: total, + responseUsage: entry?.responseUsage, + modelProvider: entry?.modelProvider, model: entry?.model, contextTokens: entry?.contextTokens, lastProvider: entry?.lastProvider, diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 6b4d29947..516ed7f89 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -64,6 +64,14 @@ export function getSlashCommands(): SlashCommand[] { (value) => ({ value, label: value }), ), }, + { + name: "cost", + description: "Toggle per-response usage line", + getArgumentCompletions: (prefix) => + TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, { name: "elevated", description: "Set elevated on/off", @@ -116,6 +124,7 @@ export function helpText(): string { "/think ", "/verbose ", "/reasoning ", + "/cost ", "/elevated ", "/elev ", "/activation ", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 2b9f0c65b..bd8afd21c 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -44,7 +44,11 @@ export type GatewaySessionList = { sendPolicy?: string; model?: string; contextTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; totalTokens?: number | null; + responseUsage?: "on" | "off"; + modelProvider?: string; displayName?: string; provider?: string; room?: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 03d2cc86e..5a189ba39 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -6,12 +6,14 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; +import { normalizeUsageDisplay } from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; +import { formatTokenCount } from "../utils/usage-format.js"; import { getSlashCommands, helpText, parseCommand } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; @@ -52,8 +54,12 @@ type SessionInfo = { verboseLevel?: string; reasoningLevel?: string; model?: string; + modelProvider?: string; contextTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; totalTokens?: number | null; + responseUsage?: "on" | "off"; updatedAt?: number | null; displayName?: string; }; @@ -99,13 +105,16 @@ function extractTextFromMessage( } function formatTokens(total?: number | null, context?: number | null) { - if (!total && !context) return "tokens ?"; - if (!context) return `tokens ${total ?? 0}`; + if (total == null && context == null) return "tokens ?"; + const totalLabel = total == null ? "?" : formatTokenCount(total); + if (context == null) return `tokens ${totalLabel}`; const pct = typeof total === "number" && context > 0 ? Math.min(999, Math.round((total / context) * 100)) : null; - return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`; + return `tokens ${totalLabel}/${formatTokenCount(context)}${ + pct !== null ? ` (${pct}%)` : "" + }`; } function asString(value: unknown, fallback = ""): string { @@ -213,7 +222,11 @@ export async function runTui(opts: TuiOptions) { ? `${sessionKeyLabel} (${sessionInfo.displayName})` : sessionKeyLabel; const agentLabel = formatAgentLabel(currentAgentId); - const modelLabel = sessionInfo.model ?? "unknown"; + const modelLabel = sessionInfo.model + ? sessionInfo.modelProvider + ? `${sessionInfo.modelProvider}/${sessionInfo.model}` + : sessionInfo.model + : "unknown"; const tokens = formatTokens( sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null, @@ -321,8 +334,12 @@ export async function runTui(opts: TuiOptions) { verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, model: entry?.model ?? result.defaults?.model ?? undefined, + modelProvider: entry?.modelProvider, contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, + inputTokens: entry?.inputTokens ?? null, + outputTokens: entry?.outputTokens ?? null, totalTokens: entry?.totalTokens ?? null, + responseUsage: entry?.responseUsage, updatedAt: entry?.updatedAt ?? null, displayName: entry?.displayName, }; @@ -773,6 +790,28 @@ export async function runTui(opts: TuiOptions) { chatLog.addSystem(`reasoning failed: ${String(err)}`); } break; + case "cost": { + const normalized = args ? normalizeUsageDisplay(args) : undefined; + if (args && !normalized) { + chatLog.addSystem("usage: /cost "); + break; + } + const current = sessionInfo.responseUsage === "on" ? "on" : "off"; + const next = normalized ?? (current === "on" ? "off" : "on"); + try { + await client.patchSession({ + key: currentSessionKey, + responseUsage: next === "off" ? null : next, + }); + chatLog.addSystem( + next === "on" ? "usage line enabled" : "usage line disabled", + ); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`cost failed: ${String(err)}`); + } + break; + } case "elevated": if (!args) { chatLog.addSystem("usage: /elevated "); diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts new file mode 100644 index 000000000..d77a89356 --- /dev/null +++ b/src/utils/usage-format.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, + resolveModelCostConfig, +} from "./usage-format.js"; + +describe("usage-format", () => { + it("formats token counts", () => { + expect(formatTokenCount(999)).toBe("999"); + expect(formatTokenCount(1234)).toBe("1.2k"); + expect(formatTokenCount(12000)).toBe("12k"); + expect(formatTokenCount(2_500_000)).toBe("2.5m"); + }); + + it("formats USD values", () => { + expect(formatUsd(1.234)).toBe("$1.23"); + expect(formatUsd(0.5)).toBe("$0.50"); + expect(formatUsd(0.0042)).toBe("$0.0042"); + }); + + it("resolves model cost config and estimates usage cost", () => { + const config = { + models: { + providers: { + test: { + models: [ + { + id: "m1", + cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 }, + }, + ], + }, + }, + }, + } as ClawdbotConfig; + + const cost = resolveModelCostConfig({ + provider: "test", + model: "m1", + config, + }); + + expect(cost).toEqual({ + input: 1, + output: 2, + cacheRead: 0.5, + cacheWrite: 0, + }); + + const total = estimateUsageCost({ + usage: { input: 1000, output: 500, cacheRead: 2000 }, + cost, + }); + + expect(total).toBeCloseTo(0.003); + }); +}); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts new file mode 100644 index 000000000..3d391b1a1 --- /dev/null +++ b/src/utils/usage-format.ts @@ -0,0 +1,69 @@ +import type { NormalizedUsage } from "../agents/usage.js"; +import type { ClawdbotConfig } from "../config/config.js"; + +export type ModelCostConfig = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +}; + +export type UsageTotals = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; +}; + +export function formatTokenCount(value?: number): string { + if (value === undefined || !Number.isFinite(value)) return "0"; + const safe = Math.max(0, value); + if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}m`; + if (safe >= 1_000) + return `${(safe / 1_000).toFixed(safe >= 10_000 ? 0 : 1)}k`; + return String(Math.round(safe)); +} + +export function formatUsd(value?: number): string | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + if (value >= 1) return `$${value.toFixed(2)}`; + if (value >= 0.01) return `$${value.toFixed(2)}`; + return `$${value.toFixed(4)}`; +} + +export function resolveModelCostConfig(params: { + provider?: string; + model?: string; + config?: ClawdbotConfig; +}): ModelCostConfig | undefined { + const provider = params.provider?.trim(); + const model = params.model?.trim(); + if (!provider || !model) return undefined; + const providers = params.config?.models?.providers ?? {}; + const entry = providers[provider]?.models?.find((item) => item.id === model); + return entry?.cost; +} + +const toNumber = (value: number | undefined): number => + typeof value === "number" && Number.isFinite(value) ? value : 0; + +export function estimateUsageCost(params: { + usage?: NormalizedUsage | UsageTotals | null; + cost?: ModelCostConfig; +}): number | undefined { + const usage = params.usage; + const cost = params.cost; + if (!usage || !cost) return undefined; + const input = toNumber(usage.input); + const output = toNumber(usage.output); + const cacheRead = toNumber(usage.cacheRead); + const cacheWrite = toNumber(usage.cacheWrite); + const total = + input * cost.input + + output * cost.output + + cacheRead * cost.cacheRead + + cacheWrite * cost.cacheWrite; + if (!Number.isFinite(total)) return undefined; + return total / 1_000_000; +} From 3c79d5c711c0bb9c287d66c0593a2b999d97c803 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:30:04 +0100 Subject: [PATCH 10/13] fix: keep /status usage filtering --- CHANGELOG.md | 1 + src/auto-reply/reply.triggers.test.ts | 1 + src/auto-reply/reply.ts | 1 + src/auto-reply/reply/commands.ts | 121 ++++++++++---------------- src/infra/provider-usage.ts | 10 +++ 5 files changed, 58 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74de86ff3..5e6fcd162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123 +- Auto-reply: fix /status usage summary filtering for the active provider. - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index da3a52c33..721139de6 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -20,6 +20,7 @@ const usageMocks = vi.hoisted(() => ({ providers: [], }), formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"), + resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]), })); vi.mock("../infra/provider-usage.js", () => usageMocks); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index a1fed6633..e194e7d47 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -346,6 +346,7 @@ export async function getReplyFromConfig( }; } } + const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true; const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 14289f6c9..2deb1a753 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,6 +1,11 @@ -import crypto from "node:crypto"; -import { resolveModelAuthMode } from "../../agents/model-auth.js"; -import { normalizeProviderId } from "../../agents/model-selection.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../../agents/auth-profiles.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -18,7 +23,7 @@ import { logVerbose } from "../../globals.js"; import { formatUsageSummaryLine, loadProviderUsageSummary, - type UsageProviderId, + resolveUsageProviderId, } from "../../infra/provider-usage.js"; import { scheduleGatewaySigusr1Restart, @@ -49,10 +54,8 @@ import type { ElevatedLevel, ReasoningLevel, ThinkLevel, - UsageDisplayLevel, VerboseLevel, } from "../thinking.js"; -import { normalizeUsageDisplay } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import type { InlineDirectives } from "./directive-handling.js"; @@ -60,22 +63,6 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; import { incrementCompactionCount } from "./session-updates.js"; -const usageProviderMap: Record = { - anthropic: "anthropic", - "github-copilot": "github-copilot", - "google-antigravity": "google-antigravity", - "google-gemini-cli": "google-gemini-cli", - google: "google-gemini-cli", - openai: "openai-codex", - "openai-codex": "openai-codex", - zai: "zai", -}; - -function resolveUsageProviderId(provider: string): UsageProviderId | undefined { - const normalized = normalizeProviderId(provider); - return usageProviderMap[normalized]; -} - function resolveSessionEntryForKey( store: Record | undefined, sessionKey: string | undefined, @@ -105,6 +92,36 @@ export type CommandContext = { to?: string; }; +function resolveModelAuthLabel( + provider?: string, + cfg?: ClawdbotConfig, +): string | undefined { + const resolved = provider?.trim(); + if (!resolved) return undefined; + + const store = ensureAuthProfileStore(); + const profiles = listProfilesForProvider(store, resolved); + if (profiles.length > 0) { + const modes = new Set( + profiles + .map((id) => store.profiles[id]?.type) + .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), + ); + if (modes.has("oauth") && modes.has("api_key")) return "mixed"; + if (modes.has("oauth")) return "oauth"; + if (modes.has("api_key")) return "api-key"; + } + + const envKey = resolveEnvApiKey(resolved); + if (envKey?.apiKey) { + return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; + } + + if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; + + return "unknown"; +} + function extractCompactInstructions(params: { rawBody?: string; ctx: MsgContext; @@ -407,13 +424,11 @@ export async function handleCommands(params: { let usageLine: string | null = null; try { const usageProvider = resolveUsageProviderId(provider); - if (usageProvider) { - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - providers: [usageProvider], - }); - usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); - } + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + providers: usageProvider ? [usageProvider] : [], + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); } catch { usageLine = null; } @@ -434,7 +449,6 @@ export async function handleCommands(params: { defaultGroupActivation()) : undefined; const statusText = buildStatusMessage({ - config: cfg, agent: { ...cfg.agent, model: { @@ -455,7 +469,7 @@ export async function handleCommands(params: { resolvedVerbose: resolvedVerboseLevel, resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthMode(provider, cfg), + modelAuth: resolveModelAuthLabel(provider, cfg), usageLine: usageLine ?? undefined, queue: { mode: queueSettings.mode, @@ -470,51 +484,6 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: statusText } }; } - const costRequested = - command.commandBodyNormalized === "/cost" || - command.commandBodyNormalized.startsWith("/cost "); - if (allowTextCommands && costRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /cost from unauthorized sender: ${command.senderE164 || ""}`, - ); - return { shouldContinue: false }; - } - const rawArgs = command.commandBodyNormalized.slice("/cost".length).trim(); - const normalized = - rawArgs.length > 0 ? normalizeUsageDisplay(rawArgs) : undefined; - if (rawArgs.length > 0 && !normalized) { - return { - shouldContinue: false, - reply: { text: "⚙️ Usage: /cost on|off" }, - }; - } - const current: UsageDisplayLevel = - sessionEntry?.responseUsage === "on" ? "on" : "off"; - const next = normalized ?? (current === "on" ? "off" : "on"); - if (sessionStore && sessionKey) { - const entry = sessionEntry ?? - sessionStore[sessionKey] ?? { - sessionId: crypto.randomUUID(), - updatedAt: Date.now(), - }; - if (next === "off") delete entry.responseUsage; - else entry.responseUsage = next; - entry.updatedAt = Date.now(); - sessionStore[sessionKey] = entry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - } - return { - shouldContinue: false, - reply: { - text: - next === "on" ? "⚙️ Usage line enabled." : "⚙️ Usage line disabled.", - }, - }; - } - const stopRequested = command.commandBodyNormalized === "/stop"; if (allowTextCommands && stopRequested) { if (!command.isAuthorizedSender) { diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index 11cab33cc..bc3e220a3 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -129,6 +129,16 @@ const usageProviders: UsageProviderId[] = [ "zai", ]; +export function resolveUsageProviderId( + provider?: string | null, +): UsageProviderId | undefined { + if (!provider) return undefined; + const normalized = normalizeProviderId(provider); + return usageProviders.includes(normalized as UsageProviderId) + ? (normalized as UsageProviderId) + : undefined; +} + const ignoredErrors = new Set([ "No credentials", "No token", From 75d193a2843d5273a1d1bf7d23c9d10232d03ad3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 02:34:07 +0000 Subject: [PATCH 11/13] test: update google shared expectations --- src/providers/google-shared.test.ts | 40 ++++++++++++----------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 80d7f3889..9bf2608cc 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -46,7 +46,7 @@ describe("google-shared convertTools", () => { converted?.[0]?.functionDeclarations?.[0]?.parameters, ); - expect(params.type).toBeUndefined(); + expect(params.type).toBe("object"); expect(params.properties).toBeDefined(); expect(params.required).toEqual(["action"]); }); @@ -93,11 +93,11 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params).toHaveProperty("patternProperties"); - expect(params).toHaveProperty("additionalProperties"); - expect(mode).toHaveProperty("const"); - expect(options).toHaveProperty("anyOf"); - expect(items).toHaveProperty("const"); + expect(params.patternProperties).toBeUndefined(); + expect(params.additionalProperties).toBeUndefined(); + expect(mode.const).toBeUndefined(); + expect(options.anyOf).toBeUndefined(); + expect(items.const).toBeUndefined(); expect(params.required).toEqual(["mode"]); }); @@ -184,13 +184,7 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); - expect(contents[0].role).toBe("model"); - expect(contents[0].parts).toHaveLength(1); - expect(contents[0].parts?.[0]).toMatchObject({ - thought: true, - thoughtSignature: "sig", - }); + expect(contents).toHaveLength(0); }); it("keeps thought signatures for Claude models", () => { @@ -254,9 +248,9 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); }); it("does not merge consecutive user messages for non-Gemini Google models", () => { @@ -275,9 +269,9 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); }); it("does not merge consecutive model messages for Gemini", () => { @@ -338,10 +332,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[2].role).toBe("model"); + expect(contents[1].parts).toHaveLength(2); }); it("handles user message after tool result without model response in between", () => { @@ -398,11 +392,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(4); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); - expect(contents[3].role).toBe("user"); const toolResponsePart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, @@ -476,11 +469,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[2].role).toBe("model"); - const toolCallPart = contents[2].parts?.find( + const toolCallPart = contents[1].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); From dc81d0a6498eebe31577eae557cc247aa8cccfb0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:38:33 +0100 Subject: [PATCH 12/13] fix: improve /status auth label --- CHANGELOG.md | 1 + src/auto-reply/reply.triggers.test.ts | 64 +++++++++++++++++++++++++++ src/auto-reply/reply/commands.ts | 64 ++++++++++++++++++++------- 3 files changed, 114 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6fcd162..6c4d4ccf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Auto-reply: fix /status usage summary filtering for the active provider. - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). +- Status: show active auth profile and key snippet in /status. - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 721139de6..f6d3a1a71 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -218,6 +218,70 @@ describe("trigger handling", () => { }); }); + it("reports active auth profile and key snippet in status", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + const agentDir = join(home, ".clawdbot", "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-test-1234567890abcdef", + }, + }, + lastGood: { anthropic: "anthropic:work" }, + }, + null, + 2, + ), + ); + + const sessionKey = resolveSessionKey("per-sender", { + From: "+1002", + To: "+2000", + Provider: "whatsapp", + } as Parameters[1]); + await fs.writeFile( + cfg.session.store, + JSON.stringify( + { + [sessionKey]: { + sessionId: "session-auth", + updatedAt: Date.now(), + authProfileOverride: "anthropic:work", + }, + }, + null, + 2, + ), + ); + + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1002", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1002", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("🔑 api-key"); + expect(text).toContain("…"); + expect(text).toContain("(anthropic:work)"); + expect(text).not.toContain("mixed"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("ignores inline /status and runs the agent", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 2deb1a753..d27b685b9 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,6 +1,7 @@ import { ensureAuthProfileStore, - listProfilesForProvider, + resolveAuthProfileDisplayLabel, + resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { getCustomProviderApiKey, @@ -92,32 +93,65 @@ export type CommandContext = { to?: string; }; +function formatApiKeySnippet(apiKey: string): string { + const compact = apiKey.replace(/\s+/g, ""); + if (!compact) return "unknown"; + const edge = compact.length >= 12 ? 6 : 4; + const head = compact.slice(0, edge); + const tail = compact.slice(-edge); + return `${head}…${tail}`; +} + function resolveModelAuthLabel( provider?: string, cfg?: ClawdbotConfig, + sessionEntry?: SessionEntry, ): string | undefined { const resolved = provider?.trim(); if (!resolved) return undefined; + const providerKey = normalizeProviderId(resolved); const store = ensureAuthProfileStore(); - const profiles = listProfilesForProvider(store, resolved); - if (profiles.length > 0) { - const modes = new Set( - profiles - .map((id) => store.profiles[id]?.type) - .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), - ); - if (modes.has("oauth") && modes.has("api_key")) return "mixed"; - if (modes.has("oauth")) return "oauth"; - if (modes.has("api_key")) return "api-key"; + const profileOverride = sessionEntry?.authProfileOverride?.trim(); + const lastGood = + store.lastGood?.[providerKey] ?? store.lastGood?.[resolved]; + const order = resolveAuthProfileOrder({ + cfg, + store, + provider: providerKey, + preferredProfile: profileOverride, + }); + const candidates = [ + profileOverride, + lastGood, + ...order, + ].filter(Boolean) as string[]; + + for (const profileId of candidates) { + const profile = store.profiles[profileId]; + if (!profile || normalizeProviderId(profile.provider) !== providerKey) { + continue; + } + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (profile.type === "oauth") { + return `oauth${label ? ` (${label})` : ""}`; + } + const snippet = formatApiKeySnippet(profile.key); + return `api-key ${snippet}${label ? ` (${label})` : ""}`; } - const envKey = resolveEnvApiKey(resolved); + const envKey = resolveEnvApiKey(providerKey); if (envKey?.apiKey) { - return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; + if (envKey.source.includes("OAUTH_TOKEN")) { + return `oauth (${envKey.source})`; + } + return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`; } - if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (customKey) { + return `api-key ${formatApiKeySnippet(customKey)} (models.json)`; + } return "unknown"; } @@ -469,7 +503,7 @@ export async function handleCommands(params: { resolvedVerbose: resolvedVerboseLevel, resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg), + modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), usageLine: usageLine ?? undefined, queue: { mode: queueSettings.mode, From 0cbc5fea9330f8864c6b9982765119acdbc1a49d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:46:00 +0100 Subject: [PATCH 13/13] fix: keep status for directive-only messages --- CHANGELOG.md | 1 + src/auto-reply/reply.directive.test.ts | 33 ++++ src/auto-reply/reply.ts | 66 +++++-- src/auto-reply/reply/commands.ts | 191 +++++++++++++-------- src/auto-reply/reply/directive-handling.ts | 1 + 5 files changed, 206 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4d4ccf8..5a1efd02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - Commands: keep multi-directive messages from clearing directive handling. - Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. +- Commands: return /status in directive-only multi-line messages. - Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist ## 2026.1.8 diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 46b9eba51..da18e9ddf 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -571,6 +571,39 @@ describe("directive behavior", () => { }); }); + it("returns status alongside directive-only acks", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off\n/status", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("status agent:main:main"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("acks queue directive and persists override", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e194e7d47..eebbe2be0 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; import { applySessionHints } from "./reply/body.js"; -import { buildCommandContext, handleCommands } from "./reply/commands.js"; +import { + buildCommandContext, + buildStatusReply, + handleCommands, +} from "./reply/commands.js"; import { handleDirectiveOnly, type InlineDirectives, @@ -346,7 +350,6 @@ export async function getReplyFromConfig( }; } } - const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true; const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || @@ -483,6 +486,21 @@ export async function getReplyFromConfig( ? undefined : directives.rawModelDirective; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey, + isGroup, + triggerBodyNormalized, + commandAuthorized, + }); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); + if ( isDirectiveOnly({ directives, @@ -528,8 +546,36 @@ export async function getReplyFromConfig( currentReasoningLevel, currentElevatedLevel, }); + let statusReply: ReplyPayload | undefined; + if (directives.hasStatusDirective && allowTextCommands) { + statusReply = await buildStatusReply({ + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel: + currentThinkLevel ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined), + resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, + resolvedReasoningLevel: (currentReasoningLevel ?? + "off") as ReasoningLevel, + resolvedElevatedLevel: currentElevatedLevel, + resolveDefaultThinkingLevel: async () => + currentThinkLevel ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined), + isGroup, + defaultGroupActivation: () => defaultActivation, + }); + } typing.cleanup(); - return directiveReply; + if (statusReply?.text && directiveReply?.text) { + return { text: `${directiveReply.text}\n${statusReply.text}` }; + } + return statusReply ?? directiveReply; } const persisted = await persistInlineDirectives({ @@ -569,20 +615,6 @@ export async function getReplyFromConfig( } : undefined; - const command = buildCommandContext({ - ctx, - cfg, - agentId, - sessionKey, - isGroup, - triggerBodyNormalized, - commandAuthorized, - }); - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface: command.surface, - commandSource: ctx.CommandSource, - }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( command.isWhatsAppProvider && diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index d27b685b9..2e847c35e 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -7,6 +7,7 @@ import { getCustomProviderApiKey, resolveEnvApiKey, } from "../../agents/model-auth.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -93,6 +94,110 @@ export type CommandContext = { to?: string; }; +export async function buildStatusReply(params: { + cfg: ClawdbotConfig; + command: CommandContext; + sessionEntry?: SessionEntry; + sessionKey?: string; + sessionScope?: SessionScope; + provider: string; + model: string; + contextTokens: number; + resolvedThinkLevel?: ThinkLevel; + resolvedVerboseLevel: VerboseLevel; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel?: ElevatedLevel; + resolveDefaultThinkingLevel: () => Promise; + isGroup: boolean; + defaultGroupActivation: () => "always" | "mention"; +}): Promise { + const { + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, + } = params; + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, + ); + return undefined; + } + let usageLine: string | null = null; + try { + const usageProvider = resolveUsageProviderId(provider); + if (usageProvider) { + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + providers: [usageProvider], + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + } + } catch { + usageLine = null; + } + const queueSettings = resolveQueueSettings({ + cfg, + provider: command.provider, + sessionEntry, + }); + const queueKey = sessionKey ?? sessionEntry?.sessionId; + const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; + const queueOverrides = Boolean( + sessionEntry?.queueDebounceMs ?? + sessionEntry?.queueCap ?? + sessionEntry?.queueDrop, + ); + const groupActivation = isGroup + ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? + defaultGroupActivation()) + : undefined; + const statusText = buildStatusMessage({ + agent: { + ...cfg.agent, + model: { + ...cfg.agent?.model, + primary: `${provider}/${model}`, + }, + contextTokens, + thinkingDefault: cfg.agent?.thinkingDefault, + verboseDefault: cfg.agent?.verboseDefault, + elevatedDefault: cfg.agent?.elevatedDefault, + }, + sessionEntry, + sessionKey, + sessionScope, + groupActivation, + resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + resolvedVerbose: resolvedVerboseLevel, + resolvedReasoning: resolvedReasoningLevel, + resolvedElevated: resolvedElevatedLevel, + modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), + usageLine: usageLine ?? undefined, + queue: { + mode: queueSettings.mode, + depth: queueDepth, + debounceMs: queueSettings.debounceMs, + cap: queueSettings.cap, + dropPolicy: queueSettings.dropPolicy, + showDetails: queueOverrides, + }, + includeTranscriptUsage: false, + }); + return { text: statusText }; +} + function formatApiKeySnippet(apiKey: string): string { const compact = apiKey.replace(/\s+/g, ""); if (!compact) return "unknown"; @@ -113,19 +218,16 @@ function resolveModelAuthLabel( const providerKey = normalizeProviderId(resolved); const store = ensureAuthProfileStore(); const profileOverride = sessionEntry?.authProfileOverride?.trim(); - const lastGood = - store.lastGood?.[providerKey] ?? store.lastGood?.[resolved]; + const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved]; const order = resolveAuthProfileOrder({ cfg, store, provider: providerKey, preferredProfile: profileOverride, }); - const candidates = [ - profileOverride, - lastGood, - ...order, - ].filter(Boolean) as string[]; + const candidates = [profileOverride, lastGood, ...order].filter( + Boolean, + ) as string[]; for (const profileId of candidates) { const profile = store.profiles[profileId]; @@ -449,73 +551,24 @@ export async function handleCommands(params: { directives.hasStatusDirective || command.commandBodyNormalized === "/status"; if (allowTextCommands && statusRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, - ); - return { shouldContinue: false }; - } - let usageLine: string | null = null; - try { - const usageProvider = resolveUsageProviderId(provider); - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - providers: usageProvider ? [usageProvider] : [], - }); - usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); - } catch { - usageLine = null; - } - const queueSettings = resolveQueueSettings({ + const reply = await buildStatusReply({ cfg, - provider: command.provider, - sessionEntry, - }); - const queueKey = sessionKey ?? sessionEntry?.sessionId; - const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; - const queueOverrides = Boolean( - sessionEntry?.queueDebounceMs ?? - sessionEntry?.queueCap ?? - sessionEntry?.queueDrop, - ); - const groupActivation = isGroup - ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? - defaultGroupActivation()) - : undefined; - const statusText = buildStatusMessage({ - agent: { - ...cfg.agent, - model: { - ...cfg.agent?.model, - primary: `${provider}/${model}`, - }, - contextTokens, - thinkingDefault: cfg.agent?.thinkingDefault, - verboseDefault: cfg.agent?.verboseDefault, - elevatedDefault: cfg.agent?.elevatedDefault, - }, + command, sessionEntry, sessionKey, sessionScope, - groupActivation, - resolvedThink: - resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), - resolvedVerbose: resolvedVerboseLevel, - resolvedReasoning: resolvedReasoningLevel, - resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), - usageLine: usageLine ?? undefined, - queue: { - mode: queueSettings.mode, - depth: queueDepth, - debounceMs: queueSettings.debounceMs, - cap: queueSettings.cap, - dropPolicy: queueSettings.dropPolicy, - showDetails: queueOverrides, - }, - includeTranscriptUsage: false, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, }); - return { shouldContinue: false, reply: { text: statusText } }; + return { shouldContinue: false, reply }; } const stopRequested = command.commandBodyNormalized === "/stop"; diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 6aea3a980..eb62f0d9b 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -744,6 +744,7 @@ export async function handleDirectiveOnly(params: { parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`); } const ack = parts.join(" ").trim(); + if (!ack && directives.hasStatusDirective) return undefined; return { text: ack || "OK." }; }