feat: native rig context command + dashboard context prominence

H44 Slice 2: context-usage as a first-class operator surface.

CLI: new `rig context` command with two-axis status model
(urgency x freshness) matching the shipped rigx-context wrapper.
Flags: --rig, --threshold (keeps unknown + stale visible),
--refresh (triggers daemon re-sample), --json. Sort: CRITICAL >
WARNING > stale > ok > unknown. Only low+fresh = ok; low+stale =
stale (not ok). Human table + fleet summary.

Daemon:
- FRESHNESS_THRESHOLD_MS updated from 120_000 (2min) to 600_000
  (10min) per PM spec — aligns CLI/UI/daemon freshness semantics.
- contextMonitor wired through AppDeps so routes can call
  pollOnce() for --refresh plumbing.
- GET /api/rigs/:id/nodes?refresh=true triggers pollOnce before
  inventory projection; 502 on refresh failure (honest, non-silent).

UI:
- RigNode.tsx: context percentage enlarged from 8px badge to
  prominent 16px bold number; color-coded (green <60 / amber
  60-80 / red >=80); stale samples at 50% opacity; unknown shows
  "?" instead of "CTX unknown".
- Explorer.tsx: context percentage indicator added per agent row;
  replaces runtime label when context data available; same color
  coding + stale opacity.
- useNodeInventory.ts: contextUsage type added to
  NodeInventoryEntry interface.

Files: 10 across CLI/daemon/UI. No new /api/context endpoint —
reuses existing node inventory routes per spec.

