From 2177df51a89aff13f81a79a7de305f716fd994ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 03:00:01 +0000 Subject: [PATCH] feat(status): enrich session details --- src/commands/status.test.ts | 24 ++++- src/commands/status.ts | 148 +++++++++++++++++++++++++++--- src/config/sessions.ts | 2 + src/infra/control-channel.test.ts | 7 +- src/rpc/loop.test.ts | 13 ++- 5 files changed, 177 insertions(+), 17 deletions(-) diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index ad672b2ff..3b2811410 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -2,7 +2,18 @@ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ loadSessionStore: vi.fn().mockReturnValue({ - "+1000": { updatedAt: Date.now() - 60_000 }, + "+1000": { + updatedAt: Date.now() - 60_000, + verboseLevel: "on", + thinkingLevel: "low", + inputTokens: 2_000, + outputTokens: 3_000, + contextTokens: 10_000, + model: "pi:opus", + sessionId: "abc123", + systemSent: true, + syncing: true, + }, }), resolveStorePath: vi.fn().mockReturnValue("/tmp/sessions.json"), webAuthExists: vi.fn().mockResolvedValue(true), @@ -40,6 +51,12 @@ describe("statusCommand", () => { expect(payload.web.linked).toBe(true); expect(payload.sessions.count).toBe(1); expect(payload.sessions.path).toBe("/tmp/sessions.json"); + expect(payload.sessions.defaults.model).toBeTruthy(); + expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0); + expect(payload.sessions.recent[0].percentUsed).toBe(50); + expect(payload.sessions.recent[0].remainingTokens).toBe(5000); + expect(payload.sessions.recent[0].flags).toContain("verbose:on"); + expect(payload.sessions.recent[0].flags).toContain("syncing"); }); it("prints formatted lines otherwise", async () => { @@ -48,6 +65,11 @@ describe("statusCommand", () => { const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0])); expect(logs.some((l) => l.includes("Web session"))).toBe(true); expect(logs.some((l) => l.includes("Active sessions"))).toBe(true); + expect(logs.some((l) => l.includes("Default model"))).toBe(true); + expect(logs.some((l) => l.includes("tokens:"))).toBe(true); + expect(logs.some((l) => l.includes("flags:") && l.includes("verbose:on"))).toBe( + true, + ); expect(mocks.logWebSelfId).toHaveBeenCalled(); }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index a0ad646dc..e2a24499a 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,5 +1,11 @@ +import { lookupContextTokens } from "../agents/context.js"; +import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { loadConfig } from "../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { + loadSessionStore, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; import { info } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; @@ -11,6 +17,27 @@ import { webAuthExists, } from "../web/session.js"; +export type SessionStatus = { + key: string; + kind: "direct" | "group" | "global" | "unknown"; + sessionId?: string; + updatedAt: number | null; + age: number | null; + thinkingLevel?: string; + verboseLevel?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + syncing?: boolean | string; + inputTokens?: number; + outputTokens?: number; + totalTokens: number | null; + remainingTokens: number | null; + percentUsed: number | null; + model: string | null; + contextTokens: number | null; + flags: string[]; +}; + export type StatusSummary = { web: { linked: boolean; authAgeMs: number | null }; heartbeatSeconds: number; @@ -19,11 +46,8 @@ export type StatusSummary = { sessions: { path: string; count: number; - recent: Array<{ - key: string; - updatedAt: number | null; - age: number | null; - }>; + defaults: { model: string | null; contextTokens: number | null }; + recent: SessionStatus[]; }; }; @@ -35,17 +59,59 @@ export async function getStatusSummary(): Promise { const providerSummary = await buildProviderSummary(cfg); const queuedSystemEvents = peekSystemEvents(); + const configModel = cfg.inbound?.reply?.agent?.model ?? DEFAULT_MODEL; + const configContextTokens = + cfg.inbound?.reply?.agent?.contextTokens ?? + lookupContextTokens(configModel) ?? + DEFAULT_CONTEXT_TOKENS; + const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); const store = loadSessionStore(storePath); + const now = Date.now(); const sessions = Object.entries(store) .filter(([key]) => key !== "global" && key !== "unknown") - .map(([key, entry]) => ({ key, updatedAt: entry?.updatedAt ?? 0 })) - .sort((a, b) => b.updatedAt - a.updatedAt); - const recent = sessions.slice(0, 5).map((s) => ({ - key: s.key, - updatedAt: s.updatedAt || null, - age: s.updatedAt ? Date.now() - s.updatedAt : null, - })); + .map(([key, entry]) => { + const updatedAt = entry?.updatedAt ?? null; + const age = updatedAt ? now - updatedAt : null; + const model = entry?.model ?? configModel ?? null; + const contextTokens = + entry?.contextTokens ?? + lookupContextTokens(model) ?? + configContextTokens ?? + null; + const input = entry?.inputTokens ?? 0; + const output = entry?.outputTokens ?? 0; + const total = entry?.totalTokens ?? input + output; + const remaining = + contextTokens != null ? Math.max(0, contextTokens - total) : null; + const pct = + contextTokens && contextTokens > 0 + ? Math.min(999, Math.round((total / contextTokens) * 100)) + : null; + + return { + key, + kind: classifyKey(key), + sessionId: entry?.sessionId, + updatedAt, + age, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + systemSent: entry?.systemSent, + abortedLastRun: entry?.abortedLastRun, + syncing: entry?.syncing, + inputTokens: entry?.inputTokens, + outputTokens: entry?.outputTokens, + totalTokens: total ?? null, + remainingTokens: remaining, + percentUsed: pct, + model, + contextTokens, + flags: buildFlags(entry), + } satisfies SessionStatus; + }) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + const recent = sessions.slice(0, 5); return { web: { linked, authAgeMs }, @@ -55,11 +121,18 @@ export async function getStatusSummary(): Promise { sessions: { path: storePath, count: sessions.length, + defaults: { + model: configModel ?? null, + contextTokens: configContextTokens ?? null, + }, recent, }, }; } +const formatKTokens = (value: number) => + `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; + const formatAge = (ms: number | null | undefined) => { if (!ms || ms < 0) return "unknown"; const minutes = Math.round(ms / 60_000); @@ -71,6 +144,48 @@ const formatAge = (ms: number | null | undefined) => { return `${days}d ago`; }; +const formatContextUsage = ( + total: number | null | undefined, + contextTokens: number | null | undefined, + remaining: number | null | undefined, + pct: number | null | undefined, +) => { + const used = total ?? 0; + if (!contextTokens) { + return `tokens: ${formatKTokens(used)} used (ctx unknown)`; + } + const left = remaining ?? Math.max(0, contextTokens - used); + const pctLabel = pct != null ? `${pct}%` : "?%"; + return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`; +}; + +const classifyKey = (key: string): SessionStatus["kind"] => { + if (key === "global") return "global"; + if (key.startsWith("group:")) return "group"; + if (key === "unknown") return "unknown"; + return "direct"; +}; + +const buildFlags = (entry: SessionEntry): string[] => { + const flags: string[] = []; + const think = entry?.thinkingLevel; + if (typeof think === "string" && think.length > 0) + flags.push(`think:${think}`); + const verbose = entry?.verboseLevel; + if (typeof verbose === "string" && verbose.length > 0) + flags.push(`verbose:${verbose}`); + if (entry?.systemSent) flags.push("system"); + if (entry?.abortedLastRun) flags.push("aborted"); + const syncing = entry?.syncing as unknown; + if (syncing === true || syncing === "on") flags.push("syncing"); + else if (typeof syncing === "string" && syncing) + flags.push(`sync:${syncing}`); + const sessionId = entry?.sessionId as unknown; + if (typeof sessionId === "string" && sessionId.length > 0) + flags.push(`id:${sessionId}`); + return flags; +}; + export async function statusCommand( opts: { json?: boolean }, runtime: RuntimeEnv, @@ -99,12 +214,17 @@ export async function statusCommand( } runtime.log(info(`Heartbeat: ${summary.heartbeatSeconds}s`)); runtime.log(info(`Session store: ${summary.sessions.path}`)); + const defaults = summary.sessions.defaults; + const defaultCtx = defaults.contextTokens + ? ` (${formatKTokens(defaults.contextTokens)} ctx)` + : ""; + runtime.log(info(`Default model: ${defaults.model ?? "unknown"}${defaultCtx}`)); runtime.log(info(`Active sessions: ${summary.sessions.count}`)); if (summary.sessions.recent.length > 0) { runtime.log("Recent sessions:"); for (const r of summary.sessions.recent) { runtime.log( - `- ${r.key} (${r.updatedAt ? formatAge(Date.now() - r.updatedAt) : "no activity"})`, + `- ${r.key} [${r.kind}] | ${r.updatedAt ? formatAge(r.age) : "no activity"} | model ${r.model ?? "unknown"} | ${formatContextUsage(r.totalTokens, r.contextTokens, r.remainingTokens, r.percentUsed)}${r.flags.length ? ` | flags: ${r.flags.join(", ")}` : ""}`, ); } } else { diff --git a/src/config/sessions.ts b/src/config/sessions.ts index ba9340395..e5f16b754 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -20,6 +20,8 @@ export type SessionEntry = { totalTokens?: number; model?: string; contextTokens?: number; + // Optional flag to mirror Mac app UI and future sync states. + syncing?: boolean | string; }; export const SESSION_STORE_DEFAULT = path.join( diff --git a/src/infra/control-channel.test.ts b/src/infra/control-channel.test.ts index 27d19d338..88594d049 100644 --- a/src/infra/control-channel.test.ts +++ b/src/infra/control-channel.test.ts @@ -26,7 +26,12 @@ vi.mock("../commands/status.js", () => ({ getStatusSummary: vi.fn(async () => ({ web: { linked: true, authAgeMs: 1000 }, heartbeatSeconds: 60, - sessions: { path: "/tmp/sessions.json", count: 1, recent: [] }, + sessions: { + path: "/tmp/sessions.json", + count: 1, + defaults: { model: "claude-opus-4-5", contextTokens: 200_000 }, + recent: [], + }, })), })); diff --git a/src/rpc/loop.test.ts b/src/rpc/loop.test.ts index 3880e4cae..eab474352 100644 --- a/src/rpc/loop.test.ts +++ b/src/rpc/loop.test.ts @@ -9,7 +9,18 @@ vi.mock("../commands/health.js", () => ({ })); vi.mock("../commands/status.js", () => ({ - getStatusSummary: vi.fn(async () => ({ providerSummary: "ok" })), + getStatusSummary: vi.fn(async () => ({ + web: { linked: true, authAgeMs: 0 }, + heartbeatSeconds: 60, + providerSummary: "ok", + queuedSystemEvents: [], + sessions: { + path: "/tmp/sessions.json", + count: 0, + defaults: { model: "claude-opus-4-5", contextTokens: 200_000 }, + recent: [], + }, + })), })); vi.mock("../infra/heartbeat-events.js", () => ({