From d09e0740e5bfda00fef2cf513edae8d0ce227d1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 4 Jun 2026 12:58:22 -0400 Subject: [PATCH] docs: document session display commands --- src/commands/sessions-display-model.ts | 15 +++++++++++++++ src/commands/sessions-table.ts | 15 +++++++++++++++ src/commands/sessions-tail.ts | 18 ++++++++++++++++++ src/commands/sessions.ts | 17 +++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/src/commands/sessions-display-model.ts b/src/commands/sessions-display-model.ts index c8703dcb2e4..ff0897474a7 100644 --- a/src/commands/sessions-display-model.ts +++ b/src/commands/sessions-display-model.ts @@ -1,3 +1,9 @@ +/** + * Model display resolution for session listings. + * + * Session rows may carry persisted model/provider overrides or CLI-runtime + * model strings; this module normalizes them into display-ready model refs. + */ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { inferUniqueProviderFromConfiguredModels, @@ -57,6 +63,8 @@ function normalizeStoredOverrideModel(params: { } const providerPrefix = `${providerOverride.toLowerCase()}/`; + // Older stores sometimes persisted both providerOverride and a + // provider/model modelOverride; trim the duplicate provider for display. return { providerOverride, modelOverride: modelOverride.toLowerCase().startsWith(providerPrefix) @@ -73,6 +81,7 @@ function resolveDefaultModelRef(cfg: OpenClawConfig, agentId?: string): SessionD return parseModelRef(primary, DEFAULT_PROVIDER); } +/** Resolves default display values for a session table scoped to an agent. */ export function resolveSessionDisplayDefaults( cfg: OpenClawConfig, agentId?: string, @@ -91,6 +100,8 @@ function normalizeCliRuntimeDisplayRef( return ref; } if (ref.model.includes("/")) { + // CLI runtimes can store the real provider/model inside the model field; + // prefer that embedded provider when it is not another CLI runtime alias. const parsed = parseModelRef(ref.model, defaultRef.provider); if (!isCliProvider(parsed.provider, cfg)) { return parsed; @@ -103,6 +114,8 @@ function normalizeCliRuntimeDisplayRef( if (inferredProvider && !isCliProvider(inferredProvider, cfg)) { return { provider: inferredProvider, model: ref.model }; } + // If the CLI runtime model cannot be mapped to a concrete provider, fall + // back to the configured default provider so rows stay comparable. const parsed = parseModelRef(ref.model, defaultRef.provider); if (!isCliProvider(parsed.provider, cfg)) { return parsed; @@ -113,6 +126,7 @@ function normalizeCliRuntimeDisplayRef( }; } +/** Resolves only the model id to show for a session row. */ export function resolveSessionDisplayModel( cfg: OpenClawConfig, row: SessionDisplayModelRow, @@ -120,6 +134,7 @@ export function resolveSessionDisplayModel( return resolveSessionDisplayModelRef(cfg, row).model; } +/** Resolves provider/model display metadata for a session row. */ export function resolveSessionDisplayModelRef( cfg: OpenClawConfig, row: SessionDisplayModelRow, diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts index 747e0918501..2db82da8cdb 100644 --- a/src/commands/sessions-table.ts +++ b/src/commands/sessions-table.ts @@ -1,7 +1,14 @@ +/** + * Shared table formatting helpers for session commands. + * + * Cleanup and listing commands use the same row shape and fixed-width cells so + * terminal output stays aligned across commands. + */ import { theme } from "../../packages/terminal-core/src/theme.js"; import type { SessionEntry } from "../config/sessions.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; +/** Display row derived from a persisted session entry. */ export type SessionDisplayRow = { key: string; updatedAt: number | null; @@ -32,6 +39,7 @@ export const SESSION_KEY_PAD = 26; export const SESSION_AGE_PAD = 9; export const SESSION_MODEL_PAD = 14; +/** Converts a persisted session entry into the shared display row shape. */ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDisplayRow { const updatedAt = entry?.updatedAt ?? null; return { @@ -60,6 +68,7 @@ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDi }; } +/** Converts and sorts a session store by most recent activity first. */ export function toSessionDisplayRows(store: Record): SessionDisplayRow[] { return Object.entries(store) .map(([key, entry]) => toSessionDisplayRow(key, entry)) @@ -70,26 +79,32 @@ function truncateSessionKey(key: string): string { if (key.length <= SESSION_KEY_PAD) { return key; } + // Keep both the stable prefix and suffix; the tail often contains direct + // recipient or runtime identifiers that distinguish otherwise similar keys. const head = Math.max(4, SESSION_KEY_PAD - 10); return `${key.slice(0, head)}...${key.slice(-6)}`; } +/** Formats a session key cell for table output. */ export function formatSessionKeyCell(key: string, rich: boolean): string { const label = truncateSessionKey(key).padEnd(SESSION_KEY_PAD); return rich ? theme.accent(label) : label; } +/** Formats a relative session age cell for table output. */ 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; } +/** Formats a model cell for table output. */ export function formatSessionModelCell(model: string | null | undefined, rich: boolean): string { const label = (model ?? "unknown").padEnd(SESSION_MODEL_PAD); return rich ? theme.info(label) : label; } +/** Formats compact per-session flags for table output. */ export function formatSessionFlagsCell( row: Pick< SessionDisplayRow, diff --git a/src/commands/sessions-tail.ts b/src/commands/sessions-tail.ts index cdd4219ee5f..54f808059b2 100644 --- a/src/commands/sessions-tail.ts +++ b/src/commands/sessions-tail.ts @@ -1,3 +1,9 @@ +/** + * Session trajectory tail command. + * + * It selects active or requested sessions, renders recent trajectory events, + * and can follow append-only trajectory files across rotation/truncation. + */ import fs from "node:fs"; import path from "node:path"; import { readAcpSessionMeta } from "../acp/runtime/session-meta.js"; @@ -64,6 +70,7 @@ const EVENT_TYPE_PAD = 16; const FOLLOW_INTERVAL_MS = 1_000; let followIntervalMsForTests: number | undefined; +/** Overrides the follow polling interval for tests. */ export function setSessionsTailFollowIntervalMsForTests(intervalMs?: number): void { followIntervalMsForTests = intervalMs; } @@ -146,6 +153,8 @@ function compareCursors(left: TrajectoryCursor, right: TrajectoryCursor): number if (left.seq !== null && right.seq !== null && left.seq !== right.seq) { return left.seq - right.seq; } + // Some trajectory events lack sequence numbers; timestamp fallback keeps + // follow mode from replaying already-rendered events after file rewrites. const byTimestamp = left.tsMs - right.tsMs; if (byTimestamp !== 0) { return byTimestamp; @@ -241,6 +250,8 @@ function safePreview(event: TrajectoryEvent): string { return `prompt skipped${reason ? `: ${reason}` : ""}`; } case "tool.call": + // Tool arguments may contain secrets or user text; tail output shows only + // the tool name and a redacted placeholder. return `${toolName(data)} {...redacted...}`; case "tool.timeout": return `${toolName(data)} timeout`; @@ -372,6 +383,8 @@ function selectSessionsToTail(selections: TailSelection[], sessionKey?: string): const running = selections.filter((selection) => isRunningSession(selection)); if (running.length > 0) { + // Without an explicit key, prefer all running sessions so follow mode shows + // concurrent active work instead of only the newest store entry. return running.toSorted(compareSelectionsByUpdatedAt); } @@ -405,6 +418,8 @@ function readNewFollowEvents(state: FollowState): TrajectoryEvent[] { fileState.size === state.offset && state.fileState?.mtimeMs !== fileState.mtimeMs; if (replaced || truncated || possiblyRewrittenSameSize) { + // Log rotation, truncation, and same-size rewrites all require a full + // rescan; cursor filtering prevents duplicate event output. const snapshot = readTrajectorySnapshot(state.selection.trajectoryPath); state.fileState = snapshot.fileState; state.offset = snapshot.offset; @@ -424,6 +439,8 @@ function readNewFollowEvents(state: FollowState): TrajectoryEvent[] { state.offset = fileState.size; state.fileState = fileState; const combined = `${state.pending}${buffer.toString("utf8")}`; + // Keep an incomplete trailing JSON line until the next poll, matching + // append-only writers that flush in chunks. const lines = combined.split(/\r?\n/u); state.pending = lines.pop() ?? ""; return parseTrajectoryEventLines(lines); @@ -492,6 +509,7 @@ function resolveTailTargetAgent(opts: SessionsTailOptions): string | undefined { return opts.sessionKey?.trim() ? resolveAgentIdFromSessionKey(opts.sessionKey) : undefined; } +/** Tails recent trajectory events for the selected session(s). */ export async function sessionsTailCommand( opts: SessionsTailOptions, runtime: RuntimeEnv, diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 37af12ed40d..70938836e0f 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,3 +1,9 @@ +/** + * Session listing command. + * + * It loads one or more agent session stores, enriches rows with model/runtime + * metadata, and emits JSON or fixed-width terminal tables. + */ import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -106,6 +112,8 @@ function selectNewestSessionRows(rows: SessionRow[], limit: number | undefined): if (limit > TOP_N_SELECTION_LIMIT) { return rows.toSorted(compareSessionRowsByUpdatedAt).slice(0, limit); } + // For small limits, keep only the top N rows without sorting the full store; + // large limits use the simpler full sort above. const selected: SessionRow[] = []; for (const row of rows) { const insertAt = selected.findIndex( @@ -241,6 +249,8 @@ function stripChannelRecipientPrefix( } const stripped = raw.slice(prefix.length); const topicMarkerIndex = stripped.toLowerCase().indexOf(":topic:"); + // Topic suffixes are routing detail, not the peer id used by runtime-policy + // session-key display. return topicMarkerIndex >= 0 ? stripped.slice(0, topicMarkerIndex) : stripped; } @@ -272,6 +282,8 @@ function resolveDisplayRuntimePolicySessionKey(params: { stripChannelRecipientPrefix(to, channel) ?? stripChannelRecipientPrefix(from, channel); + // Direct-message runtime policy can route by native user id, stripped + // recipient, or sender; expose the derived key when it differs from the row. const runtimePolicySessionKey = resolveRuntimePolicySessionKey({ cfg, sessionKey: key, @@ -296,6 +308,7 @@ function resolveDisplayRuntimePolicySessionKey(params: { : undefined; } +/** Lists sessions across selected stores with optional JSON output. */ export async function sessionsCommand( opts: { json?: boolean; @@ -369,6 +382,8 @@ export async function sessionsCommand( entry, }); const acpRuntime = acpMeta != null; + // ACP rows need stored-key metadata before model/runtime resolution so + // bridge sessions and true ACP runtime sessions display differently. const modelRef = applyAcpModelOverlayIfNeeded( resolveSessionDisplayModelRef(cfg, row), acpSessionKey, @@ -443,6 +458,8 @@ export async function sessionsCommand( totalTokens: resolveSessionTotalTokens(r) ?? null, totalTokensFresh: typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false, + // Prefer row-level context tokens, then config/model lookup, so JSON + // mirrors the terminal percentage calculation. contextTokens: r.contextTokens ?? configuredContextTokens ??