Gates: daemon 1812/1812; CLI 446/447 (preexisting chatroom);
UI 396/396; tsc clean across all 3 workspaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.6 committed 2026-04-23 03:13:36 -07:00
1 parent efa6f41575
commit bad82b3a7b
10 files changed
+340 -15

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface ContextDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface NodeEntry {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
[key: string]: unknown;
}
interface Seat {
session: string;
rig: string;
logicalId: string;
runtime: string;
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
urgency: "critical" | "warning" | "low" | "unknown";
freshness: "fresh" | "stale" | "none";
status: string;
displayStatus: string;
source: string | null;
availability: string;
sampledAt: string | null;
staleness: string;
fresh: boolean;
}
const FRESHNESS_THRESHOLD_S = 600; // 10 minutes
function ageLabel(sampledAt: string | null): string {
if (!sampledAt) return "\u2014";
const age = Math.max(0, Math.floor((Date.now() - new Date(sampledAt).getTime()) / 1000));
if (age < 60) return "<1m ago";
const minutes = Math.floor(age / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function computeFreshness(sampledAt: string | null): "fresh" | "stale" | "none" {
if (!sampledAt) return "none";
const age = (Date.now() - new Date(sampledAt).getTime()) / 1000;
return age <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
}
function analyze(node: NodeEntry): Seat {
const ctx = node.contextUsage ?? {} as Partial<NonNullable<NodeEntry["contextUsage"]>>;
const used = ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null;
const remaining = ctx.remainingPercentage != null ? Math.round(ctx.remainingPercentage) : null;
const window = ctx.contextWindowSize ?? null;
const sampledAt = ctx.sampledAt ?? null;
const source = ctx.source ?? null;
const availability = ctx.availability ?? (used != null ? "known" : "unknown");
if (used == null) {
return {
session: node.canonicalSessionName ?? "",
rig: node.rigName ?? "",
logicalId: node.logicalId ?? "",
runtime: node.runtime ?? "unknown",
usedPercentage: null, remainingPercentage: null, contextWindowSize: null,
urgency: "unknown", freshness: "none", status: "unknown", displayStatus: "unknown",
source, availability, sampledAt, staleness: "\u2014", fresh: false,
};
}
const urgency: "critical" | "warning" | "low" = used >= 80 ? "critical" : used >= 60 ? "warning" : "low";
const freshness = computeFreshness(sampledAt);
let status: string;
let displayStatus: string;
if (urgency === "critical") {
status = freshness === "fresh" ? "critical" : "critical_stale";
displayStatus = freshness === "fresh" ? "CRITICAL" : "CRITICAL (stale)";
} else if (urgency === "warning") {
status = freshness === "fresh" ? "warning" : "warning_stale";
displayStatus = freshness === "fresh" ? "WARNING" : "WARNING (stale)";
} else if (freshness === "fresh") {
status = "ok";
displayStatus = "ok";
} else {
status = "stale";
displayStatus = "stale";
}
return {
session: node.canonicalSessionName ?? "",
rig: node.rigName ?? "",
logicalId: node.logicalId ?? "",
runtime: node.runtime ?? "unknown",
usedPercentage: used, remainingPercentage: remaining, contextWindowSize: window,
urgency, freshness, status, displayStatus,
source, availability, sampledAt, staleness: ageLabel(sampledAt), fresh: freshness === "fresh",
};
}
const STATUS_RANK: Record<string, number> = {
critical: 0, critical_stale: 0,
warning: 1, warning_stale: 1,
stale: 2, ok: 3, unknown: 4,
};
function sortSeats(seats: Seat[]): Seat[] {
return seats.sort((a, b) => {
const ra = STATUS_RANK[a.status] ?? 9;
const rb = STATUS_RANK[b.status] ?? 9;
if (ra !== rb) return ra - rb;
const pa = a.usedPercentage ?? -1;
const pb = b.usedPercentage ?? -1;
if (pa !== pb) return pb - pa; // higher percentage first
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
return a.session.localeCompare(b.session);
});
}
function isVisible(seat: Seat, threshold: number | null): boolean {
if (threshold == null) return true;
if (seat.status === "unknown") return true;
if (seat.freshness === "stale") return true;
return seat.usedPercentage != null && seat.usedPercentage >= threshold;
}
export function contextCommand(depsOverride?: ContextDeps): Command {
const cmd = new Command("context")
.description("Show context-usage across running agents")
.addHelpText("after", `
Examples:
rig context Show all seats with context usage
rig context --rig openrig-pm Show one rig
rig context --threshold 80 Show seats at or above 80% (plus unknown + stale)
rig context --refresh Re-sample context before displaying
rig context --json JSON output for agents`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Show one rig only")
.option("--threshold <pct>", "Show seats at or above this percentage", parseInt)
.option("--refresh", "Re-sample context usage before displaying")
.action(async (opts: { json?: boolean; rig?: string; threshold?: number; refresh?: boolean }) => {
const deps = getDepsF();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon is not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
// Get rig list to iterate
const psResult = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
const rigs = psResult.data ?? [];
const targetRigs = opts.rig
? rigs.filter((r) => r.name === opts.rig)
: rigs;
if (opts.rig && targetRigs.length === 0) {
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
process.exitCode = 1;
return;
}
// Refresh once globally if requested (pollOnce samples all active Claude sessions)
let refreshFailed = false;
if (opts.refresh && targetRigs.length > 0) {
const firstRig = targetRigs[0]!;
try {
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
if (refreshResult.status >= 400) {
refreshFailed = true;
if (!opts.json) {
console.error("Context refresh failed. Displaying stale data.");
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
console.error("");
}
}
} catch {
refreshFailed = true;
if (!opts.json) {
console.error("Context refresh failed. Displaying stale data.");
console.error("");
}
}
}
// Fetch node inventory per rig (without refresh — already done globally)
const allNodes: NodeEntry[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
allNodes.push(...nodesResult.data);
}
}
const seats = sortSeats(
allNodes
.map(analyze)
.filter((s) => isVisible(s, opts.threshold ?? null))
);
const summary = {
total: seats.length,
known: seats.filter((s) => s.usedPercentage != null).length,
critical: seats.filter((s) => s.urgency === "critical").length,
warning: seats.filter((s) => s.urgency === "warning").length,
ok: seats.filter((s) => s.status === "ok").length,
stale: seats.filter((s) => s.freshness === "stale").length,
unknown: seats.filter((s) => s.status === "unknown").length,
};
if (opts.json) {
const output: Record<string, unknown> = { seats, summary };
if (refreshFailed) {
output.refreshFailed = true;
output.refreshError = "Context refresh failed. Data may be stale.";
}
console.log(JSON.stringify(output, null, 2));
return;
}
// Human-readable output
const title = opts.rig ? `CONTEXT USAGE - ${opts.rig}` : "CONTEXT USAGE - all rigs";
console.log(title);
console.log(
`${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} status`
);
for (const seat of seats) {
const context = seat.usedPercentage == null ? "?" : `${seat.usedPercentage}%`;
const window = seat.contextWindowSize == null ? "?" : String(seat.contextWindowSize);
console.log(
`${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${seat.displayStatus}`
);
}
console.log();
console.log(
`FLEET SUMMARY: ${summary.known}/${summary.total} known | ` +
`${summary.critical} CRITICAL | ${summary.warning} WARNING | ` +
`${summary.ok} ok | ${summary.stale} stale | ${summary.unknown} unknown`
);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 2;
}
});
return cmd;
}
+2
View File
@@ -43,6 +43,7 @@ import { removeCommand } from "./commands/remove.js";
import { shrinkCommand } from "./commands/shrink.js";
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
import { setupCommand } from "./commands/setup.js";
import { contextCommand } from "./commands/context.js";
import type { LifecycleDeps } from "./daemon-lifecycle.js";
import { CLI_VERSION } from "./version.js";
@@ -136,6 +137,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
program.addCommand(setupCommand());
program.addCommand(contextCommand());
return program;
}
@@ -4,7 +4,7 @@ import { readFileSync, existsSync } from "node:fs";
import type { ContextUsage, ContextUnknownReason } from "./types.js";
/** Freshness threshold: samples older than this are considered stale for compact displays. */
export const FRESHNESS_THRESHOLD_MS = 120_000; // 2 minutes
export const FRESHNESS_THRESHOLD_MS = 600_000; // 10 minutes per PM spec
export interface ContextUsageStoreOpts {
stateDir: string;
+19 -1
View File
@@ -31,11 +31,29 @@ sessionsRoutes.get("/", (c) => {
});
// GET /api/rigs/:rigId/nodes — node inventory projection
nodesRoutes.get("/", (c) => {
// ?refresh=true triggers a context-monitor re-sample before responding
nodesRoutes.get("/", async (c) => {
const rigId = c.req.param("rigId")!;
const deps = getDeps(c);
const rig = deps.rigRepo.getRig(rigId);
if (!rig) return c.json({ error: `Rig "${rigId}" not found. List rigs with: rig ps` }, 404);
const refresh = c.req.query("refresh") === "true";
if (refresh) {
const monitor = c.get("contextMonitor" as never) as { pollOnce(): Promise<void> } | undefined;
if (monitor) {
try {
await monitor.pollOnce();
} catch (err) {
return c.json({
error: "Context refresh failed. Stale data may be returned.",
code: "context_refresh_failed",
detail: err instanceof Error ? err.message : String(err),
}, 502);
}
}
}
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
const inventory = contextUsageStore
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore)
+2
View File
@@ -98,6 +98,7 @@ export interface AppDeps {
specLibraryService?: SpecLibraryService;
whoamiService?: WhoamiService;
contextUsageStore?: import("./domain/context-usage-store.js").ContextUsageStore;
contextMonitor?: { pollOnce(): Promise<void> };
nodeCmuxService?: import("./domain/node-cmux-service.js").NodeCmuxService;
serviceOrchestrator?: import("./domain/service-orchestrator.js").ServiceOrchestrator;
composeAdapter?: import("./adapters/compose-services-adapter.js").ComposeServicesAdapter;
@@ -236,6 +237,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("specLibraryService" as never, deps.specLibraryService);
c.set("whoamiService" as never, deps.whoamiService);
c.set("contextUsageStore" as never, deps.contextUsageStore);
c.set("contextMonitor" as never, deps.contextMonitor);
c.set("nodeCmuxService" as never, deps.nodeCmuxService);
c.set("serviceOrchestrator" as never, deps.serviceOrchestrator);
c.set("composeAdapter" as never, deps.composeAdapter);
+5 -3
View File
@@ -399,11 +399,13 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
}
} catch { /* best-effort — reference docs are not critical to daemon operation */ }
const app = createApp(deps);
// Context monitor — caller (index.ts) starts polling after listen
// Context monitor — constructed before createApp so routes can access pollOnce for refresh.
// Caller (index.ts) starts polling after listen.
const { ContextMonitor } = await import("./domain/context-monitor.js");
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter);
deps.contextMonitor = contextMonitor;
const app = createApp(deps);
return { app, db, deps, contextMonitor };
}
@@ -174,7 +174,7 @@ describe("ContextUsageStore", () => {
// T9: Freshness threshold is centralized
it("FRESHNESS_THRESHOLD_MS is exported and used consistently", () => {
expect(typeof FRESHNESS_THRESHOLD_MS).toBe("number");
expect(FRESHNESS_THRESHOLD_MS).toBe(120_000);
expect(FRESHNESS_THRESHOLD_MS).toBe(600_000);
});
// T10: context_usage row cascades on node delete
+17 -3
View File
@@ -165,9 +165,23 @@ function PodBranch({
testId={`node-icon-${node.logicalId}`}
/>
<span className="font-mono text-[10px] text-stone-700 truncate">{memberName}</span>
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase text-stone-400">
{node.nodeKind === "infrastructure" ? "INFRA" : (node.runtime ?? "").replace("claude-code", "claude")}
</span>
{node.contextUsage?.availability === "known" && typeof node.contextUsage.usedPercentage === "number" ? (
<span
className={`ml-auto shrink-0 font-mono text-[10px] font-bold ${
node.contextUsage.usedPercentage >= 80 ? "text-red-600" :
node.contextUsage.usedPercentage >= 60 ? "text-amber-600" :
"text-green-700"
}${!node.contextUsage.fresh ? " opacity-50" : ""}`}
data-testid={`context-indicator-${node.logicalId}`}
title={node.contextUsage.fresh ? `${node.contextUsage.usedPercentage}% context (fresh)` : `${node.contextUsage.usedPercentage}% context (stale)`}
>
{node.contextUsage.usedPercentage}%
</span>
) : (
<span className="ml-auto shrink-0 font-mono text-[8px] uppercase text-stone-400">
{node.nodeKind === "infrastructure" ? "INFRA" : (node.runtime ?? "").replace("claude-code", "claude")}
</span>
)}
</button>
);
})}
+14 -6
View File
@@ -194,14 +194,22 @@ export function RigNode({ data }: { data: RigNodeData }) {
</div>
)}
{/* Context usage badge */}
{data.contextAvailability === "known" && data.contextFresh && typeof data.contextUsedPercentage === "number" ? (
<div className="font-mono text-[8px] text-stone-500" data-testid="context-badge">
CTX {data.contextUsedPercentage}%
{/* Context usage — prominent big number per founder directive */}
{data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number" ? (
<div
className={`font-mono text-base font-bold leading-tight ${
data.contextUsedPercentage >= 80 ? "text-red-600" :
data.contextUsedPercentage >= 60 ? "text-amber-600" :
"text-green-700"
}${!data.contextFresh ? " opacity-50" : ""}`}
data-testid="context-badge"
title={data.contextFresh ? "Context usage (fresh)" : "Context usage (stale sample)"}
>
{data.contextUsedPercentage}%
</div>
) : (
<div className="font-mono text-[8px] text-stone-300" data-testid="context-badge-unknown">
CTX unknown
<div className="font-mono text-xs text-stone-400" data-testid="context-badge-unknown">
?
</div>
)}
@@ -15,6 +15,14 @@ export interface NodeInventoryEntry {
tmuxAttachCommand: string | null;
resumeCommand: string | null;
latestError: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
}
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {