docs: document session display commands

This commit is contained in:
Peter Steinberger
2026-06-04 12:58:22 -04:00
parent 09467b1b87
commit d09e0740e5
4 changed files with 65 additions and 0 deletions

View File

@@ -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,

View File

@@ -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<string, SessionEntry>): 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,

View File

@@ -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,

View File

@@ -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 ??