Session/Cron maintenance hardening and cleanup UX (#24753)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 7533b85156
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
Gustavo Madeira Santana
2026-02-23 17:39:48 -05:00
committed by GitHub
parent 29b19455e3
commit eff3c5c707
49 changed files with 3180 additions and 235 deletions

View File

@@ -124,4 +124,51 @@ describe("doctor state integrity oauth dir checks", () => {
expect(confirmSkipInNonInteractive).toHaveBeenCalledWith(OAUTH_PROMPT_MATCHER);
expect(stateIntegrityText()).toContain("CRITICAL: OAuth dir missing");
});
it("detects orphan transcripts and offers archival remediation", async () => {
const cfg: OpenClawConfig = {};
setupSessionState(cfg, process.env, process.env.HOME ?? "");
const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => tempHome);
fs.writeFileSync(path.join(sessionsDir, "orphan-session.jsonl"), '{"type":"session"}\n');
const confirmSkipInNonInteractive = vi.fn(async (params: { message: string }) =>
params.message.includes("orphan transcript file"),
);
await noteStateIntegrity(cfg, { confirmSkipInNonInteractive });
expect(stateIntegrityText()).toContain("orphan transcript file");
expect(confirmSkipInNonInteractive).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("orphan transcript file"),
}),
);
const files = fs.readdirSync(sessionsDir);
expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(true);
});
it("prints openclaw-only verification hints when recent sessions are missing transcripts", async () => {
const cfg: OpenClawConfig = {};
setupSessionState(cfg, process.env, process.env.HOME ?? "");
const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" });
fs.writeFileSync(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "missing-transcript",
updatedAt: Date.now(),
},
},
null,
2,
),
);
await noteStateIntegrity(cfg, { confirmSkipInNonInteractive: vi.fn(async () => false) });
const text = stateIntegrityText();
expect(text).toContain("recent sessions are missing transcripts");
expect(text).toMatch(/openclaw sessions --store ".*sessions\.json"/);
expect(text).toMatch(/openclaw sessions cleanup --store ".*sessions\.json" --dry-run/);
expect(text).not.toContain("--active");
expect(text).not.toContain(" ls ");
});
});

View File

