diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffb47b09f1..af367887c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc. - Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email. - Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive. +- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit ` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3. - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc. - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 9dfdefa1993..58bab6fdbd9 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -16,17 +16,19 @@ until a message is processed. Use `openclaw channels status --probe`, `openclaw status --deep`, or `openclaw health --verbose` when you need live channel connectivity. -Gateway `sessions.list` responses are bounded by default so large long-lived -stores cannot monopolize the Gateway event loop. Pass an explicit positive -`limit` from RPC clients when a different result window is needed; responses -include `totalCount`, `limitApplied`, and `hasMore` when callers need to show -that more rows exist. +`openclaw sessions` and Gateway `sessions.list` responses are bounded by +default so large long-lived stores cannot monopolize the CLI process or Gateway +event loop. The CLI returns the newest 100 sessions by default; pass +`--limit ` for a smaller/larger window or `--limit all` when you intentionally +need the full store. JSON responses include `totalCount`, `limitApplied`, and +`hasMore` when callers need to show that more rows exist. ```bash openclaw sessions openclaw sessions --agent work openclaw sessions --all-agents openclaw sessions --active 120 +openclaw sessions --limit 25 openclaw sessions --verbose openclaw sessions --json ``` @@ -38,6 +40,7 @@ Scope selection: - `--agent `: one configured agent store - `--all-agents`: aggregate all configured agent stores - `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) +- `--limit `: max rows to output (default `100`; `all` restores full output) Export a trajectory bundle for a stored session: @@ -69,6 +72,9 @@ JSON examples: ], "allAgents": true, "count": 2, + "totalCount": 2, + "limitApplied": 100, + "hasMore": false, "activeMinutes": null, "sessions": [ { "agentId": "main", "key": "agent:main:main", "model": "gpt-5" }, diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index b4d62771182..a7f0764f8f9 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -191,6 +191,8 @@ describe("registerStatusHealthSessionsCommands", () => { "/tmp/sessions.json", "--active", "120", + "--limit", + "25", ]); expect(setVerbose).toHaveBeenCalledWith(true); @@ -199,6 +201,7 @@ describe("registerStatusHealthSessionsCommands", () => { json: true, store: "/tmp/sessions.json", active: "120", + limit: "25", }), runtime, ); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 1014626e73f..4d39987135e 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -132,6 +132,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { .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") + .option("--limit ", 'Max sessions to show (default: 100; use "all" for full output)') .addHelpText( "after", () => @@ -140,6 +141,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { ["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 --limit 25", "Show the newest 25 sessions."], ["openclaw sessions --json", "Machine-readable output."], ["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."], ])}\n\n${theme.muted( @@ -160,6 +162,7 @@ export function registerStatusHealthSessionsCommands(program: Command) { agent: opts.agent as string | undefined, allAgents: Boolean(opts.allAgents), active: opts.active as string | undefined, + limit: opts.limit as string | undefined, }, defaultRuntime, ); diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts index e595d8f0001..85e4bbc511d 100644 --- a/src/cli/program/route-args.test.ts +++ b/src/cli/program/route-args.test.ts @@ -96,6 +96,8 @@ describe("route-args", () => { "sqlite", "--active", "true", + "--limit", + "25", ]), ).toEqual({ json: true, @@ -103,8 +105,10 @@ describe("route-args", () => { agent: "default", store: "sqlite", active: "true", + limit: "25", }); expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent"])).toBeNull(); + expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--limit"])).toBeNull(); expect( parseAgentsListRouteArgs(["node", "openclaw", "agents", "list", "--json", "--bindings"]), ).toEqual({ diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index a80af17964e..d8f259c5419 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -144,12 +144,17 @@ export function parseSessionsRouteArgs(argv: string[]) { if (!active.ok) { return null; } + const limit = parseOptionalFlagValue(argv, "--limit"); + if (!limit.ok) { + return null; + } return { json: hasFlag(argv, "--json"), allAgents: hasFlag(argv, "--all-agents"), agent: agent.value, store: store.value, active: active.value, + limit: limit.value, }; } diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts index 8c365bef07a..c989152656b 100644 --- a/src/commands/sessions-table.ts +++ b/src/commands/sessions-table.ts @@ -31,35 +31,37 @@ export const SESSION_KEY_PAD = 26; export const SESSION_AGE_PAD = 9; export const SESSION_MODEL_PAD = 14; +export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDisplayRow { + 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, + traceLevel: entry?.traceLevel, + 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, + }; +} + export function toSessionDisplayRows(store: Record): 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, - traceLevel: entry?.traceLevel, - 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; - }) + .map(([key, entry]) => toSessionDisplayRow(key, entry)) .toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); } diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index bfdd4e33a22..c5fe142835f 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -73,12 +73,13 @@ export function writeStore(data: unknown, prefix = "sessions"): string { export async function runSessionsJson( run: ( - opts: { json?: boolean; store?: string; active?: string }, + opts: { json?: boolean; store?: string; active?: string; limit?: string | number }, runtime: RuntimeEnv, ) => Promise, store: string, options?: { active?: string; + limit?: string | number; }, ): Promise { const { runtime, logs } = makeRuntime(); @@ -88,6 +89,7 @@ export async function runSessionsJson( store, json: true, active: options?.active, + limit: options?.limit, }, runtime, ); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 881e4c06283..52c6d287ff0 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -154,6 +154,106 @@ describe("sessionsCommand", () => { expect(payload.sessions?.map((row) => row.key)).toEqual(["recent"]); }); + it("limits JSON output to the newest 100 sessions by default", async () => { + const entries: Record = {}; + for (let i = 0; i < 105; i += 1) { + entries[`session-${String(i).padStart(3, "0")}`] = { + sessionId: `session-${i}`, + updatedAt: Date.now() - i * 60_000, + model: "pi:opus", + }; + } + const store = writeStore(entries, "sessions-default-limit"); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store); + + expect(payload.count).toBe(100); + expect(payload.totalCount).toBe(105); + expect(payload.limitApplied).toBe(100); + expect(payload.hasMore).toBe(true); + expect(payload.sessions?.at(0)?.key).toBe("session-000"); + expect(payload.sessions?.some((row) => row.key === "session-104")).toBe(false); + }); + + it("honors explicit JSON output limits", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + middle: { sessionId: "middle", updatedAt: Date.now() - 60_000, model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-explicit-limit", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "2" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(3); + expect(payload.limitApplied).toBe(2); + expect(payload.hasMore).toBe(true); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "middle"]); + }); + + it("allows full JSON output with --limit all", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-limit-all", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "all" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(2); + expect(payload.limitApplied).toBeNull(); + expect(payload.hasMore).toBe(false); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]); + }); + + it("sorts and slices large explicit limits instead of using top-N insertion", async () => { + const store = writeStore( + { + newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" }, + oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" }, + }, + "sessions-large-limit", + ); + + const payload = await runSessionsJson<{ + count?: number; + totalCount?: number; + limitApplied?: number | null; + hasMore?: boolean; + sessions?: Array<{ key: string }>; + }>(sessionsCommand, store, { limit: "100000" }); + + expect(payload.count).toBe(2); + expect(payload.totalCount).toBe(2); + expect(payload.limitApplied).toBe(100000); + expect(payload.hasMore).toBe(false); + expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]); + }); + it("rejects invalid --active values", async () => { const store = writeStore( { @@ -171,4 +271,22 @@ describe("sessionsCommand", () => { fs.rmSync(store); }); + + it("rejects invalid --limit values", async () => { + const store = writeStore( + { + demo: { + sessionId: "demo", + updatedAt: Date.now() - 5 * 60_000, + }, + }, + "sessions-limit-invalid", + ); + const { runtime, errors } = makeRuntime(); + + await expect(sessionsCommand({ store, limit: "0" }, runtime)).rejects.toThrow("exit 1"); + expect(errors[0]).toContain('--limit must be a positive integer or "all"'); + + fs.rmSync(store); + }); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 2c405c27cbd..96c41d418e3 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -23,7 +23,7 @@ import { SESSION_KEY_PAD, SESSION_MODEL_PAD, type SessionDisplayRow, - toSessionDisplayRows, + toSessionDisplayRow, } from "./sessions-table.js"; type SessionRow = SessionDisplayRow & { @@ -35,10 +35,58 @@ type SessionRow = SessionDisplayRow & { const AGENT_PAD = 10; const KIND_PAD = 6; const TOKENS_PAD = 20; +const DEFAULT_SESSIONS_LIMIT = 100; +const TOP_N_SELECTION_LIMIT = 200; const contextLookupRuntimeLoader = createLazyImportLoader(() => import("../agents/context.js")); const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; +function compareSessionRowsByUpdatedAt(a: SessionRow, b: SessionRow): number { + return (b.updatedAt ?? 0) - (a.updatedAt ?? 0); +} + +function selectNewestSessionRows(rows: SessionRow[], limit: number | undefined): SessionRow[] { + if (limit === undefined) { + return rows.toSorted(compareSessionRowsByUpdatedAt); + } + if (limit > TOP_N_SELECTION_LIMIT) { + return rows.toSorted(compareSessionRowsByUpdatedAt).slice(0, limit); + } + const selected: SessionRow[] = []; + for (const row of rows) { + const insertAt = selected.findIndex( + (candidate) => compareSessionRowsByUpdatedAt(row, candidate) < 0, + ); + if (insertAt >= 0) { + selected.splice(insertAt, 0, row); + if (selected.length > limit) { + selected.pop(); + } + } else if (selected.length < limit) { + selected.push(row); + } + } + return selected; +} + +function parseSessionsLimit(value: string | number | undefined): number | undefined | null { + if (value === undefined) { + return DEFAULT_SESSIONS_LIMIT; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "all") { + return undefined; + } + if (!/^\d+$/.test(trimmed)) { + return null; + } + const parsed = Number.parseInt(trimmed, 10); + return parsed > 0 ? parsed : null; + } + return Number.isInteger(value) && value > 0 ? value : null; +} + const colorByPct = (label: string, pct: number | null, rich: boolean) => { if (!rich || pct === null) { return label; @@ -115,7 +163,14 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => { }; export async function sessionsCommand( - opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean }, + opts: { + json?: boolean; + store?: string; + active?: string; + agent?: string; + allAgents?: boolean; + limit?: string | number; + }, runtime: RuntimeEnv, ) { const aggregateAgents = opts.allAgents === true; @@ -150,10 +205,25 @@ export async function sessionsCommand( activeMinutes = parsed; } - const rows = targets - .flatMap((target) => { - const store = loadSessionStore(target.storePath); - return toSessionDisplayRows(store).map((row) => { + const limit = parseSessionsLimit(opts.limit); + if (limit === null) { + runtime.error('--limit must be a positive integer or "all"'); + runtime.exit(1); + return; + } + + const allRows = targets.flatMap((target) => { + const store = loadSessionStore(target.storePath); + return Object.entries(store) + .filter(([, entry]) => { + if (activeMinutes === undefined) { + return true; + } + const updatedAt = entry?.updatedAt; + return typeof updatedAt === "number" && Date.now() - updatedAt <= activeMinutes * 60_000; + }) + .map(([key, entry]) => { + const row = toSessionDisplayRow(key, entry); const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId; return Object.assign({}, row, { agentId, @@ -161,17 +231,10 @@ export async function sessionsCommand( 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)); + }); + const totalCount = allRows.length; + const rows = selectNewestSessionRows(allRows, limit); + const hasMore = rows.length < totalCount; if (opts.json) { const multi = targets.length > 1; @@ -186,6 +249,9 @@ export async function sessionsCommand( : undefined, allAgents: aggregateAgents ? true : undefined, count: rows.length, + totalCount, + limitApplied: limit ?? null, + hasMore, activeMinutes: activeMinutes ?? null, sessions: await Promise.all( rows.map(async (r) => { @@ -217,7 +283,13 @@ export async function sessionsCommand( info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`), ); } - runtime.log(info(`Sessions listed: ${rows.length}`)); + runtime.log( + info( + hasMore && limit !== undefined + ? `Sessions listed: ${rows.length} of ${totalCount} (limit ${limit})` + : `Sessions listed: ${rows.length}`, + ), + ); if (activeMinutes) { runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`)); }