diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 056676d28d9..6f313d762ee 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -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 `: one configured agent store +- `--all-agents`: aggregate all configured agent stores +- `--store `: 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 `: protect a specific active key from disk-budget eviction. +- `--agent `: run cleanup for one configured agent store. +- `--all-agents`: run cleanup for all configured agent stores. - `--store `: 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: diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index cdbd188e652..ac84bb5c1ca 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -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, + ); + }); }); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index f497f4d6382..b708d42e665 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -118,12 +118,16 @@ export function registerStatusHealthSessionsCommands(program: Command) { .option("--json", "Output as JSON", false) .option("--verbose", "Verbose logging", false) .option("--store ", "Path to session store (default: resolved from config)") + .option("--agent ", "Agent id to inspect (default: configured default agent)") + .option("--all-agents", "Aggregate sessions across all configured agents", false) .option("--active ", "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 to session store (default: resolved from config)") + .option("--agent ", "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 ", "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, diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index cc26a535e13..9442785b083 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -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(); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 507e575087a..b3a4e1f8161 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -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; }, }; diff --git a/src/commands/session-store-targets.test.ts b/src/commands/session-store-targets.test.ts new file mode 100644 index 00000000000..876877e2006 --- /dev/null +++ b/src/commands/session-store-targets.test.ts @@ -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); + }); +}); diff --git a/src/commands/session-store-targets.ts b/src/commands/session-store-targets.ts new file mode 100644 index 00000000000..94353acce0e --- /dev/null +++ b/src/commands/session-store-targets.ts @@ -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 }), + }, + ]; +} diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 4844c18e409..e96de78c67e 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -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; + expect(payload.allAgents).toBe(true); + expect(Array.isArray(payload.stores)).toBe(true); + expect((payload.stores as unknown[]).length).toBe(2); + }); }); diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index 088a6ccf7d6..f1df9055afc 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -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[number] & { + action: SessionCleanupAction; +}; + +type SessionCleanupSummary = { + agentId: string; + storePath: string; + mode: "warn" | "enforce"; + dryRun: boolean; + beforeCount: number; + afterCount: number; + pruned: number; + capped: number; + diskBudget: Awaited>; + wouldMutate: boolean; + applied?: true; + appliedCount?: number; +}; + function resolveSessionCleanupAction(params: { key: string; staleKeys: Set; @@ -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; + staleKeys: Set; + cappedKeys: Set; + budgetEvictedKeys: Set; +}): 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(); const cappedKeys = new Set(); @@ -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(); 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; + summary: SessionCleanupSummary; + actionRows: SessionCleanupActionRow[]; + displayDefaults: ReturnType; + 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}`); + } } diff --git a/src/commands/sessions.default-agent-store.test.ts b/src/commands/sessions.default-agent-store.test.ts index 604d6eb9fc2..a440c1700b9 100644 --- a/src/commands/sessions.default-agent-store.test.ts +++ b/src/commands/sessions.default-agent-store.test.ts @@ -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)"); + }); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 3bd6ad74105..be5af192db1 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -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; + 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);