fix: refresh status output

This commit is contained in:
Peter Steinberger
2026-01-07 07:21:56 +01:00
parent 86fde78442
commit dc941b7e57
6 changed files with 217 additions and 103 deletions

View File

@@ -46,6 +46,7 @@
- Auto-reply: add `/reasoning on|off` to expose model reasoning blocks (italic). - Auto-reply: add `/reasoning on|off` to expose model reasoning blocks (italic).
- Auto-reply: place reasoning blocks before the final reply text when appended. - Auto-reply: place reasoning blocks before the final reply text when appended.
- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331. - Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295. - Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275. - Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.

View File

@@ -175,7 +175,7 @@ describe("trigger handling", () => {
makeCfg(home), makeCfg(home),
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status"); expect(text).toContain("ClawdBot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -25,8 +25,6 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { parseAgentSessionKey } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js"; import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
import { shouldHandleTextCommands } from "../commands-registry.js"; import { shouldHandleTextCommands } from "../commands-registry.js";
import { import {
@@ -51,6 +49,7 @@ import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js";
import type { InlineDirectives } from "./directive-handling.js"; import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
import { incrementCompactionCount } from "./session-updates.js"; import { incrementCompactionCount } from "./session-updates.js";
function resolveSessionEntryForKey( function resolveSessionEntryForKey(
@@ -384,9 +383,18 @@ export async function handleCommands(params: {
); );
return { shouldContinue: false }; return { shouldContinue: false };
} }
const webLinked = await webAuthExists(); const queueSettings = resolveQueueSettings({
const webAuthAgeMs = getWebAuthAgeMs(); cfg,
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); 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 const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? ? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation()) defaultGroupActivation())
@@ -403,11 +411,9 @@ export async function handleCommands(params: {
verboseDefault: cfg.agent?.verboseDefault, verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault, elevatedDefault: cfg.agent?.elevatedDefault,
}, },
workspaceDir,
sessionEntry, sessionEntry,
sessionKey, sessionKey,
sessionScope, sessionScope,
storePath,
groupActivation, groupActivation,
resolvedThink: resolvedThink:
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
@@ -415,9 +421,15 @@ export async function handleCommands(params: {
resolvedReasoning: resolvedReasoningLevel, resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel, resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg), modelAuth: resolveModelAuthLabel(provider, cfg),
webLinked, queue: {
webAuthAgeMs, mode: queueSettings.mode,
heartbeatSeconds, depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
}); });
return { shouldContinue: false, reply: { text: statusText } }; return { shouldContinue: false, reply: { text: statusText } };
} }

View File

@@ -586,3 +586,11 @@ export function resolveQueueSettings(params: {
dropPolicy: dropRaw, dropPolicy: dropRaw,
}; };
} }
export function getFollowupQueueDepth(key: string): number {
const cleaned = key.trim();
if (!cleaned) return 0;
const queue = FOLLOWUP_QUEUES.get(cleaned);
if (!queue) return 0;
return queue.items.length;
}

View File