@@ -2,9 +2,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import {
formatSessionArchiveTimestamp,
isPrimarySessionTranscriptFileName,
loadSessionStore,
resolveMainSessionKey,
resolveSessionFilePath,
@@ -202,6 +205,7 @@ export async function noteStateIntegrity(
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const storeDir = path.dirname(storePath);
const absoluteStorePath = path.resolve(storePath);
const displayStateDir = shortenHomePath(stateDir);
const displayOauthDir = shortenHomePath(oauthDir);
const displaySessionsDir = shortenHomePath(sessionsDir);
@@ -408,7 +412,11 @@ export async function noteStateIntegrity(
});
if (missing.length > 0) {
warnings.push(
`- ${missing.length}/${recent.length} recent sessions are missing transcripts. Check for deleted session files or split state dirs.`,
[
`- ${missing.length}/${recent.length} recent sessions are missing transcripts.`,
` Verify sessions in store: ${formatCliCommand(`openclaw sessions --store "${absoluteStorePath}"`)}`,
` Preview cleanup impact: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --dry-run`)}`,
].join("\n"),
);
}
@@ -435,6 +443,54 @@ export async function noteStateIntegrity(
}
}
if (existsDir(sessionsDir)) {
const referencedTranscriptPaths = new Set<string>();
for (const [, entry] of entries) {
if (!entry?.sessionId) {
continue;
}
try {
referencedTranscriptPaths.add(
path.resolve(resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts)),
);
} catch {
// ignore invalid legacy paths
}
}
const sessionDirEntries = fs.readdirSync(sessionsDir, { withFileTypes: true });
const orphanTranscriptPaths = sessionDirEntries
.filter((entry) => entry.isFile() && isPrimarySessionTranscriptFileName(entry.name))
.map((entry) => path.resolve(path.join(sessionsDir, entry.name)))
.filter((filePath) => !referencedTranscriptPaths.has(filePath));
if (orphanTranscriptPaths.length > 0) {
warnings.push(
`- Found ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}. They are not referenced by sessions.json and can consume disk over time.`,
);
const archiveOrphans = await prompter.confirmSkipInNonInteractive({
message: `Archive ${orphanTranscriptPaths.length} orphan transcript file(s) in ${displaySessionsDir}?`,
initialValue: false,
});
if (archiveOrphans) {
let archived = 0;
const archivedAt = formatSessionArchiveTimestamp();
for (const orphanPath of orphanTranscriptPaths) {
const archivedPath = `${orphanPath}.deleted.${archivedAt}`;
try {
fs.renameSync(orphanPath, archivedPath);
archived += 1;
} catch (err) {
warnings.push(
`- Failed to archive orphan transcript ${shortenHomePath(orphanPath)}: ${String(err)}`,
);
}
}
if (archived > 0) {
changes.push(`- Archived ${archived} orphan transcript file(s) in ${displaySessionsDir}`);
}
}
}
}
if (warnings.length > 0) {
note(warnings.join("\n"), "State integrity");
}

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSessionStoreTargets } from "./session-store-targets.js";
const resolveStorePathMock = vi.hoisted(() => vi.fn());
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
const listAgentIdsMock = vi.hoisted(() => vi.fn());
vi.mock("../config/sessions.js", () => ({
resolveStorePath: resolveStorePathMock,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: resolveDefaultAgentIdMock,
listAgentIds: listAgentIdsMock,
}));
describe("resolveSessionStoreTargets", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves the default agent store when no selector is provided", () => {
resolveDefaultAgentIdMock.mockReturnValue("main");
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
const targets = resolveSessionStoreTargets({}, {});
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/main-sessions.json" }]);
expect(resolveStorePathMock).toHaveBeenCalledWith(undefined, { agentId: "main" });
});
it("resolves all configured agent stores", () => {
listAgentIdsMock.mockReturnValue(["main", "work"]);
resolveStorePathMock
.mockReturnValueOnce("/tmp/main-sessions.json")
.mockReturnValueOnce("/tmp/work-sessions.json");
const targets = resolveSessionStoreTargets(
{
session: { store: "~/.openclaw/agents/{agentId}/sessions/sessions.json" },
},
{ allAgents: true },
);
expect(targets).toEqual([
{ agentId: "main", storePath: "/tmp/main-sessions.json" },
{ agentId: "work", storePath: "/tmp/work-sessions.json" },
]);
});
it("dedupes shared store paths for --all-agents", () => {
listAgentIdsMock.mockReturnValue(["main", "work"]);
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
const targets = resolveSessionStoreTargets(
{
session: { store: "/tmp/shared-sessions.json" },
},
{ allAgents: true },
);
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]);
expect(resolveStorePathMock).toHaveBeenCalledTimes(2);
});
it("rejects unknown agent ids", () => {
listAgentIdsMock.mockReturnValue(["main", "work"]);
expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/);
});
it("rejects conflicting selectors", () => {
expect(() => resolveSessionStoreTargets({}, { agent: "main", allAgents: true })).toThrow(
/cannot be used together/i,
);
expect(() =>
resolveSessionStoreTargets({}, { store: "/tmp/sessions.json", allAgents: true }),
).toThrow(/cannot be combined/i);
});
});

View File

@@ -0,0 +1,80 @@
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveStorePath } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
export type SessionStoreSelectionOptions = {
store?: string;
agent?: string;
allAgents?: boolean;
};
export type SessionStoreTarget = {
agentId: string;
storePath: string;
};
function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] {
const deduped = new Map<string, SessionStoreTarget>();
for (const target of targets) {
if (!deduped.has(target.storePath)) {
deduped.set(target.storePath, target);
}
}
return [...deduped.values()];
}
export function resolveSessionStoreTargets(
cfg: OpenClawConfig,
opts: SessionStoreSelectionOptions,
): SessionStoreTarget[] {
const defaultAgentId = resolveDefaultAgentId(cfg);
const hasAgent = Boolean(opts.agent?.trim());
const allAgents = opts.allAgents === true;
if (hasAgent && allAgents) {
throw new Error("--agent and --all-agents cannot be used together");
}
if (opts.store && (hasAgent || allAgents)) {
throw new Error("--store cannot be combined with --agent or --all-agents");
}
if (opts.store) {
return [
{
agentId: defaultAgentId,
storePath: resolveStorePath(opts.store, { agentId: defaultAgentId }),
},
];
}
if (allAgents) {
const targets = listAgentIds(cfg).map((agentId) => ({
agentId,
storePath: resolveStorePath(cfg.session?.store, { agentId }),
}));
return dedupeTargetsByStorePath(targets);
}
if (hasAgent) {
const knownAgents = listAgentIds(cfg);
const requested = normalizeAgentId(opts.agent ?? "");
if (!knownAgents.includes(requested)) {
throw new Error(
`Unknown agent id "${opts.agent}". Use "openclaw agents list" to see configured agents.`,
);
}
return [
{
agentId: requested,
storePath: resolveStorePath(cfg.session?.store, { agentId: requested }),
},
];
}
return [
{
agentId: defaultAgentId,
storePath: resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }),
},
];
}

View File

@@ -0,0 +1,246 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
resolveSessionStoreTargets: vi.fn(),
resolveMaintenanceConfig: vi.fn(),
loadSessionStore: vi.fn(),
pruneStaleEntries: vi.fn(),
capEntryCount: vi.fn(),
updateSessionStore: vi.fn(),
enforceSessionDiskBudget: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("./session-store-targets.js", () => ({
resolveSessionStoreTargets: mocks.resolveSessionStoreTargets,
}));
vi.mock("../config/sessions.js", () => ({
resolveMaintenanceConfig: mocks.resolveMaintenanceConfig,
loadSessionStore: mocks.loadSessionStore,
pruneStaleEntries: mocks.pruneStaleEntries,
capEntryCount: mocks.capEntryCount,
updateSessionStore: mocks.updateSessionStore,
enforceSessionDiskBudget: mocks.enforceSessionDiskBudget,
}));
import { sessionsCleanupCommand } from "./sessions-cleanup.js";
function makeRuntime(): { runtime: RuntimeEnv; logs: string[] } {
const logs: string[] = [];
return {
runtime: {
log: (msg: unknown) => logs.push(String(msg)),
error: () => {},
exit: () => {},
},
logs,
};
}
describe("sessionsCleanupCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfig.mockReturnValue({ session: { store: "/cfg/sessions.json" } });
mocks.resolveSessionStoreTargets.mockReturnValue([
{ agentId: "main", storePath: "/resolved/sessions.json" },
]);
mocks.resolveMaintenanceConfig.mockReturnValue({
mode: "warn",
pruneAfterMs: 7 * 24 * 60 * 60 * 1000,
maxEntries: 500,
rotateBytes: 10_485_760,
resetArchiveRetentionMs: 7 * 24 * 60 * 60 * 1000,
maxDiskBytes: null,
highWaterBytes: null,
});
mocks.pruneStaleEntries.mockImplementation(
(
store: Record<string, SessionEntry>,
_maxAgeMs: number,
opts?: { onPruned?: (params: { key: string; entry: SessionEntry }) => void },
) => {
if (store.stale) {
opts?.onPruned?.({ key: "stale", entry: store.stale });
delete store.stale;
return 1;
}
return 0;
},
);
mocks.capEntryCount.mockImplementation(() => 0);
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.enforceSessionDiskBudget.mockResolvedValue({
totalBytesBefore: 1000,
totalBytesAfter: 700,
removedFiles: 1,
removedEntries: 1,
freedBytes: 300,
maxBytes: 900,
highWaterBytes: 700,
overBudget: true,
});
});
it("emits a single JSON object for non-dry runs and applies maintenance", async () => {
mocks.loadSessionStore
.mockReturnValueOnce({
stale: { sessionId: "stale", updatedAt: 1 },
fresh: { sessionId: "fresh", updatedAt: 2 },
})
.mockReturnValueOnce({
fresh: { sessionId: "fresh", updatedAt: 2 },
});
mocks.updateSessionStore.mockImplementation(
async (
_storePath: string,
mutator: (store: Record<string, SessionEntry>) => Promise<void> | void,
opts?: {
onMaintenanceApplied?: (report: {
mode: "warn" | "enforce";
beforeCount: number;
afterCount: number;
pruned: number;
capped: number;
diskBudget: Record<string, unknown> | null;
}) => Promise<void> | void;
},
) => {
await mutator({});
await opts?.onMaintenanceApplied?.({
mode: "enforce",
beforeCount: 3,
afterCount: 1,
pruned: 0,
capped: 2,
diskBudget: {
totalBytesBefore: 1200,
totalBytesAfter: 800,
removedFiles: 0,
removedEntries: 0,
freedBytes: 400,
maxBytes: 1000,
highWaterBytes: 800,
overBudget: true,
},
});
},
);
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
json: true,
enforce: true,
activeKey: "agent:main:main",
},
runtime,
);
expect(logs).toHaveLength(1);
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
expect(payload.applied).toBe(true);
expect(payload.mode).toBe("enforce");
expect(payload.beforeCount).toBe(3);
expect(payload.appliedCount).toBe(1);
expect(payload.pruned).toBe(0);
expect(payload.capped).toBe(2);
expect(payload.diskBudget).toEqual(
expect.objectContaining({
removedFiles: 0,
removedEntries: 0,
}),
);
expect(mocks.updateSessionStore).toHaveBeenCalledWith(
"/resolved/sessions.json",
expect.any(Function),
expect.objectContaining({
activeSessionKey: "agent:main:main",
maintenanceOverride: { mode: "enforce" },
onMaintenanceApplied: expect.any(Function),
}),
);
});
it("returns dry-run JSON without mutating the store", async () => {
mocks.loadSessionStore.mockReturnValue({
stale: { sessionId: "stale", updatedAt: 1 },
fresh: { sessionId: "fresh", updatedAt: 2 },
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
json: true,
dryRun: true,
},
runtime,
);
expect(logs).toHaveLength(1);
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
expect(payload.dryRun).toBe(true);
expect(payload.applied).toBeUndefined();
expect(mocks.updateSessionStore).not.toHaveBeenCalled();
expect(payload.diskBudget).toEqual(
expect.objectContaining({
removedFiles: 1,
removedEntries: 1,
}),
);
});
it("renders a dry-run action table with keep/prune actions", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore.mockReturnValue({
stale: { sessionId: "stale", updatedAt: 1, model: "pi:opus" },
fresh: { sessionId: "fresh", updatedAt: 2, model: "pi:opus" },
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
dryRun: true,
},
runtime,
);
expect(logs.some((line) => line.includes("Planned session actions:"))).toBe(true);
expect(logs.some((line) => line.includes("Action") && line.includes("Key"))).toBe(true);
expect(logs.some((line) => line.includes("fresh") && line.includes("keep"))).toBe(true);
expect(logs.some((line) => line.includes("stale") && line.includes("prune-stale"))).toBe(true);
});
it("returns grouped JSON for --all-agents dry-runs", async () => {
mocks.resolveSessionStoreTargets.mockReturnValue([
{ agentId: "main", storePath: "/resolved/main-sessions.json" },
{ agentId: "work", storePath: "/resolved/work-sessions.json" },
]);
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore
.mockReturnValueOnce({ stale: { sessionId: "stale-main", updatedAt: 1 } })
.mockReturnValueOnce({ stale: { sessionId: "stale-work", updatedAt: 1 } });
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
json: true,
dryRun: true,
allAgents: true,
},
runtime,
);
expect(logs).toHaveLength(1);
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
expect(payload.allAgents).toBe(true);
expect(Array.isArray(payload.stores)).toBe(true);
expect((payload.stores as unknown[]).length).toBe(2);
});
});

View File

@@ -0,0 +1,397 @@
import { loadConfig } from "../config/config.js";
import {
capEntryCount,
enforceSessionDiskBudget,
loadSessionStore,
pruneStaleEntries,
resolveMaintenanceConfig,
updateSessionStore,
type SessionEntry,
type SessionMaintenanceApplyReport,
} from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { resolveSessionStoreTargets, type SessionStoreTarget } from "./session-store-targets.js";
import {
formatSessionAgeCell,
formatSessionFlagsCell,
formatSessionKeyCell,
formatSessionModelCell,
resolveSessionDisplayDefaults,
resolveSessionDisplayModel,
SESSION_AGE_PAD,
SESSION_KEY_PAD,
SESSION_MODEL_PAD,
toSessionDisplayRows,
} from "./sessions-table.js";
export type SessionsCleanupOptions = {
store?: string;
agent?: string;
allAgents?: boolean;
dryRun?: boolean;
enforce?: boolean;
activeKey?: string;
json?: boolean;
};
type SessionCleanupAction = "keep" | "prune-stale" | "cap-overflow" | "evict-budget";
const ACTION_PAD = 12;
type SessionCleanupActionRow = ReturnType<typeof toSessionDisplayRows>[number] & {
action: SessionCleanupAction;
};
type SessionCleanupSummary = {
agentId: string;
storePath: string;
mode: "warn" | "enforce";
dryRun: boolean;
beforeCount: number;
afterCount: number;
pruned: number;
capped: number;
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
wouldMutate: boolean;
applied?: true;
appliedCount?: number;
};
function resolveSessionCleanupAction(params: {
key: string;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}): SessionCleanupAction {
if (params.staleKeys.has(params.key)) {
return "prune-stale";
}
if (params.cappedKeys.has(params.key)) {
return "cap-overflow";
}
if (params.budgetEvictedKeys.has(params.key)) {
return "evict-budget";
}
return "keep";
}
function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): string {
const label = action.padEnd(ACTION_PAD);
if (!rich) {
return label;
}
if (action === "keep") {
return theme.muted(label);
}
if (action === "prune-stale") {
return theme.warn(label);
}
if (action === "cap-overflow") {
return theme.accentBright(label);
}
return theme.error(label);
}
function buildActionRows(params: {
beforeStore: Record<string, SessionEntry>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}): SessionCleanupActionRow[] {
return toSessionDisplayRows(params.beforeStore).map((row) => ({
...row,
action: resolveSessionCleanupAction({
key: row.key,
staleKeys: params.staleKeys,
cappedKeys: params.cappedKeys,
budgetEvictedKeys: params.budgetEvictedKeys,
}),
}));
}
async function previewStoreCleanup(params: {
target: SessionStoreTarget;
mode: "warn" | "enforce";
dryRun: boolean;
activeKey?: string;
}) {
const maintenance = resolveMaintenanceConfig();
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, {
log: false,
onPruned: ({ key }) => {
staleKeys.add(key);
},
});
const capped = capEntryCount(previewStore, maintenance.maxEntries, {
log: false,
onCapped: ({ key }) => {
cappedKeys.add(key);
},
});
const beforeBudgetStore = structuredClone(previewStore);
const diskBudget = await enforceSessionDiskBudget({
store: previewStore,
storePath: params.target.storePath,
activeSessionKey: params.activeKey,
maintenance,
warnOnly: false,
dryRun: true,
});
const budgetEvictedKeys = new Set<string>();
for (const key of Object.keys(beforeBudgetStore)) {
if (!Object.hasOwn(previewStore, key)) {
budgetEvictedKeys.add(key);
}
}
const beforeCount = Object.keys(beforeStore).length;
const afterPreviewCount = Object.keys(previewStore).length;
const wouldMutate =
pruned > 0 ||
capped > 0 ||
Boolean((diskBudget?.removedEntries ?? 0) > 0 || (diskBudget?.removedFiles ?? 0) > 0);
const summary: SessionCleanupSummary = {
agentId: params.target.agentId,
storePath: params.target.storePath,
mode: params.mode,
dryRun: params.dryRun,
beforeCount,
afterCount: afterPreviewCount,
pruned,
capped,
diskBudget,
wouldMutate,
};
return {
summary,
actionRows: buildActionRows({
beforeStore,
staleKeys,
cappedKeys,
budgetEvictedKeys,
}),
};
}
function renderStoreDryRunPlan(params: {
cfg: ReturnType<typeof loadConfig>;
summary: SessionCleanupSummary;
actionRows: SessionCleanupActionRow[];
displayDefaults: ReturnType<typeof resolveSessionDisplayDefaults>;
runtime: RuntimeEnv;
showAgentHeader: boolean;
}) {
const rich = isRich();
if (params.showAgentHeader) {
params.runtime.log(`Agent: ${params.summary.agentId}`);
}
params.runtime.log(`Session store: ${params.summary.storePath}`);
params.runtime.log(`Maintenance mode: ${params.summary.mode}`);
params.runtime.log(
`Entries: ${params.summary.beforeCount} -> ${params.summary.afterCount} (remove ${params.summary.beforeCount - params.summary.afterCount})`,
);
params.runtime.log(`Would prune stale: ${params.summary.pruned}`);
params.runtime.log(`Would cap overflow: ${params.summary.capped}`);
if (params.summary.diskBudget) {
params.runtime.log(
`Would enforce disk budget: ${params.summary.diskBudget.totalBytesBefore} -> ${params.summary.diskBudget.totalBytesAfter} bytes (files ${params.summary.diskBudget.removedFiles}, entries ${params.summary.diskBudget.removedEntries})`,
);
}
if (params.actionRows.length === 0) {
return;
}
params.runtime.log("");
params.runtime.log("Planned session actions:");
const header = [
"Action".padEnd(ACTION_PAD),
"Key".padEnd(SESSION_KEY_PAD),
"Age".padEnd(SESSION_AGE_PAD),
"Model".padEnd(SESSION_MODEL_PAD),
"Flags",
].join(" ");
params.runtime.log(rich ? theme.heading(header) : header);
for (const actionRow of params.actionRows) {
const model = resolveSessionDisplayModel(params.cfg, actionRow, params.displayDefaults);
const line = [
formatCleanupActionCell(actionRow.action, rich),
formatSessionKeyCell(actionRow.key, rich),
formatSessionAgeCell(actionRow.updatedAt, rich),
formatSessionModelCell(model, rich),
formatSessionFlagsCell(actionRow, rich),
].join(" ");
params.runtime.log(line.trimEnd());
}
}
export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runtime: RuntimeEnv) {
const cfg = loadConfig();
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode;
let targets: SessionStoreTarget[];
try {
targets = resolveSessionStoreTargets(cfg, {
store: opts.store,
agent: opts.agent,
allAgents: opts.allAgents,
});
} catch (error) {
runtime.error(error instanceof Error ? error.message : String(error));
runtime.exit(1);
return;
}
const previewResults: Array<{
summary: SessionCleanupSummary;
actionRows: SessionCleanupActionRow[];
}> = [];
for (const target of targets) {
const result = await previewStoreCleanup({
target,
mode,
dryRun: Boolean(opts.dryRun),
activeKey: opts.activeKey,
});
previewResults.push(result);
}
if (opts.dryRun) {
if (opts.json) {
if (previewResults.length === 1) {
runtime.log(JSON.stringify(previewResults[0]?.summary ?? {}, null, 2));
return;
}
runtime.log(
JSON.stringify(
{
allAgents: true,
mode,
dryRun: true,
stores: previewResults.map((result) => result.summary),
},
null,
2,
),
);
return;
}
for (let i = 0; i < previewResults.length; i += 1) {
const result = previewResults[i];
if (i > 0) {
runtime.log("");
}
renderStoreDryRunPlan({
cfg,
summary: result.summary,
actionRows: result.actionRows,
displayDefaults,
runtime,
showAgentHeader: previewResults.length > 1,
});
}
return;
}
const appliedSummaries: SessionCleanupSummary[] = [];
for (const target of targets) {
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
current: null,
};
await updateSessionStore(
target.storePath,
async () => {
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
},
{
activeSessionKey: opts.activeKey,
maintenanceOverride: {
mode,
},
onMaintenanceApplied: (report) => {
appliedReportRef.current = report;
},
},
);
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
const preview = previewResults.find((result) => result.summary.storePath === target.storePath);
const appliedReport = appliedReportRef.current;
const summary: SessionCleanupSummary =
appliedReport === null
? {
...(preview?.summary ?? {
agentId: target.agentId,
storePath: target.storePath,
mode,
dryRun: false,
beforeCount: 0,
afterCount: 0,
pruned: 0,
capped: 0,
diskBudget: null,
wouldMutate: false,
}),
dryRun: false,
applied: true,
appliedCount: Object.keys(afterStore).length,
}
: {
agentId: target.agentId,
storePath: target.storePath,
mode: appliedReport.mode,
dryRun: false,
beforeCount: appliedReport.beforeCount,
afterCount: appliedReport.afterCount,
pruned: appliedReport.pruned,
capped: appliedReport.capped,
diskBudget: appliedReport.diskBudget,
wouldMutate:
appliedReport.pruned > 0 ||
appliedReport.capped > 0 ||
Boolean(
(appliedReport.diskBudget?.removedEntries ?? 0) > 0 ||
(appliedReport.diskBudget?.removedFiles ?? 0) > 0,
),
applied: true,
appliedCount: Object.keys(afterStore).length,
};
appliedSummaries.push(summary);
}
if (opts.json) {
if (appliedSummaries.length === 1) {
runtime.log(JSON.stringify(appliedSummaries[0] ?? {}, null, 2));
return;
}
runtime.log(
JSON.stringify(
{
allAgents: true,
mode,
dryRun: false,
stores: appliedSummaries,
},
null,
2,
),
);
return;
}
for (let i = 0; i < appliedSummaries.length; i += 1) {
const summary = appliedSummaries[i];
if (i > 0) {
runtime.log("");
}
if (appliedSummaries.length > 1) {
runtime.log(`Agent: ${summary.agentId}`);
}
runtime.log(`Session store: ${summary.storePath}`);
runtime.log(`Applied maintenance. Current entries: ${summary.appliedCount ?? 0}`);
}
}

View File

@@ -0,0 +1,148 @@
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveSessionModelRef } from "../gateway/session-utils.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { theme } from "../terminal/theme.js";
export type SessionDisplayRow = {
key: string;
updatedAt: number | null;
ageMs: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: string;
groupActivation?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
model?: string;
modelProvider?: string;
providerOverride?: string;
modelOverride?: string;
contextTokens?: number;
};
export type SessionDisplayDefaults = {
model: string;
};
export const SESSION_KEY_PAD = 26;
export const SESSION_AGE_PAD = 9;
export const SESSION_MODEL_PAD = 14;
export function toSessionDisplayRows(store: Record<string, SessionEntry>): SessionDisplayRow[] {
return Object.entries(store)
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
responseUsage: entry?.responseUsage,
groupActivation: entry?.groupActivation,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
modelProvider: entry?.modelProvider,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
} satisfies SessionDisplayRow;
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
export function resolveSessionDisplayDefaults(cfg: OpenClawConfig): SessionDisplayDefaults {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
return {
model: resolved.model ?? DEFAULT_MODEL,
};
}
export function resolveSessionDisplayModel(
cfg: OpenClawConfig,
row: Pick<
SessionDisplayRow,
"key" | "model" | "modelProvider" | "modelOverride" | "providerOverride"
>,
defaults: SessionDisplayDefaults,
): string {
const resolved = resolveSessionModelRef(cfg, row, parseAgentSessionKey(row.key)?.agentId);
return resolved.model ?? defaults.model;
}
function truncateSessionKey(key: string): string {
if (key.length <= SESSION_KEY_PAD) {
return key;
}
const head = Math.max(4, SESSION_KEY_PAD - 10);
return `${key.slice(0, head)}...${key.slice(-6)}`;
}
export function formatSessionKeyCell(key: string, rich: boolean): string {
const label = truncateSessionKey(key).padEnd(SESSION_KEY_PAD);
return rich ? theme.accent(label) : label;
}
export function formatSessionAgeCell(updatedAt: number | null | undefined, rich: boolean): string {
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(SESSION_AGE_PAD);
return rich ? theme.muted(padded) : padded;
}
export function formatSessionModelCell(model: string | null | undefined, rich: boolean): string {
const label = (model ?? "unknown").padEnd(SESSION_MODEL_PAD);
return rich ? theme.info(label) : label;
}
export function formatSessionFlagsCell(
row: Pick<
SessionDisplayRow,
| "thinkingLevel"
| "verboseLevel"
| "reasoningLevel"
| "elevatedLevel"
| "responseUsage"
| "groupActivation"
| "systemSent"
| "abortedLastRun"
| "sessionId"
>,
rich: boolean,
): string {
const flags = [
row.thinkingLevel ? `think:${row.thinkingLevel}` : null,
row.verboseLevel ? `verbose:${row.verboseLevel}` : null,
row.reasoningLevel ? `reasoning:${row.reasoningLevel}` : null,
row.elevatedLevel ? `elev:${row.elevatedLevel}` : null,
row.responseUsage ? `usage:${row.responseUsage}` : null,
row.groupActivation ? `activation:${row.groupActivation}` : null,
row.systemSent ? "system" : null,
row.abortedLastRun ? "aborted" : null,
row.sessionId ? `id:${row.sessionId}` : null,
].filter(Boolean);
const label = flags.join(" ");
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const loadConfigMock = vi.hoisted(() =>
@@ -25,6 +25,7 @@ const resolveStorePathMock = vi.hoisted(() =>
return `/tmp/sessions-${opts?.agentId ?? "missing"}.json`;
}),
);
const loadSessionStoreMock = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -39,7 +40,7 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
return {
...actual,
resolveStorePath: resolveStorePathMock,
loadSessionStore: vi.fn(() => ({})),
loadSessionStore: loadSessionStoreMock,
};
});
@@ -58,6 +59,67 @@ function createRuntime(): { runtime: RuntimeEnv; logs: string[] } {
}
describe("sessionsCommand default store agent selection", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveStorePathMock.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
return `/tmp/sessions-${opts?.agentId ?? "missing"}.json`;
},
);
loadSessionStoreMock.mockImplementation(() => ({}));
});
it("includes agentId on sessions rows for --all-agents JSON output", async () => {
resolveStorePathMock.mockClear();
loadSessionStoreMock.mockReset();
loadSessionStoreMock
.mockReturnValueOnce({
main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
})
.mockReturnValueOnce({
voice_row: { sessionId: "s2", updatedAt: Date.now() - 120_000, model: "pi:opus" },
});
const { runtime, logs } = createRuntime();
await sessionsCommand({ allAgents: true, json: true }, runtime);
const payload = JSON.parse(logs[0] ?? "{}") as {
allAgents?: boolean;
sessions?: Array<{ key: string; agentId?: string }>;
};
expect(payload.allAgents).toBe(true);
expect(payload.sessions?.map((session) => session.agentId)).toContain("main");
expect(payload.sessions?.map((session) => session.agentId)).toContain("voice");
});
it("avoids duplicate rows when --all-agents resolves to a shared store path", async () => {
resolveStorePathMock.mockReset();
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
loadSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
"agent:main:room": { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
"agent:voice:room": { sessionId: "s2", updatedAt: Date.now() - 30_000, model: "pi:opus" },
});
const { runtime, logs } = createRuntime();
await sessionsCommand({ allAgents: true, json: true }, runtime);
const payload = JSON.parse(logs[0] ?? "{}") as {
count?: number;
stores?: Array<{ agentId: string; path: string }>;
allAgents?: boolean;
sessions?: Array<{ key: string; agentId?: string }>;
};
expect(payload.count).toBe(2);
expect(payload.allAgents).toBe(true);
expect(payload.stores).toEqual([{ agentId: "main", path: "/tmp/shared-sessions.json" }]);
expect(payload.sessions?.map((session) => session.agentId).toSorted()).toEqual([
"main",
"voice",
]);
expect(loadSessionStoreMock).toHaveBeenCalledTimes(1);
});
it("uses configured default agent id when resolving implicit session store path", async () => {
resolveStorePathMock.mockClear();
const { runtime, logs } = createRuntime();
@@ -69,4 +131,26 @@ describe("sessionsCommand default store agent selection", () => {
});
expect(logs[0]).toContain("Session store: /tmp/sessions-voice.json");
});
it("uses all configured agent stores with --all-agents", async () => {
resolveStorePathMock.mockClear();
loadSessionStoreMock.mockReset();
loadSessionStoreMock
.mockReturnValueOnce({
main_row: { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
})
.mockReturnValueOnce({});
const { runtime, logs } = createRuntime();
await sessionsCommand({ allAgents: true }, runtime);
expect(resolveStorePathMock).toHaveBeenCalledWith("/tmp/sessions-{agentId}.json", {
agentId: "main",
});
expect(resolveStorePathMock).toHaveBeenCalledWith("/tmp/sessions-{agentId}.json", {
agentId: "voice",
});
expect(logs[0]).toContain("Session stores: 2 (main, voice)");
expect(logs[2]).toContain("Agent");
});
});

View File

@@ -1,62 +1,38 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js";
import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.js";
import { classifySessionKey } from "../gateway/session-utils.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { resolveSessionStoreTargets } from "./session-store-targets.js";
import {
formatSessionAgeCell,
formatSessionFlagsCell,
formatSessionKeyCell,
formatSessionModelCell,
resolveSessionDisplayDefaults,
resolveSessionDisplayModel,
SESSION_AGE_PAD,
SESSION_KEY_PAD,
SESSION_MODEL_PAD,
type SessionDisplayRow,
toSessionDisplayRows,
} from "./sessions-table.js";
type SessionRow = {
key: string;
type SessionRow = SessionDisplayRow & {
agentId: string;
kind: "direct" | "group" | "global" | "unknown";
updatedAt: number | null;
ageMs: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: string;
groupActivation?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
model?: string;
modelProvider?: string;
providerOverride?: string;
modelOverride?: string;
contextTokens?: number;
};
const AGENT_PAD = 10;
const KIND_PAD = 6;
const KEY_PAD = 26;
const AGE_PAD = 9;
const MODEL_PAD = 14;
const TOKENS_PAD = 20;
const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
const truncateKey = (key: string) => {
if (key.length <= KEY_PAD) {
return key;
}
const head = Math.max(4, KEY_PAD - 10);
return `${key.slice(0, head)}...${key.slice(-6)}`;
};
const colorByPct = (label: string, pct: number | null, rich: boolean) => {
if (!rich || pct === null) {
return label;
@@ -108,83 +84,29 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
return theme.muted(label);
};
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(AGE_PAD);
return rich ? theme.muted(padded) : padded;
};
const formatModelCell = (model: string | null | undefined, rich: boolean) => {
const label = (model ?? "unknown").padEnd(MODEL_PAD);
return rich ? theme.info(label) : label;
};
const formatFlagsCell = (row: SessionRow, rich: boolean) => {
const flags = [
row.thinkingLevel ? `think:${row.thinkingLevel}` : null,
row.verboseLevel ? `verbose:${row.verboseLevel}` : null,
row.reasoningLevel ? `reasoning:${row.reasoningLevel}` : null,
row.elevatedLevel ? `elev:${row.elevatedLevel}` : null,
row.responseUsage ? `usage:${row.responseUsage}` : null,
row.groupActivation ? `activation:${row.groupActivation}` : null,
row.systemSent ? "system" : null,
row.abortedLastRun ? "aborted" : null,
row.sessionId ? `id:${row.sessionId}` : null,
].filter(Boolean);
const label = flags.join(" ");
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
};
function toRows(store: Record<string, SessionEntry>): SessionRow[] {
return Object.entries(store)
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
kind: classifySessionKey(key, entry),
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
responseUsage: entry?.responseUsage,
groupActivation: entry?.groupActivation,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
modelProvider: entry?.modelProvider,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
} satisfies SessionRow;
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
export async function sessionsCommand(
opts: { json?: boolean; store?: string; active?: string },
opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean },
runtime: RuntimeEnv,
) {
const aggregateAgents = opts.allAgents === true;
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const configContextTokens =
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(resolved.model) ??
lookupContextTokens(displayDefaults.model) ??
DEFAULT_CONTEXT_TOKENS;
const configModel = resolved.model ?? DEFAULT_MODEL;
const defaultAgentId = resolveDefaultAgentId(cfg);
const storePath = resolveStorePath(opts.store ?? cfg.session?.store, { agentId: defaultAgentId });
const store = loadSessionStore(storePath);
let targets: ReturnType<typeof resolveSessionStoreTargets>;
try {
targets = resolveSessionStoreTargets(cfg, {
store: opts.store,
agent: opts.agent,
allAgents: opts.allAgents,
});
} catch (error) {
runtime.error(error instanceof Error ? error.message : String(error));
runtime.exit(1);
return;
}
let activeMinutes: number | undefined;
if (opts.active !== undefined) {
@@ -197,30 +119,44 @@ export async function sessionsCommand(
activeMinutes = parsed;
}
const rows = toRows(store).filter((row) => {
if (activeMinutes === undefined) {
return true;
}
if (!row.updatedAt) {
return false;
}
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
});
const rows = targets
.flatMap((target) => {
const store = loadSessionStore(target.storePath);
return toSessionDisplayRows(store).map((row) => ({
...row,
agentId: parseAgentSessionKey(row.key)?.agentId ?? target.agentId,
kind: classifySessionKey(row.key, store[row.key]),
}));
})
.filter((row) => {
if (activeMinutes === undefined) {
return true;
}
if (!row.updatedAt) {
return false;
}
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
if (opts.json) {
const multi = targets.length > 1;
const aggregate = aggregateAgents || multi;
runtime.log(
JSON.stringify(
{
path: storePath,
path: aggregate ? null : (targets[0]?.storePath ?? null),
stores: aggregate
? targets.map((target) => ({
agentId: target.agentId,
path: target.storePath,
}))
: undefined,
allAgents: aggregateAgents ? true : undefined,
count: rows.length,
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => {
const resolvedModel = resolveSessionModelRef(
cfg,
r,
parseAgentSessionKey(r.key)?.agentId,
);
const model = resolvedModel.model ?? configModel;
const model = resolveSessionDisplayModel(cfg, r, displayDefaults);
return {
...r,
totalTokens: resolveFreshSessionTotalTokens(r) ?? null,
@@ -239,7 +175,13 @@ export async function sessionsCommand(
return;
}
runtime.log(info(`Session store: ${storePath}`));
if (targets.length === 1 && !aggregateAgents) {
runtime.log(info(`Session store: ${targets[0]?.storePath}`));
} else {
runtime.log(
info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`),
);
}
runtime.log(info(`Sessions listed: ${rows.length}`));
if (activeMinutes) {
runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`));
@@ -250,11 +192,13 @@ export async function sessionsCommand(
}
const rich = isRich();
const showAgentColumn = aggregateAgents || targets.length > 1;
const header = [
...(showAgentColumn ? ["Agent".padEnd(AGENT_PAD)] : []),
"Kind".padEnd(KIND_PAD),
"Key".padEnd(KEY_PAD),
"Age".padEnd(AGE_PAD),
"Model".padEnd(MODEL_PAD),
"Key".padEnd(SESSION_KEY_PAD),
"Age".padEnd(SESSION_AGE_PAD),
"Model".padEnd(SESSION_MODEL_PAD),
"Tokens (ctx %)".padEnd(TOKENS_PAD),
"Flags",
].join(" ");
@@ -262,21 +206,20 @@ export async function sessionsCommand(
runtime.log(rich ? theme.heading(header) : header);
for (const row of rows) {
const resolvedModel = resolveSessionModelRef(cfg, row, parseAgentSessionKey(row.key)?.agentId);
const model = resolvedModel.model ?? configModel;
const model = resolveSessionDisplayModel(cfg, row, displayDefaults);
const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
const total = resolveFreshSessionTotalTokens(row);
const keyLabel = truncateKey(row.key).padEnd(KEY_PAD);
const keyCell = rich ? theme.accent(keyLabel) : keyLabel;
const line = [
...(showAgentColumn
? [rich ? theme.accentBright(row.agentId.padEnd(AGENT_PAD)) : row.agentId.padEnd(AGENT_PAD)]
: []),
formatKindCell(row.kind, rich),
keyCell,
formatAgeCell(row.updatedAt, rich),
formatModelCell(model, rich),
formatSessionKeyCell(row.key, rich),
formatSessionAgeCell(row.updatedAt, rich),
formatSessionModelCell(model, rich),
formatTokensCell(total, contextTokens ?? null, rich),
formatFlagsCell(row, rich),
formatSessionFlagsCell(row, rich),
].join(" ");
runtime.log(line.trimEnd());