Compare commits

..
Author SHA1 Message Date
Peter Steinberger e2065e8d96 fix: reset session tokens on /new (#1542) (thanks @robbyczgw-cla) 2026-01-24 00:15:12 +00:00
Robby 78d9f5baff fix(sessions): reset token counts to 0 on /new (#1523)
- Set inputTokens, outputTokens, totalTokens to 0 in sessions.reset
- Clear TUI sessionInfo tokens immediately before async reset
- Prevents stale token display after session reset

Fixes #1523
2026-01-24 00:09:33 +00:00
11 changed files with 57 additions and 162 deletions

No files matched your search

+1 -3
View File
@@ -13,15 +13,13 @@ Docs: https://docs.clawd.bot
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- Sandbox: log docker image inspect failures during doctor checks. (#1548) Thanks @sweepies.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- TUI/Gateway: reset session token counters on `/new` and refresh the footer immediately. (#1542) Thanks @robbyczgw-cla.
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
+1 -2
View File
@@ -79,7 +79,6 @@ export async function runEmbeddedPiAgent(
? "markdown"
: "plain"
: "markdown");
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
@@ -456,7 +455,7 @@ export async function runEmbeddedPiAgent(
cfg: params.config,
agentDir: params.agentDir,
});
if (timedOut && !isProbeSession) {
if (timedOut) {
log.warn(
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
);
+6 -11
View File
@@ -595,23 +595,18 @@ export async function runEmbeddedAttempt(
setActiveEmbeddedRun(params.sessionId, queueHandle);
let abortWarnTimer: NodeJS.Timeout | undefined;
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
const abortTimer = setTimeout(
() => {
if (!isProbeSession) {
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
}
log.warn(
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
abortRun(true);
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!activeSession.isStreaming) return;
if (!isProbeSession) {
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}
log.warn(
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}, 10_000);
}
},
@@ -1,19 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveDockerImageInspectResult } from "./sandbox/docker.js";
describe("ensureDockerImage", () => {
it("surfaces inspect failures with detail", async () => {
const result = resolveDockerImageInspectResult("custom-sandbox:latest", {
stdout: "",
stderr: "permission denied while trying to connect to the Docker daemon socket",
code: 1,
});
expect(result).toEqual({
exists: false,
error:
"Failed to inspect sandbox image: permission denied while trying to connect to the Docker daemon socket",
});
});
});
+1 -15
View File
@@ -46,25 +46,11 @@ export async function readDockerPort(containerName: string, port: number) {
return Number.isFinite(mapped) ? mapped : null;
}
export function resolveDockerImageInspectResult(
image: string,
result: { stdout: string; stderr: string; code: number },
): { exists: boolean; error?: string } {
if (result.code === 0) return { exists: true };
const stderr = result.stderr.trim();
if (/no such image/i.test(stderr)) return { exists: false };
const stdout = result.stdout.trim();
const detail = stderr || stdout || `docker image inspect ${image} failed (exit ${result.code})`;
return { exists: false, error: `Failed to inspect sandbox image: ${detail}` };
}
async function dockerImageExists(image: string) {
const result = await execDocker(["image", "inspect", image], {
allowFailure: true,
});
const resolved = resolveDockerImageInspectResult(image, result);
if (resolved.error) throw new Error(resolved.error);
return resolved.exists;
return result.code === 0;
}
export async function ensureDockerImage(image: string) {
@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const runExec = vi.fn();
const runCommandWithTimeout = vi.fn();
const note = vi.fn();
vi.mock("../process/exec.js", () => ({
runExec,
runCommandWithTimeout,
}));
vi.mock("../terminal/note.js", () => ({
note,
}));
describe("maybeRepairSandboxImages", () => {
beforeEach(() => {
runExec.mockReset();
runCommandWithTimeout.mockReset();
note.mockReset();
});
it("logs docker inspect errors and continues", async () => {
runExec.mockImplementation(async (_command: string, args: string[]) => {
if (args[0] === "version") return { stdout: "26.0.0", stderr: "" };
if (args[0] === "image" && args[1] === "inspect") {
const err = new Error(
"permission denied while trying to connect to the Docker daemon socket",
) as Error & { stderr?: string };
err.stderr = "permission denied while trying to connect to the Docker daemon socket";
throw err;
}
return { stdout: "", stderr: "" };
});
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
const cfg = {
agents: {
defaults: {
sandbox: {
mode: "all",
docker: { image: "custom-sandbox:latest" },
},
},
},
} satisfies ClawdbotConfig;
const runtime = { log: vi.fn(), error: vi.fn() } as RuntimeEnv;
const prompter = { confirmSkipInNonInteractive: vi.fn() } as DoctorPrompter;
await expect(maybeRepairSandboxImages(cfg, runtime, prompter)).resolves.toBe(cfg);
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Unable to inspect sandbox base image (custom-sandbox:latest)"),
"Sandbox",
);
expect(runCommandWithTimeout).not.toHaveBeenCalled();
});
});
+3 -20
View File
@@ -78,15 +78,8 @@ async function dockerImageExists(image: string): Promise<boolean> {
try {
await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 });
return true;
} catch (error: any) {
const stderr = typeof error?.stderr === "string" ? error.stderr.trim() : "";
const stdout = typeof error?.stdout === "string" ? error.stdout.trim() : "";
const message = typeof error?.message === "string" ? error.message.trim() : "";
const detail = stderr || stdout || message;
if (/no such image/i.test(detail)) {
return false;
}
throw new Error(`Failed to inspect sandbox image: ${detail || "unknown error"}`);
} catch {
return false;
}
}
@@ -150,17 +143,7 @@ async function handleMissingSandboxImage(
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
let exists = false;
try {
exists = await dockerImageExists(params.image);
} catch (error: any) {
const stderr = typeof error?.stderr === "string" ? error.stderr.trim() : "";
const stdout = typeof error?.stdout === "string" ? error.stdout.trim() : "";
const message = typeof error?.message === "string" ? error.message.trim() : "";
const detail = stderr || stdout || message || "unknown error";
note(`Unable to inspect sandbox ${params.label} image (${params.image}): ${detail}`, "Sandbox");
return;
}
const exists = await dockerImageExists(params.image);
if (exists) return;
const buildHint = params.buildScript
+25 -30
View File
@@ -28,7 +28,6 @@ import {
} from "../../infra/provider-usage.js";
import type { RuntimeEnv } from "../../runtime.js";
import { colorize, theme } from "../../terminal/theme.js";
import { renderTable } from "../../terminal/table.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { shortenHomePath } from "../../utils.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
@@ -36,6 +35,7 @@ import { isRich } from "./list.format.js";
import {
describeProbeSummary,
formatProbeLatency,
groupProbeResults,
runAuthProbes,
sortProbeResults,
type AuthProbeSummary,
@@ -571,8 +571,7 @@ export async function modelsStatusCommand(
if (probeSummary.results.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
} else {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const sorted = sortProbeResults(probeSummary.results);
const grouped = groupProbeResults(sortProbeResults(probeSummary.results));
const statusColor = (status: string) => {
if (status === "ok") return theme.success;
if (status === "rate_limit") return theme.warn;
@@ -581,33 +580,29 @@ export async function modelsStatusCommand(
if (status === "no_model") return theme.muted;
return theme.muted;
};
const rows = sorted.map((result) => {
const status = colorize(rich, statusColor(result.status), result.status);
const latency = formatProbeLatency(result.latencyMs);
const detail = result.error ? colorize(rich, theme.muted, result.error) : "";
const modelLabel = result.model ?? `${result.provider}/-`;
const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`;
return {
Model: colorize(rich, theme.heading, modelLabel),
Profile: profile,
Status: statusLabel,
Detail: detail,
};
});
runtime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Model", header: "Model", minWidth: 18 },
{ key: "Profile", header: "Profile", minWidth: 24 },
{ key: "Status", header: "Status", minWidth: 12 },
{ key: "Detail", header: "Detail", minWidth: 16, flex: true },
],
rows,
}).trimEnd(),
);
for (const [provider, results] of grouped) {
const modelLabel = results.find((r) => r.model)?.model ?? "-";
runtime.log(
`- ${theme.heading(provider)}${colorize(
rich,
theme.muted,
modelLabel ? ` (model: ${modelLabel})` : "",
)}`,
);
for (const result of results) {
const status = colorize(rich, statusColor(result.status), result.status);
const latency = formatProbeLatency(result.latencyMs);
const mode = result.mode ? ` (${result.mode})` : "";
const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : "";
runtime.log(
` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize(
rich,
theme.muted,
latency,
)}${detail}`,
);
}
}
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
}
}
+4
View File
@@ -251,6 +251,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
// Reset token counts to 0 on session reset (#1523)
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
store[primaryKey] = nextEntry;
return nextEntry;
@@ -345,11 +345,19 @@ describe("gateway server sessions", () => {
const reset = await rpcReq<{
ok: true;
key: string;
entry: { sessionId: string };
entry: {
sessionId: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
}>(ws, "sessions.reset", { key: "agent:main:main" });
expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("agent:main:main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
expect(reset.payload?.entry.inputTokens).toBe(0);
expect(reset.payload?.entry.outputTokens).toBe(0);
expect(reset.payload?.entry.totalTokens).toBe(0);
const badThinking = await rpcReq(ws, "sessions.patch", {
key: "agent:main:main",
+7
View File
@@ -409,8 +409,15 @@ export function createCommandHandlers(context: CommandHandlerContext) {
case "reset":
try {
await client.resetSession(state.currentSessionKey);
// Clear token counts after reset to avoid stale display while refreshing. (#1523)
state.sessionInfo.inputTokens = 0;
state.sessionInfo.outputTokens = 0;
state.sessionInfo.totalTokens = 0;
tui.requestRender();
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory();
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`reset failed: ${String(err)}`);
}