@@ -26,27 +26,23 @@ describe("buildStatusMessage", () => {
}, },
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
sessionScope: "per-sender", sessionScope: "per-sender",
storePath: "/tmp/sessions.json",
resolvedThink: "medium", resolvedThink: "medium",
resolvedVerbose: "off", resolvedVerbose: "off",
queue: { mode: "collect", depth: 0 },
now: 10 * 60_000, // 10 minutes later now: 10 * 60_000, // 10 minutes later
webLinked: true,
webAuthAgeMs: 5 * 60_000,
heartbeatSeconds: 45,
}); });
expect(text).toContain("⚙️ Status"); expect(text).toContain("🦞 ClawdBot");
expect(text).toContain("Agent: embedded pi"); expect(text).toContain("🧠 Model:");
expect(text).toContain("Runtime: direct"); expect(text).toContain("Runtime: direct");
expect(text).toContain("Context: 16k/32k (50%)"); expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("🧹 Compactions: 2");
expect(text).toContain("Session: agent:main:main"); expect(text).toContain("Session: agent:main:main");
expect(text).toContain("compactions 2"); expect(text).toContain("updated 10m ago");
expect(text).toContain("Web: linked"); expect(text).toContain("Think: medium");
expect(text).toContain("heartbeat 45s"); expect(text).toContain("Verbose: off");
expect(text).toContain("thinking=medium"); expect(text).toContain("Elevated: on");
expect(text).toContain("verbose=off"); expect(text).toContain("Queue: collect");
expect(text).not.toContain("Shortcuts:");
expect(text).not.toContain("set with");
}); });
it("handles missing agent config gracefully", () => { it("handles missing agent config gracefully", () => {
@@ -56,9 +52,9 @@ describe("buildStatusMessage", () => {
webLinked: false, webLinked: false,
}); });
expect(text).toContain("Agent: embedded pi"); expect(text).toContain("🧠 Model:");
expect(text).toContain("Context:"); expect(text).toContain("Context:");
expect(text).toContain("Web: not linked"); expect(text).toContain("Queue:");
}); });
it("includes group activation for group sessions", () => { it("includes group activation for group sessions", () => {
@@ -72,10 +68,31 @@ describe("buildStatusMessage", () => {
}, },
sessionKey: "agent:main:whatsapp:group:123@g.us", sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender", sessionScope: "per-sender",
webLinked: true, queue: { mode: "collect", depth: 0 },
}); });
expect(text).toContain("Group activation: always"); expect(text).toContain("Activation: always");
});
it("shows queue details when overridden", () => {
const text = buildStatusMessage({
agent: {},
sessionEntry: { sessionId: "q1", updatedAt: 0 },
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: {
mode: "collect",
depth: 3,
debounceMs: 2000,
cap: 5,
dropPolicy: "old",
showDetails: true,
},
});
expect(text).toContain(
"Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)",
);
}); });
it("prefers cached prompt tokens from the session log", async () => { it("prefers cached prompt tokens from the session log", async () => {
@@ -88,14 +105,6 @@ describe("buildStatusMessage", () => {
"./status.js" "./status.js"
); );
const storePath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
"sessions.json",
);
const sessionId = "sess-1"; const sessionId = "sess-1";
const logPath = path.join( const logPath = path.join(
dir, dir,
@@ -141,8 +150,8 @@ describe("buildStatusMessage", () => {
}, },
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
sessionScope: "per-sender", sessionScope: "per-sender",
storePath, queue: { mode: "collect", depth: 0 },
webLinked: true, includeTranscriptUsage: true,
}); });
expect(text).toContain("Context: 1.0k/32k"); expect(text).toContain("Context: 1.0k/32k");

View File

@@ -1,4 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import { lookupContextTokens } from "../agents/context.js"; import { lookupContextTokens } from "../agents/context.js";
import { import {
@@ -19,7 +20,7 @@ import {
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { shortenHomePath } from "../utils.js"; import { VERSION } from "../version.js";
import type { import type {
ElevatedLevel, ElevatedLevel,
ReasoningLevel, ReasoningLevel,
@@ -29,23 +30,29 @@ import type {
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>; type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
type QueueStatus = {
mode?: string;
depth?: number;
debounceMs?: number;
cap?: number;
dropPolicy?: string;
showDetails?: boolean;
};
type StatusArgs = { type StatusArgs = {
agent: AgentConfig; agent: AgentConfig;
workspaceDir?: string;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionKey?: string; sessionKey?: string;
sessionScope?: SessionScope; sessionScope?: SessionScope;
storePath?: string;
groupActivation?: "mention" | "always"; groupActivation?: "mention" | "always";
resolvedThink?: ThinkLevel; resolvedThink?: ThinkLevel;
resolvedVerbose?: VerboseLevel; resolvedVerbose?: VerboseLevel;
resolvedReasoning?: ReasoningLevel; resolvedReasoning?: ReasoningLevel;
resolvedElevated?: ElevatedLevel; resolvedElevated?: ElevatedLevel;
modelAuth?: string; modelAuth?: string;
queue?: QueueStatus;
includeTranscriptUsage?: boolean;
now?: number; now?: number;
webLinked?: boolean;
webAuthAgeMs?: number | null;
heartbeatSeconds?: number;
}; };
const formatAge = (ms?: number | null) => { const formatAge = (ms?: number | null) => {
@@ -84,6 +91,97 @@ export const formatContextUsageShort = (
contextTokens: number | null | undefined, contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`; ) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatCommit = (value?: string | null) => {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed;
};
const resolveGitHead = (startDir: string) => {
let current = startDir;
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory()) {
return path.join(gitPath, "HEAD");
}
if (stat.isFile()) {
const raw = fs.readFileSync(gitPath, "utf-8");
const match = raw.match(/gitdir:\s*(.+)/i);
if (match?.[1]) {
const resolved = path.resolve(current, match[1].trim());
return path.join(resolved, "HEAD");
}
}
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
};
let cachedCommit: string | null | undefined;
const resolveCommitHash = () => {
if (cachedCommit !== undefined) return cachedCommit;
const envCommit =
process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim();
const normalized = formatCommit(envCommit);
if (normalized) {
cachedCommit = normalized;
return cachedCommit;
}
try {
const headPath = resolveGitHead(process.cwd());
if (!headPath) {
cachedCommit = null;
return cachedCommit;
}
const head = fs.readFileSync(headPath, "utf-8").trim();
if (!head) {
cachedCommit = null;
return cachedCommit;
}
if (head.startsWith("ref:")) {
const ref = head.replace(/^ref:\s*/i, "").trim();
const refPath = path.resolve(path.dirname(headPath), ref);
const refHash = fs.readFileSync(refPath, "utf-8").trim();
cachedCommit = formatCommit(refHash);
return cachedCommit;
}
cachedCommit = formatCommit(head);
return cachedCommit;
} catch {
cachedCommit = null;
return cachedCommit;
}
};
const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) return "";
const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null;
if (!queue.showDetails) {
return depth ? ` (${depth})` : "";
}
const detailParts: string[] = [];
if (depth) detailParts.push(depth);
if (typeof queue.debounceMs === "number") {
const ms = Math.max(0, Math.round(queue.debounceMs));
const label =
ms >= 1000
? `${ms % 1000 === 0 ? ms / 1000 : (ms / 1000).toFixed(1)}s`
: `${ms}ms`;
detailParts.push(`debounce ${label}`);
}
if (typeof queue.cap === "number") detailParts.push(`cap ${queue.cap}`);
if (queue.dropPolicy) detailParts.push(`drop ${queue.dropPolicy}`);
return detailParts.length ? ` (${detailParts.join(" · ")})` : "";
};
const readUsageFromSessionLog = ( const readUsageFromSessionLog = (
sessionId?: string, sessionId?: string,
): ):
@@ -164,15 +262,17 @@ export function buildStatusMessage(args: StatusArgs): string {
// Prefer prompt-size tokens from the session transcript when it looks larger // Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store). // (cached prompt tokens are often missing from agent meta/store).
const logUsage = readUsageFromSessionLog(entry?.sessionId); if (args.includeTranscriptUsage) {
if (logUsage) { const logUsage = readUsageFromSessionLog(entry?.sessionId);
const candidate = logUsage.promptTokens || logUsage.total; if (logUsage) {
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { const candidate = logUsage.promptTokens || logUsage.total;
totalTokens = candidate; if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
} totalTokens = candidate;
if (!model) model = logUsage.model ?? model; }
if (!contextTokens && logUsage.model) { if (!model) model = logUsage.model ?? model;
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
} }
} }
@@ -188,8 +288,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const runtime = (() => { const runtime = (() => {
const sandboxMode = args.agent?.sandbox?.mode ?? "off"; const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") if (sandboxMode === "off") return { label: "direct" };
return { line: "Runtime: direct", sandboxed: false };
const sessionScope = args.sessionScope ?? "per-sender"; const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({ const mainKey = resolveMainSessionKey({
session: { scope: sessionScope }, session: { scope: sessionScope },
@@ -199,35 +298,17 @@ export function buildStatusMessage(args: StatusArgs): string {
? sandboxMode === "all" || sessionKey !== mainKey.trim() ? sandboxMode === "all" || sessionKey !== mainKey.trim()
: false; : false;
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
const suffix = sandboxed ? ` • elevated ${elevatedLevel}` : "";
return { return {
line: `Runtime: ${runtime} (sandbox ${sandboxMode})${suffix}`, label: `${runtime}/${sandboxMode}`,
sandboxed,
}; };
})(); })();
const webLine = (() => { const updatedAt = entry?.updatedAt;
if (args.webLinked === false) {
return "Web: not linked — run `clawdbot login` to scan the QR.";
}
const authAge = formatAge(args.webAuthAgeMs);
const heartbeat =
typeof args.heartbeatSeconds === "number"
? ` • heartbeat ${args.heartbeatSeconds}s`
: "";
return `Web: linked • auth refreshed ${authAge}${heartbeat}`;
})();
const sessionLine = [ const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`, `Session: ${args.sessionKey ?? "unknown"}`,
`scope ${args.sessionScope ?? "per-sender"}`, typeof updatedAt === "number"
entry?.updatedAt ? `updated ${formatAge(now - updatedAt)}`
? `updated ${formatAge(now - entry.updatedAt)}`
: "no activity", : "no activity",
typeof entry?.compactionCount === "number"
? `compactions ${entry.compactionCount}`
: undefined,
args.storePath ? `store ${shortenHomePath(args.storePath)}` : undefined,
] ]
.filter(Boolean) .filter(Boolean)
.join(" • "); .join(" • ");
@@ -238,39 +319,42 @@ export function buildStatusMessage(args: StatusArgs): string {
Boolean(args.sessionKey?.includes(":group:")) || Boolean(args.sessionKey?.includes(":group:")) ||
Boolean(args.sessionKey?.includes(":channel:")) || Boolean(args.sessionKey?.includes(":channel:")) ||
Boolean(args.sessionKey?.startsWith("group:")); Boolean(args.sessionKey?.startsWith("group:"));
const groupActivationLine = isGroupSession const groupActivationValue = isGroupSession
? `Group activation: ${args.groupActivation ?? entry?.groupActivation ?? "mention"}` ? (args.groupActivation ?? entry?.groupActivation ?? "mention")
: undefined; : undefined;
const contextLine = `Context: ${formatTokens( const contextLine = [
totalTokens, `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`,
contextTokens ?? null, `🧹 Compactions: ${entry?.compactionCount ?? 0}`,
)}${entry?.abortedLastRun ? " • last run aborted" : ""}`; ]
.filter(Boolean)
.join(" · ");
const optionsLine = runtime.sandboxed const queueMode = args.queue?.mode ?? "unknown";
? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel} | elevated=${elevatedLevel}` const queueDetails = formatQueueDetails(args.queue);
: `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel}`; const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
`Verbose: ${verboseLevel}`,
reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null,
`Elevated: ${elevatedLevel}`,
groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null,
`🪢 Queue: ${queueMode}${queueDetails}`,
];
const optionsLine = optionParts.filter(Boolean).join(" · ");
const modelLabel = model ? `${provider}/${model}` : "unknown"; const modelLabel = model ? `${provider}/${model}` : "unknown";
const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : "";
const agentLine = `Agent: embedded pi • ${modelLabel}`; const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
const authLine = args.modelAuth ? `Model auth: ${args.modelAuth}` : undefined; const commit = resolveCommitHash();
const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
const workspaceLine = args.workspaceDir
? `Workspace: ${shortenHomePath(args.workspaceDir)}`
: undefined;
return [ return [
"⚙️ Status", versionLine,
webLine, modelLine,
agentLine, `📚 ${contextLine}`,
authLine, `🧵 ${sessionLine}`,
runtime.line, `⚙️ ${optionsLine}`,
workspaceLine,
contextLine,
sessionLine,
groupActivationLine,
optionsLine,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");