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:
-
-
-
-
-
-
-
+
+
+
+
+
+
+
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:
-
-
-
-
-
-
+
+
+
+
+
+
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." };
}