Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions gui/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -863,20 +863,25 @@ function renderSessions(summary) {
header.appendChild(idEl);
header.appendChild(connBadge);
row.appendChild(header);
const modelsEl = document.createElement('div');
modelsEl.className = 'session-models';
row.appendChild(modelsEl);
const meta = document.createElement('div');
meta.className = 'session-meta';
row.appendChild(meta);
sessionsEl.appendChild(row);
row._idEl = idEl;
row._modelsEl = modelsEl;
row._connEl = connBadge;
row._metaEl = meta;
sessionRows.set(s.sessionId, row);
}
// Show model names (e.g. "glm-5.1, MiniMax-M2.7") as primary identifier
const modelNames = s.models && s.models.length > 0
? s.models.join(', ')
: (s.sessionId.length > 12 ? s.sessionId.slice(0, 8) + '\u2026' : s.sessionId);
if (row._idEl.textContent !== modelNames) row._idEl.textContent = modelNames;
// Session name (light blue) — show name or fallback to truncated session ID
const nameText = s.name || (s.sessionId.length > 12 ? s.sessionId.slice(0, 8) + '\u2026' : s.sessionId);
if (row._idEl.textContent !== nameText) row._idEl.textContent = nameText;
// Model names on a separate line
const modelNames = s.models && s.models.length > 0 ? s.models.join(', ') : '';
if (row._modelsEl.textContent !== modelNames) row._modelsEl.textContent = modelNames;
// Connection count badge (show modelCount from pool, or "—" if unavailable)
const connCount = s.modelCount ?? 0;
const badgeText = connCount > 0 ? connCount + ' conn' + (connCount !== 1 ? 's' : '') : '\u2014';
Expand Down
13 changes: 11 additions & 2 deletions gui/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -644,15 +644,24 @@ body {
}

.session-id {
color: var(--text);
color: #7ec8e3;
font-family: monospace;
font-size: 12px;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}

