CLI: add --agent/--all-agents for sessions and cleanup

This commit is contained in:
Gustavo Madeira Santana 2026-02-23 13:19:28 -05:00 committed by Shakker
parent ff076fc358
commit ede32ceed7
No known key found for this signature in database
11 changed files with 518 additions and 109 deletions

View File

@ -11,16 +11,27 @@ List stored conversation sessions.
```bash
openclaw sessions
openclaw sessions --agent work
openclaw sessions --all-agents
openclaw sessions --active 120
openclaw sessions --json
```
Scope selection:
- default: configured default agent store
- `--agent <id>`: one configured agent store
- `--all-agents`: aggregate all configured agent stores
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
## Cleanup maintenance
Run maintenance now (instead of waiting for the next write cycle):
```bash
openclaw sessions cleanup --dry-run
openclaw sessions cleanup --agent work --dry-run
openclaw sessions cleanup --all-agents --dry-run
openclaw sessions cleanup --enforce
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123"
openclaw sessions cleanup --json
@ -32,8 +43,10 @@ openclaw sessions cleanup --json
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
- `--active-key <key>`: protect a specific active key from disk-budget eviction.
- `--agent <id>`: run cleanup for one configured agent store.
- `--all-agents`: run cleanup for all configured agent stores.
- `--store <path>`: run against a specific `sessions.json` file.
- `--json`: print one JSON summary object. Dry-run output includes projected `diskBudget` impact (`totalBytesBefore/After`, `removedFiles`, `removedEntries`) when disk budgeting is enabled.
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
Related:

View File

@ -140,6 +140,29 @@ describe("registerStatusHealthSessionsCommands", () => {
);
});
it("runs sessions command with --agent forwarding", async () => {
await runCli(["sessions", "--agent", "work"]);
expect(sessionsCommand).toHaveBeenCalledWith(
expect.objectContaining({
agent: "work",
allAgents: false,
}),
runtime,
);
});
it("runs sessions command with --all-agents forwarding", async () => {
await runCli(["sessions", "--all-agents"]);
expect(sessionsCommand).toHaveBeenCalledWith(
expect.objectContaining({
allAgents: true,
}),
runtime,
);
});
it("runs sessions cleanup subcommand with forwarded options", async () => {
await runCli([
"sessions",
@ -156,6 +179,8 @@ describe("registerStatusHealthSessionsCommands", () => {
expect(sessionsCleanupCommand).toHaveBeenCalledWith(
expect.objectContaining({
store: "/tmp/sessions.json",
agent: undefined,
allAgents: false,
dryRun: true,
enforce: true,
activeKey: "agent:main:main",
@ -164,4 +189,15 @@ describe("registerStatusHealthSessionsCommands", () => {
runtime,
);
});
it("forwards parent-level all-agents to cleanup subcommand", async () => {
await runCli(["sessions", "--all-agents", "cleanup", "--dry-run"]);
expect(sessionsCleanupCommand).toHaveBeenCalledWith(
expect.objectContaining({
allAgents: true,
}),
runtime,
);
});
});

View File

@ -118,12 +118,16 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--json", "Output as JSON", false)
.option("--verbose", "Verbose logging", false)
.option("--store <path>", "Path to session store (default: resolved from config)")
.option("--agent <id>", "Agent id to inspect (default: configured default agent)")
.option("--all-agents", "Aggregate sessions across all configured agents", false)
.option("--active <minutes>", "Only show sessions updated within the past N minutes")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw sessions", "List all sessions."],
["openclaw sessions --agent work", "List sessions for one agent."],
["openclaw sessions --all-agents", "Aggregate sessions across agents."],
["openclaw sessions --active 120", "Only last 2 hours."],
["openclaw sessions --json", "Machine-readable output."],
["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."],
@ -142,6 +146,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
{
json: Boolean(opts.json),
store: opts.store as string | undefined,
agent: opts.agent as string | undefined,
allAgents: Boolean(opts.allAgents),
active: opts.active as string | undefined,
},
defaultRuntime,
@ -153,6 +159,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.command("cleanup")
.description("Run session-store maintenance now")
.option("--store <path>", "Path to session store (default: resolved from config)")
.option("--agent <id>", "Agent id to maintain (default: configured default agent)")
.option("--all-agents", "Run maintenance across all configured agents", false)
.option("--dry-run", "Preview maintenance actions without writing", false)
.option("--enforce", "Apply maintenance even when configured mode is warn", false)
.option("--active-key <key>", "Protect this session key from budget-eviction")
@ -163,6 +171,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw sessions cleanup --dry-run", "Preview stale/cap cleanup."],
["openclaw sessions cleanup --enforce", "Apply maintenance now."],
["openclaw sessions cleanup --agent work --dry-run", "Preview one agent store."],
["openclaw sessions cleanup --all-agents --dry-run", "Preview all agent stores."],
[
"openclaw sessions cleanup --enforce --store ./tmp/sessions.json",
"Use a specific store.",
@ -173,6 +183,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
const parentOpts = command.parent?.opts() as
| {
store?: string;
agent?: string;
allAgents?: boolean;
json?: boolean;
}
| undefined;
@ -180,6 +192,8 @@ export function registerStatusHealthSessionsCommands(program: Command) {
await sessionsCleanupCommand(
{
store: (opts.store as string | undefined) ?? parentOpts?.store,
agent: (opts.agent as string | undefined) ?? parentOpts?.agent,
allAgents: Boolean(opts.allAgents || parentOpts?.allAgents),
dryRun: Boolean(opts.dryRun),
enforce: Boolean(opts.enforce),
activeKey: opts.activeKey as string | undefined,

View File

@ -30,6 +30,10 @@ describe("program routes", () => {
await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--active"]);
});
it("returns false for sessions route when --agent value is missing", async () => {
await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--agent"]);
});
it("does not fast-route sessions subcommands", () => {
expect(findRoutedCommand(["sessions", "cleanup"])).toBeNull();
});

View File

@ -48,6 +48,11 @@ const routeSessions: RouteSpec = {
match: (path) => path[0] === "sessions" && !path[1],
run: async (argv) => {
const json = hasFlag(argv, "--json");
const allAgents = hasFlag(argv, "--all-agents");
const agent = getFlagValue(argv, "--agent");
if (agent === null) {
return false;
}
const store = getFlagValue(argv, "--store");
if (store === null) {
return false;
@ -57,7 +62,7 @@ const routeSessions: RouteSpec = {
return false;
}
const { sessionsCommand } = await import("../../commands/sessions.js");
await sessionsCommand({ json, store, active }, defaultRuntime);
await sessionsCommand({ json, store, agent, allAgents, active }, defaultRuntime);
return true;
},
};

View File

@ -0,0 +1,60 @@
import { 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", () => {
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("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,69 @@
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;
};
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) {
return listAgentIds(cfg).map((agentId) => ({
agentId,
storePath: resolveStorePath(cfg.session?.store, { agentId }),
}));
}
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

@ -4,8 +4,7 @@ import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
resolveDefaultAgentId: vi.fn(),
resolveStorePath: vi.fn(),
resolveSessionStoreTargets: vi.fn(),
resolveMaintenanceConfig: vi.fn(),
loadSessionStore: vi.fn(),
pruneStaleEntries: vi.fn(),
@ -18,12 +17,11 @@ vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
vi.mock("./session-store-targets.js", () => ({
resolveSessionStoreTargets: mocks.resolveSessionStoreTargets,
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: mocks.resolveStorePath,
resolveMaintenanceConfig: mocks.resolveMaintenanceConfig,
loadSessionStore: mocks.loadSessionStore,
pruneStaleEntries: mocks.pruneStaleEntries,
@ -50,8 +48,9 @@ describe("sessionsCleanupCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfig.mockReturnValue({ session: { store: "/cfg/sessions.json" } });
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.resolveStorePath.mockReturnValue("/resolved/sessions.json");
mocks.resolveSessionStoreTargets.mockReturnValue([
{ agentId: "main", storePath: "/resolved/sessions.json" },
]);
mocks.resolveMaintenanceConfig.mockReturnValue({
mode: "warn",
pruneAfterMs: 7 * 24 * 60 * 60 * 1000,
@ -179,4 +178,31 @@ describe("sessionsCleanupCommand", () => {
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

@ -1,4 +1,3 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import {
capEntryCount,
@ -6,11 +5,12 @@ import {
loadSessionStore,
pruneStaleEntries,
resolveMaintenanceConfig,
resolveStorePath,
updateSessionStore,
type SessionEntry,
} 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,
@ -26,6 +26,8 @@ import {
export type SessionsCleanupOptions = {
store?: string;
agent?: string;
allAgents?: boolean;
dryRun?: boolean;
enforce?: boolean;
activeKey?: string;
@ -36,6 +38,25 @@ type SessionCleanupAction = "keep" | "prune-stale" | "cap-overflow" | "evict-bud
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>;
@ -71,15 +92,31 @@ function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): s
return theme.error(label);
}
export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runtime: RuntimeEnv) {
const cfg = loadConfig();
const displayDefaults = resolveSessionDisplayDefaults(cfg);
const defaultAgentId = resolveDefaultAgentId(cfg);
const storePath = resolveStorePath(opts.store ?? cfg.session?.store, { agentId: defaultAgentId });
const maintenance = resolveMaintenanceConfig();
const effectiveMode = opts.enforce ? "enforce" : maintenance.mode;
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,
}),
}));
}
const beforeStore = loadSessionStore(storePath, { skipCache: true });
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>();
@ -98,18 +135,17 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
const beforeBudgetStore = structuredClone(previewStore);
const diskBudget = await enforceSessionDiskBudget({
store: previewStore,
storePath,
activeSessionKey: opts.activeKey,
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)) {
continue;
if (!Object.hasOwn(previewStore, key)) {
budgetEvictedKeys.add(key);
}
budgetEvictedKeys.add(key);
}
const beforeCount = Object.keys(beforeStore).length;
const afterPreviewCount = Object.keys(previewStore).length;
@ -118,10 +154,11 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
capped > 0 ||
Boolean((diskBudget?.removedEntries ?? 0) > 0 || (diskBudget?.removedFiles ?? 0) > 0);
const summary = {
storePath,
mode: effectiveMode,
dryRun: Boolean(opts.dryRun),
const summary: SessionCleanupSummary = {
agentId: params.target.agentId,
storePath: params.target.storePath,
mode: params.mode,
dryRun: params.dryRun,
beforeCount,
afterCount: afterPreviewCount,
pruned,
@ -130,86 +167,184 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
wouldMutate,
};
const actionRows = toSessionDisplayRows(beforeStore).map((row) => ({
row,
action: resolveSessionCleanupAction({
key: row.key,
return {
summary,
actionRows: buildActionRows({
beforeStore,
staleKeys,
cappedKeys,
budgetEvictedKeys,
}),
}));
};
}
if (opts.json && opts.dryRun) {
runtime.log(JSON.stringify(summary, null, 2));
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;
}
if (!opts.json) {
runtime.log(`Session store: ${storePath}`);
runtime.log(`Maintenance mode: ${effectiveMode}`);
runtime.log(
`Entries: ${beforeCount} -> ${afterPreviewCount} (remove ${beforeCount - afterPreviewCount})`,
);
runtime.log(`Would prune stale: ${pruned}`);
runtime.log(`Would cap overflow: ${capped}`);
if (diskBudget) {
runtime.log(
`Would enforce disk budget: ${diskBudget.totalBytesBefore} -> ${diskBudget.totalBytesAfter} bytes (files ${diskBudget.removedFiles}, entries ${diskBudget.removedEntries})`,
);
}
if (opts.dryRun && actionRows.length > 0) {
const rich = isRich();
runtime.log("");
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(" ");
runtime.log(rich ? theme.heading(header) : header);
for (const actionRow of actionRows) {
const model = resolveSessionDisplayModel(cfg, actionRow.row, displayDefaults);
const line = [
formatCleanupActionCell(actionRow.action, rich),
formatSessionKeyCell(actionRow.row.key, rich),
formatSessionAgeCell(actionRow.row.updatedAt, rich),
formatSessionModelCell(model, rich),
formatSessionFlagsCell(actionRow.row, rich),
].join(" ");
runtime.log(line.trimEnd());
}
}
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;
}
await updateSessionStore(
storePath,
async () => {
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
},
{
activeSessionKey: opts.activeKey,
maintenanceOverride: {
mode: effectiveMode,
const appliedSummaries: SessionCleanupSummary[] = [];
for (const target of targets) {
await updateSessionStore(
target.storePath,
async () => {
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
},
},
);
{
activeSessionKey: opts.activeKey,
maintenanceOverride: {
mode,
},
},
);
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
const preview = previewResults.find((result) => result.summary.storePath === target.storePath);
const summary: SessionCleanupSummary = {
...(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,
};
appliedSummaries.push(summary);
}
const afterStore = loadSessionStore(storePath, { skipCache: true });
const appliedCount = Object.keys(afterStore).length;
if (opts.json) {
if (appliedSummaries.length === 1) {
runtime.log(JSON.stringify(appliedSummaries[0] ?? {}, null, 2));
return;
}
runtime.log(
JSON.stringify(
{
...summary,
applied: true,
appliedCount,
allAgents: true,
mode,
dryRun: false,
stores: appliedSummaries,
},
null,
2,
@ -218,5 +353,15 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
return;
}
runtime.log(`Applied maintenance. Current entries: ${appliedCount}`);
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

@ -69,4 +69,19 @@ 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();
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)");
});
});

View File

@ -1,16 +1,12 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveStorePath,
} from "../config/sessions.js";
import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.js";
import { classifySessionKey } from "../gateway/session-utils.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { resolveSessionStoreTargets } from "./session-store-targets.js";
import {
formatSessionAgeCell,
formatSessionFlagsCell,
@ -86,7 +82,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
};
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 cfg = loadConfig();
@ -95,10 +91,18 @@ export async function sessionsCommand(
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(displayDefaults.model) ??
DEFAULT_CONTEXT_TOKENS;
const configModel = displayDefaults.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) {
@ -111,11 +115,14 @@ export async function sessionsCommand(
activeMinutes = parsed;
}
const rows = toSessionDisplayRows(store)
.map((row) => ({
...row,
kind: classifySessionKey(row.key, store[row.key]),
}))
const rows = targets
.flatMap((target) => {
const store = loadSessionStore(target.storePath);
return toSessionDisplayRows(store).map((row) => ({
...row,
kind: classifySessionKey(row.key, store[row.key]),
}));
})
.filter((row) => {
if (activeMinutes === undefined) {
return true;
@ -124,13 +131,22 @@ export async function sessionsCommand(
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;
runtime.log(
JSON.stringify(
{
path: storePath,
path: multi ? null : (targets[0]?.storePath ?? null),
stores: multi
? targets.map((target) => ({
agentId: target.agentId,
path: target.storePath,
}))
: undefined,
allAgents: multi ? true : undefined,
count: rows.length,
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => {
@ -153,7 +169,13 @@ export async function sessionsCommand(
return;
}
runtime.log(info(`Session store: ${storePath}`));
if (targets.length === 1) {
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)`));
@ -176,7 +198,7 @@ export async function sessionsCommand(
runtime.log(rich ? theme.heading(header) : header);
for (const row of rows) {
const model = resolveSessionDisplayModel(cfg, row, displayDefaults) ?? configModel;
const model = resolveSessionDisplayModel(cfg, row, displayDefaults);
const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
const total = resolveFreshSessionTotalTokens(row);