.session-models {
color: var(--text-dim);
font-size: 9px;
margin-top: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.session-conn-badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.08);
Expand Down
4 changes: 4 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,10 @@ export function createApp(initConfig: AppConfig, logLevel: LogLevel, metricsStor
const sessionId = c.req.header("x-session-id") || c.req.header("x-claude-code-session-id");
if (sessionId) ctx.sessionId = sessionId;

// Extract session name (human-readable label from ANTHROPIC_CUSTOM_HEADERS)
const sessionName = c.req.header("x-session-name");
if (sessionId && sessionName) sessionPool.setName(sessionId, sessionName);

// Global backoff: all providers in the chain are unhealthy (health < 0.5).
// Skip the entire fallback chain and return 503 immediately.
if (ctx._globalBackoff) {
Expand Down
74 changes: 74 additions & 0 deletions src/session-pool.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// src/session-pool.ts
import { Agent, type Dispatcher } from "undici";
import { readdir, readFile } from "fs/promises";
import { join } from "path";
import { homedir } from "os";

export interface SessionStats {
id: string;
name?: string; // Human-readable session name (slug from Claude Code JSONL or x-session-name header)
modelCount: number;
lastActivity: string; // ISO 8601
idleMs: number;
Expand Down Expand Up @@ -37,6 +41,10 @@ export class SessionAgentPool {
private lastActivity = new Map<string, Map<string, number>>();
/** sessionId → modelName → in-flight request count (prevents stale close on active streams) */
private inFlight = new Map<string, Map<string, number>>();
/** sessionId → human-readable name (from x-session-name header or Claude Code JSONL slug) */
private sessionNames = new Map<string, string>();
/** sessionId → promise (prevents concurrent slug lookups for same session) */
private slugResolving = new Map<string, Promise<string | undefined>>();
private sweepTimer: ReturnType<typeof setInterval> | null = null;
private idleTtlMs: number;
private staleThresholdMs: number;
Expand All @@ -56,6 +64,11 @@ export class SessionAgentPool {
get(sessionId: string | undefined, modelName: string): Dispatcher | null {
if (!sessionId) return null;

// Lazily resolve Claude Code session slug on first encounter
if (!this.agents.has(sessionId)) {
this.resolveSlug(sessionId);
}

let modelMap = this.agents.get(sessionId);
if (!modelMap) {
modelMap = new Map();
Expand Down Expand Up @@ -110,6 +123,63 @@ export class SessionAgentPool {
return agent;
}

/** Store a human-readable name for a session (from x-session-name header). */
setName(sessionId: string, name: string): void {
this.sessionNames.set(sessionId, name);
}

/**
* Lazily resolve a Claude Code session name from ~/.claude/projects/ JSONL files.
* Claude Code stores two name fields:
* - `customTitle`: set via /rename or --name (user's explicit choice)
* - `slug`: auto-generated like "clever-waddling-allen"
* Prefers customTitle over slug. Scans project directories once per session and caches.
*/
private resolveSlug(sessionId: string): void {
// Already known (from header or previous lookup)
if (this.sessionNames.has(sessionId)) return;
// Already resolving
if (this.slugResolving.has(sessionId)) return;

const promise = (async (): Promise<string | undefined> => {
try {
const projectsDir = join(homedir(), ".claude", "projects");
const dirs = await readdir(projectsDir);
for (const dir of dirs) {
const sessionFile = join(projectsDir, dir, `${sessionId}.jsonl`);
try {
const content = await readFile(sessionFile, "utf-8");
// Scan the entire file — customTitle (from /rename) can appear anywhere,
// and session files are typically <5MB (one-time cost, cached after)
// Use matchAll to get the LAST customTitle (user may rename multiple times)
const titles = [...content.matchAll(/"customTitle"\s*:\s*"([^"]+)"/g)];
if (titles.length > 0) {
const name = titles[titles.length - 1][1];
this.sessionNames.set(sessionId, name);
return name;
}
// Fall back to slug (auto-generated name) — also take last occurrence
const slugs = [...content.matchAll(/"slug"\s*:\s*"([^"]+)"/g)];
if (slugs.length > 0) {
const name = slugs[slugs.length - 1][1];
this.sessionNames.set(sessionId, name);
return name;
}
} catch {
// File not found or unreadable — skip this directory
}
}
} catch {
// ~/.claude/projects/ not readable — skip silently
} finally {
this.slugResolving.delete(sessionId);
}
return undefined;
})();

this.slugResolving.set(sessionId, promise);
}

/** Decrement in-flight count for a session+model (call when request completes) */
release(sessionId: string, modelName: string): void {
const flightMap = this.inFlight.get(sessionId);
Expand Down Expand Up @@ -154,6 +224,7 @@ export class SessionAgentPool {
if (!providerMap || providerMap.size === 0) {
this.agents.delete(sessionId);
this.lastActivity.delete(sessionId);
this.sessionNames.delete(sessionId);
}
}
}
Expand All @@ -172,6 +243,7 @@ export class SessionAgentPool {
this.agents.delete(sessionId);
this.lastActivity.delete(sessionId);
this.inFlight.delete(sessionId);
this.sessionNames.delete(sessionId);
}
}

Expand All @@ -190,6 +262,7 @@ export class SessionAgentPool {
this.agents.clear();
this.lastActivity.clear();
this.inFlight.clear();
this.sessionNames.clear();
await Promise.all(promises);
}

Expand All @@ -202,6 +275,7 @@ export class SessionAgentPool {
if (entries.length === 0) continue; // skip stale entries (sweep may have emptied the map)
result.push({
id: sessionId,
name: this.sessionNames.get(sessionId),
modelCount: entries.length,
lastActivity: new Date(Math.max(...entries.map(([, ts]) => ts))).toISOString(),
idleMs: now - Math.max(...entries.map(([, ts]) => ts)),
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface MetricsSummary {
providerDistribution: { provider: string; count: number }[];
recentRequests: RequestMetrics[];
modelStats: ModelPerformanceStats[];
sessionStats: { sessionId: string; requestCount: number; lastSeen: number; modelCount?: number; models?: string[] }[];
sessionStats: { sessionId: string; requestCount: number; lastSeen: number; modelCount?: number; models?: string[]; name?: string }[];
providerErrors: { [provider: string]: { total: number; errors: { [status: number]: number }; lastErrorCode: number | null; lastErrorTime: number | null } };
smartTierCounts?: { tier1: number; tier2: number; passthrough: number };
}
Expand Down
2 changes: 1 addition & 1 deletion src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function enrichWithPoolStats(summary: MetricsSummary, getPoolStats?: () => Sessi
summary.sessionStats = summary.sessionStats.map(entry => {
const poolEntry = poolMap.get(entry.sessionId);
if (poolEntry) {
return { ...entry, modelCount: poolEntry.modelCount, models: poolEntry.models };
return { ...entry, modelCount: poolEntry.modelCount, models: poolEntry.models, name: poolEntry.name };
}
return entry;
});
Expand Down
Loading