COD-8 add away digest

This commit is contained in:
Aamer Akhter
2026-06-19 17:58:06 -04:00
parent 1255e28f6f
commit c95e94e4cb
9 changed files with 1273 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
import type { LifecycleEntry, RunSummary, RunSummaryEvent, TokenUsageEntry } from '../types.js';
export type AwayDigestRangeName = 'since-last-visit' | '1h' | 'today' | '24h' | 'custom';
export type AwayDigestCategory = 'needs_attention' | 'completed' | 'still_running' | 'idle' | 'informational';
export type AwayDigestSectionName = 'needsAttention' | 'completed' | 'stillRunning' | 'idle' | 'informational';
export type AwayDigestSeverity = 'info' | 'success' | 'warning' | 'error';
export type AwayDigestSource = 'lifecycle' | 'run_summary' | 'status' | 'token_stats' | 'subagent';
export type AwayDigestTokenWindowPrecision = 'day' | 'none';
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
const VALID_RANGES = new Set<AwayDigestRangeName>(['since-last-visit', '1h', 'today', '24h', 'custom']);
export interface AwayDigestRange {
range: AwayDigestRangeName;
since: number;
until: number;
}
export interface AwayDigestRangeInput {
range?: string;
since?: number;
until?: number;
lastViewed?: number;
now?: number;
}
export interface AwayDigestSession {
id: string;
name?: string;
status?: string;
inputTokens?: number;
outputTokens?: number;
totalCost?: number;
}
export interface AwayDigestSubagent {
id?: string;
agentId?: string;
sessionId?: string;
description?: string;
status?: string;
lastUpdated?: number;
updatedAt?: number;
completedAt?: number;
modifiedAt?: number;
lastActivityAt?: number;
}
export interface AwayDigestItem {
id: string;
sessionId?: string;
sessionName?: string;
timestamp: number;
category: AwayDigestCategory;
severity: AwayDigestSeverity;
title: string;
detail?: string;
source: AwayDigestSource;
link?: {
type: 'session' | 'run_summary' | 'lifecycle' | 'notification';
sessionId?: string;
};
}
export interface AwayDigestTotals {
sessionsCreated: number;
sessionsExited: number;
activeSessions: number;
needsAttention: number;
completed: number;
errors: number;
warnings: number;
inputTokens?: number;
outputTokens?: number;
estimatedCost?: number;
tokenWindowPrecision: AwayDigestTokenWindowPrecision;
}
export interface AwayDigestResponse {
range: AwayDigestRange;
generatedAt: number;
dataFreshness: {
lifecyclePersisted: true;
tokenStatsPersisted: true;
runSummariesLiveOnly: true;
subagentsLiveOnly: true;
};
totals: AwayDigestTotals;
sections: Record<AwayDigestSectionName, AwayDigestItem[]>;
}
export interface AwayDigestInput {
range: AwayDigestRange;
lifecycleEntries: LifecycleEntry[];
runSummaries: RunSummary[];
sessions: AwayDigestSession[];
dailyTokenStats: TokenUsageEntry[];
subagents: AwayDigestSubagent[];
now?: number;
}
export function resolveAwayDigestRange(input: AwayDigestRangeInput): AwayDigestRange {
const now = input.now ?? Date.now();
const range = (input.range ?? 'since-last-visit') as AwayDigestRangeName;
if (!VALID_RANGES.has(range)) {
throw new Error(`Invalid away digest range: ${input.range}`);
}
let since: number;
const until = finiteOrDefault(input.until, now);
switch (range) {
case 'since-last-visit':
since = finiteOrDefault(input.lastViewed, now - DAY_MS);
break;
case '1h':
since = now - HOUR_MS;
break;
case 'today': {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
since = start.getTime();
break;
}
case '24h':
since = now - DAY_MS;
break;
case 'custom':
if (!Number.isFinite(input.since)) {
throw new Error('Custom away digest range requires a finite since timestamp');
}
since = input.since as number;
break;
}
if (until < since) {
throw new Error('Away digest until timestamp must be greater than or equal to since');
}
return { range, since, until };
}
export function buildAwayDigest(input: AwayDigestInput): AwayDigestResponse {
const now = input.now ?? Date.now();
const sections: Record<AwayDigestSectionName, AwayDigestItem[]> = {
needsAttention: [],
completed: [],
stillRunning: [],
idle: [],
informational: [],
};
const sessionsById = new Map(input.sessions.map((session) => [session.id, session]));
const lifecycleEntries = input.lifecycleEntries.filter((entry) => isInRange(entry.ts, input.range));
for (const entry of lifecycleEntries) {
addItem(sections, lifecycleEntryToItem(entry));
}
for (const summary of input.runSummaries) {
for (const event of summary.events) {
if (!isInRange(event.timestamp, input.range)) continue;
addItem(sections, runSummaryEventToItem(summary, event));
}
}
for (const session of input.sessions) {
const item = sessionToItem(session, now);
addItem(sections, item);
}
for (const subagent of input.subagents) {
const timestamp = subagentTimestamp(subagent, now);
if (!isInRange(timestamp, input.range) || subagent.status !== 'completed') continue;
addItem(sections, subagentToItem(subagent, sessionsById, timestamp));
}
const tokenTotals = aggregateTokenStats(input.dailyTokenStats, input.range);
const totals = calculateTotals(sections, lifecycleEntries, input.sessions, tokenTotals);
return {
range: input.range,
generatedAt: now,
dataFreshness: {
lifecyclePersisted: true,
tokenStatsPersisted: true,
runSummariesLiveOnly: true,
subagentsLiveOnly: true,
},
totals,
sections,
};
}
function finiteOrDefault(value: number | undefined, fallback: number): number {
return Number.isFinite(value) ? (value as number) : fallback;
}
function isInRange(timestamp: number, range: AwayDigestRange): boolean {
return timestamp >= range.since && timestamp <= range.until;
}
function addItem(sections: Record<AwayDigestSectionName, AwayDigestItem[]>, item: AwayDigestItem): void {
sections[sectionNameForCategory(item.category)].push(item);
}
function sectionNameForCategory(category: AwayDigestCategory): AwayDigestSectionName {
switch (category) {
case 'needs_attention':
return 'needsAttention';
case 'still_running':
return 'stillRunning';
case 'completed':
case 'idle':
case 'informational':
return category;
}
}
function lifecycleEntryToItem(entry: LifecycleEntry): AwayDigestItem {
const needsAttention = entry.event === 'mux_died' || (entry.event === 'exit' && (entry.exitCode ?? 0) !== 0);
return {
id: `lifecycle-${entry.ts}-${entry.event}-${entry.sessionId}`,
sessionId: entry.sessionId,
sessionName: entry.name,
timestamp: entry.ts,
category: needsAttention ? 'needs_attention' : 'informational',
severity: needsAttention ? 'error' : entry.event === 'exit' ? 'info' : 'info',
title: lifecycleTitle(entry),
detail: lifecycleDetail(entry),
source: 'lifecycle',
link: { type: 'lifecycle', sessionId: entry.sessionId },
};
}
function lifecycleTitle(entry: LifecycleEntry): string {
if (entry.event === 'exit') {
return (entry.exitCode ?? 0) === 0 ? 'Session exited' : 'Session exited with error';
}
if (entry.event === 'mux_died') return 'Tmux session died';
return `Session ${entry.event.replaceAll('_', ' ')}`;
}
function lifecycleDetail(entry: LifecycleEntry): string | undefined {
if (entry.reason) return entry.reason;
if (entry.event === 'exit' && entry.exitCode !== undefined && entry.exitCode !== null) {
return `Exit code ${entry.exitCode}`;
}
return undefined;
}
function runSummaryEventToItem(summary: RunSummary, event: RunSummaryEvent): AwayDigestItem {
const category = runSummaryCategory(event);
return {
id: `run-summary-${summary.sessionId}-${event.id}`,
sessionId: summary.sessionId,
sessionName: summary.sessionName,
timestamp: event.timestamp,
category,
severity: runSummarySeverity(event, category),
title: event.title,
detail: event.details,
source: 'run_summary',
link: { type: 'run_summary', sessionId: summary.sessionId },
};
}
function runSummaryCategory(event: RunSummaryEvent): AwayDigestCategory {
if (event.type === 'ralph_completion') return 'completed';
if (event.severity === 'error' || event.severity === 'warning' || event.type === 'state_stuck') {
return 'needs_attention';
}
return 'informational';
}
function runSummarySeverity(event: RunSummaryEvent, category: AwayDigestCategory): AwayDigestSeverity {
if (category === 'completed') return 'success';
return event.severity;
}
function sessionToItem(session: AwayDigestSession, now: number): AwayDigestItem {
const isIdle = session.status === 'idle';
return {
id: `status-${session.id}`,
sessionId: session.id,
sessionName: session.name,
timestamp: now,
category: isIdle ? 'idle' : 'still_running',
severity: isIdle ? 'info' : 'success',
title: isIdle ? 'Session idle' : 'Session still running',
detail: session.status ? `Status: ${session.status}` : undefined,
source: 'status',
link: { type: 'session', sessionId: session.id },
};
}
function subagentToItem(
subagent: AwayDigestSubagent,
sessionsById: Map<string, AwayDigestSession>,
timestamp: number
): AwayDigestItem {
const session = subagent.sessionId ? sessionsById.get(subagent.sessionId) : undefined;
const agentId = subagent.id ?? subagent.agentId ?? 'unknown';
return {
id: `subagent-${agentId}`,
sessionId: subagent.sessionId,
sessionName: session?.name,
timestamp,
category: 'informational',
severity: 'success',
title: 'Subagent completed',
detail: subagent.description,
source: 'subagent',
link: subagent.sessionId ? { type: 'session', sessionId: subagent.sessionId } : undefined,
};
}
function subagentTimestamp(subagent: AwayDigestSubagent, fallback: number): number {
return (
subagent.completedAt ??
subagent.lastUpdated ??
subagent.updatedAt ??
subagent.modifiedAt ??
subagent.lastActivityAt ??
fallback
);
}
function aggregateTokenStats(
dailyTokenStats: TokenUsageEntry[],
range: AwayDigestRange
): { inputTokens: number; outputTokens: number; estimatedCost: number; precision: AwayDigestTokenWindowPrecision } {
let inputTokens = 0;
let outputTokens = 0;
let estimatedCost = 0;
for (const day of dailyTokenStats) {
if (!dayOverlapsRange(day.date, range)) continue;
inputTokens += day.inputTokens;
outputTokens += day.outputTokens;
estimatedCost += day.estimatedCost;
}
return {
inputTokens,
outputTokens,
estimatedCost,
precision: inputTokens > 0 || outputTokens > 0 || estimatedCost > 0 ? 'day' : 'none',
};
}
function dayOverlapsRange(date: string, range: AwayDigestRange): boolean {
const dayStart = new Date(`${date}T00:00:00`).getTime();
const dayEnd = dayStart + DAY_MS - 1;
return dayStart <= range.until && dayEnd >= range.since;
}
function calculateTotals(
sections: Record<AwayDigestSectionName, AwayDigestItem[]>,
lifecycleEntries: LifecycleEntry[],
sessions: AwayDigestSession[],
tokenTotals: ReturnType<typeof aggregateTokenStats>
): AwayDigestTotals {
const allItems = Object.values(sections).flat();
return {
sessionsCreated: lifecycleEntries.filter((entry) => entry.event === 'created').length,
sessionsExited: lifecycleEntries.filter((entry) => entry.event === 'exit').length,
activeSessions: sessions.length,
needsAttention: sections.needsAttention.length,
completed: sections.completed.length,
errors: allItems.filter((item) => item.severity === 'error').length,
warnings: allItems.filter((item) => item.severity === 'warning').length,
inputTokens: tokenTotals.inputTokens,
outputTokens: tokenTotals.outputTokens,
estimatedCost: tokenTotals.estimatedCost,
tokenWindowPrecision: tokenTotals.precision,
};
}
+39
View File
@@ -118,6 +118,7 @@
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
@@ -1863,6 +1864,44 @@
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
</div>
<!-- Away Digest Modal -->
<div class="modal" id="awayDigestModal">
<div class="modal-backdrop" onclick="app.closeAwayDigest()"></div>
<div class="modal-content away-digest-modal">
<div class="modal-header">
<h3>Away Digest</h3>
<div class="modal-header-actions">
<button class="btn-toolbar btn-sm" onclick="app.loadAwayDigest()" title="Refresh away digest">&#x21BB; Refresh</button>
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">&times;</button>
</div>
</div>
<div class="modal-body">
<div class="away-digest-ranges" role="group" aria-label="Away digest range">
<button class="filter-btn active" data-away-range="since-last-visit" onclick="app.setAwayDigestRange('since-last-visit')">Since last visit</button>
<button class="filter-btn" data-away-range="1h" onclick="app.setAwayDigestRange('1h')">Last hour</button>
<button class="filter-btn" data-away-range="today" onclick="app.setAwayDigestRange('today')">Today</button>
<button class="filter-btn" data-away-range="24h" onclick="app.setAwayDigestRange('24h')">24h</button>
<button class="filter-btn" data-away-range="custom" onclick="app.setAwayDigestRange('custom')">Custom</button>
</div>
<div class="away-digest-custom-range" id="awayDigestCustomRange">
<label>
Since
<input type="datetime-local" id="awayDigestCustomSince">
</label>
<label>
Until
<input type="datetime-local" id="awayDigestCustomUntil">
</label>
</div>
<div class="away-digest-summary" id="awayDigestSummary"></div>
<div class="away-digest-freshness" id="awayDigestFreshness"></div>
<div class="away-digest-sections" id="awayDigestSections">
<p class="empty-message">Open the digest to load recent activity</p>
</div>
</div>
</div>
</div>
<!-- Token Stats Modal -->
<div class="modal" id="tokenStatsModal">
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
+38
View File
@@ -1163,6 +1163,44 @@ html.mobile-init .file-browser-panel {
-webkit-overflow-scrolling: touch;
}
.away-digest-modal {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}
.away-digest-ranges {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.4rem;
}
.away-digest-ranges .filter-btn {
min-height: 38px;
padding: 0.4rem 0.5rem;
}
.away-digest-custom-range,
.away-digest-custom-range.active {
grid-template-columns: 1fr;
}
.away-digest-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.away-digest-item {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.away-digest-action {
width: 100%;
min-height: 38px;
}
.modal-footer,
.form-actions {
padding: 0.75rem 1rem;
+274
View File
@@ -13,6 +13,15 @@
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
*/
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
['stillRunning', 'Still Running'],
['idle', 'Idle'],
['informational', 'Informational'],
];
Object.assign(CodemanApp.prototype, {
_addActivityEntry(agentId, entry, maxSize = 50) {
const activity = this.subagentActivity.get(agentId) || [];
@@ -242,6 +251,271 @@ Object.assign(CodemanApp.prototype, {
},
// ═══════════════════════════════════════════════════════════════
// Away Digest Modal
// ═══════════════════════════════════════════════════════════════
async openAwayDigest(range = 'since-last-visit') {
this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false;
this._awayDigestSinceLastVisitGeneratedAt = undefined;
const modal = document.getElementById('awayDigestModal');
if (modal) modal.classList.add('active');
this.updateAwayDigestRangeControls();
await this.loadAwayDigest();
},
closeAwayDigest() {
const modal = document.getElementById('awayDigestModal');
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
if (Number.isFinite(generatedAt)) {
try {
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
} catch (err) {
console.warn('Failed to save away digest last-viewed marker:', err);
}
}
if (modal) modal.classList.remove('active');
},
setAwayDigestRange(range) {
this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false;
this.updateAwayDigestRangeControls();
this.loadAwayDigest();
},
updateAwayDigestRangeControls() {
const range = this.awayDigestRange || 'since-last-visit';
document.querySelectorAll('[data-away-range]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.awayRange === range);
});
const customRange = document.getElementById('awayDigestCustomRange');
if (customRange) customRange.classList.toggle('active', range === 'custom');
if (range === 'custom') this.ensureAwayDigestCustomDefaults();
},
ensureAwayDigestCustomDefaults() {
const sinceInput = document.getElementById('awayDigestCustomSince');
const untilInput = document.getElementById('awayDigestCustomUntil');
if (!sinceInput || !untilInput) return;
const now = new Date();
if (!untilInput.value) untilInput.value = this.formatAwayDigestDateTimeLocal(now);
if (!sinceInput.value) {
const since = new Date(now.getTime() - 60 * 60 * 1000);
sinceInput.value = this.formatAwayDigestDateTimeLocal(since);
}
},
formatAwayDigestDateTimeLocal(date) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
},
async loadAwayDigest() {
const summaryEl = document.getElementById('awayDigestSummary');
const freshnessEl = document.getElementById('awayDigestFreshness');
const sectionsEl = document.getElementById('awayDigestSections');
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-loading">Loading digest...</div>';
if (freshnessEl) freshnessEl.textContent = '';
if (sectionsEl) sectionsEl.innerHTML = '';
try {
const range = this.awayDigestRange || 'since-last-visit';
const params = new URLSearchParams({ range });
if (range === 'since-last-visit') {
const lastViewed = this.readAwayDigestLastViewed();
if (Number.isFinite(lastViewed)) params.set('lastViewed', String(lastViewed));
}
if (range === 'custom') {
this.ensureAwayDigestCustomDefaults();
const since = this.readAwayDigestDateTimeInput('awayDigestCustomSince');
const until = this.readAwayDigestDateTimeInput('awayDigestCustomUntil');
if (!Number.isFinite(since)) {
throw new Error('Choose a custom start time');
}
params.set('since', String(since));
if (Number.isFinite(until)) params.set('until', String(until));
}
const response = await fetch(`/api/away-digest?${params.toString()}`);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to load away digest');
}
this._awayDigestLoadedSuccessfully = true;
this._awayDigestGeneratedAt = data.digest.generatedAt;
if (range === 'since-last-visit') {
this._awayDigestSinceLastVisitGeneratedAt = data.digest.generatedAt;
}
this.renderAwayDigest(data.digest);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load away digest';
console.error('Failed to fetch away digest:', err);
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-load-error">Failed to load away digest</div>';
if (sectionsEl) {
sectionsEl.innerHTML = `<div class="empty-message">${escapeHtml(message)}</div>`;
}
this.showToast(message, 'error');
}
},
readAwayDigestLastViewed() {
try {
const value = localStorage.getItem(AWAY_DIGEST_LAST_VIEWED_KEY);
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
} catch {
return undefined;
}
},
readAwayDigestDateTimeInput(id) {
const input = document.getElementById(id);
if (!input || !input.value) return undefined;
const parsed = Date.parse(input.value);
return Number.isFinite(parsed) ? parsed : undefined;
},
renderAwayDigest(digest) {
const summaryEl = document.getElementById('awayDigestSummary');
const freshnessEl = document.getElementById('awayDigestFreshness');
const sectionsEl = document.getElementById('awayDigestSections');
if (!summaryEl || !freshnessEl || !sectionsEl) return;
const inputTokens = digest.totals.inputTokens || 0;
const outputTokens = digest.totals.outputTokens || 0;
const estimatedCost = digest.totals.estimatedCost || 0;
summaryEl.innerHTML = `
<div class="away-digest-card">
<span class="away-digest-card-label">Needs Attention</span>
<span class="away-digest-card-value">${digest.totals.needsAttention}</span>
</div>
<div class="away-digest-card">
<span class="away-digest-card-label">Completed</span>
<span class="away-digest-card-value">${digest.totals.completed}</span>
</div>
<div class="away-digest-card">
<span class="away-digest-card-label">Active Sessions</span>
<span class="away-digest-card-value">${digest.totals.activeSessions}</span>
</div>
<div class="away-digest-card">
<span class="away-digest-card-label">Tokens</span>
<span class="away-digest-card-value">${this.formatTokens(inputTokens + outputTokens)}</span>
<span class="away-digest-card-cost">~$${estimatedCost.toFixed(2)}</span>
</div>
`;
const freshnessNotes = [];
if (digest.dataFreshness.runSummariesLiveOnly || digest.dataFreshness.subagentsLiveOnly) {
freshnessNotes.push('Run summaries and subagent completions use recent live state; lifecycle and token stats are persisted.');
}
if (digest.totals.tokenWindowPrecision === 'day') {
freshnessNotes.push('Token totals are aggregated at day precision.');
}
freshnessEl.textContent = freshnessNotes.join(' ');
sectionsEl.innerHTML = AWAY_DIGEST_SECTIONS
.map(([key, title]) => this.renderAwayDigestSection(title, digest.sections[key] || []))
.join('');
this.attachAwayDigestActions();
},
renderAwayDigestSection(title, items) {
const count = items.length;
const body = count
? items.map(item => this.renderAwayDigestItem(item)).join('')
: '<div class="away-digest-empty">No items</div>';
return `
<section class="away-digest-section">
<div class="away-digest-section-title">
<h4>${escapeHtml(title)}</h4>
<span>${count}</span>
</div>
${body}
</section>
`;
},
renderAwayDigestItem(item) {
const sourceLabel = this.formatAwayDigestSource(item.source);
const sessionLabel = item.sessionName || item.sessionId || '';
const detail = item.detail ? `<div class="away-digest-item-detail">${escapeHtml(item.detail)}</div>` : '';
const action = item.link ? `
<button class="away-digest-action"
data-away-link-type="${escapeHtml(item.link.type)}"
data-away-session-id="${escapeHtml(item.link.sessionId || '')}">
Open
</button>
` : '';
return `
<article class="away-digest-item away-digest-${escapeHtml(item.severity)}">
<div class="away-digest-item-main">
<div class="away-digest-item-meta">
<span>${escapeHtml(this.formatAwayDigestTimestamp(item.timestamp))}</span>
<span>${escapeHtml(sourceLabel)}</span>
${sessionLabel ? `<span>${escapeHtml(sessionLabel)}</span>` : ''}
</div>
<div class="away-digest-item-title">${escapeHtml(item.title)}</div>
${detail}
</div>
${action}
</article>
`;
},
attachAwayDigestActions() {
const sectionsEl = document.getElementById('awayDigestSections');
if (!sectionsEl) return;
sectionsEl.querySelectorAll('[data-away-link-type]').forEach(button => {
button.addEventListener('click', () => {
this.openAwayDigestItem(button.dataset.awayLinkType, button.dataset.awaySessionId || undefined);
});
});
},
async openAwayDigestItem(type, sessionId) {
if (type === 'session' && sessionId) {
await this.selectSession(sessionId);
this.closeAwayDigest();
return;
}
if (type === 'run_summary' && sessionId) {
await this.openRunSummary(sessionId);
this.closeAwayDigest();
return;
}
if (type === 'lifecycle') {
this.openLifecycleLog();
this.closeAwayDigest();
}
},
formatAwayDigestTimestamp(timestamp) {
if (!Number.isFinite(timestamp)) return '';
return new Date(timestamp).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
},
formatAwayDigestSource(source) {
const labels = {
lifecycle: 'Lifecycle',
run_summary: 'Run Summary',
status: 'Status',
token_stats: 'Token Stats',
subagent: 'Subagent',
};
return labels[source] || source;
},
// ═══════════════════════════════════════════════════════════════
// Token Statistics Modal
// ═══════════════════════════════════════════════════════════════
+232
View File
@@ -5108,6 +5108,238 @@ kbd {
color: var(--text);
}
/* Away Digest Modal */
.away-digest-modal {
max-width: 860px;
width: min(92vw, 860px);
max-height: 86vh;
display: flex;
flex-direction: column;
}
.away-digest-modal .modal-body {
overflow-y: auto;
padding: 1rem;
}
.away-digest-ranges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
}
.away-digest-custom-range {
display: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: 8px;
}
.away-digest-custom-range.active {
display: grid;
}
.away-digest-custom-range label {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.away-digest-custom-range input {
min-height: 34px;
padding: 0.35rem 0.5rem;
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 6px;
color: var(--text);
}
.away-digest-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.away-digest-card,
.away-digest-loading,
.away-digest-load-error {
min-height: 74px;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: 8px;
}
.away-digest-loading,
.away-digest-load-error {
grid-column: 1 / -1;
display: flex;
align-items: center;
color: var(--text-dim);
font-size: 0.8rem;
}
.away-digest-load-error {
color: var(--red);
}
.away-digest-card-label {
display: block;
margin-bottom: 0.25rem;
color: var(--text-muted);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.away-digest-card-value {
display: block;
color: var(--text);
font-family: 'SF Mono', Monaco, monospace;
font-size: 1.2rem;
font-weight: 600;
}
.away-digest-card-cost {
display: block;
margin-top: 0.15rem;
color: var(--accent-hover);
font-size: 0.7rem;
}
.away-digest-freshness {
min-height: 18px;
margin-bottom: 0.75rem;
color: var(--text-muted);
font-size: 0.72rem;
line-height: 1.4;
}
.away-digest-sections {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.away-digest-section {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.away-digest-section-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.55rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border);
}
.away-digest-section-title h4 {
margin: 0;
color: var(--text-dim);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.away-digest-section-title span {
color: var(--text-muted);
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.away-digest-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
border-left: 3px solid var(--border-light);
border-bottom: 1px solid var(--border);
}
.away-digest-item:last-child {
border-bottom: none;
}
.away-digest-success {
border-left-color: var(--green);
}
.away-digest-warning {
border-left-color: var(--yellow);
}
.away-digest-error {
border-left-color: var(--red);
}
.away-digest-info {
border-left-color: var(--accent);
}
.away-digest-item-main {
min-width: 0;
}
.away-digest-item-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.55rem;
margin-bottom: 0.3rem;
color: var(--text-muted);
font-size: 0.68rem;
}
.away-digest-item-title {
color: var(--text);
font-size: 0.85rem;
font-weight: 600;
line-height: 1.35;
}
.away-digest-item-detail {
margin-top: 0.25rem;
color: var(--text-dim);
font-size: 0.76rem;
line-height: 1.35;
}
.away-digest-action {
min-height: 32px;
padding: 0.35rem 0.7rem;
background: transparent;
border: 1px solid var(--border-light);
border-radius: 6px;
color: var(--accent-hover);
cursor: pointer;
font-size: 0.75rem;
}
.away-digest-action:hover {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(96, 165, 250, 0.45);
}
.away-digest-empty {
padding: 0.75rem;
color: var(--text-muted);
font-size: 0.78rem;
}
/* Token Stats Modal */
.token-stats-modal {
max-width: 560px;
+62
View File
@@ -29,6 +29,12 @@ import { imageWatcher } from '../../image-watcher.js';
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
import { applyStatusLineConfig } from '../../hooks-config.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import {
buildAwayDigest,
resolveAwayDigestRange,
type AwayDigestSession,
type AwayDigestSubagent,
} from '../away-digest.js';
import {
findSessionOrFail,
formatUptime,
@@ -52,6 +58,12 @@ const SCREENSHOTS_DIR = dataPath('screenshots');
/** Cached CPU count — doesn't change at runtime */
const CPU_COUNT = cpus().length;
function parseOptionalNumber(value: string | undefined): number | undefined {
if (value === undefined || value.trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
/** Get system CPU and memory usage */
function getSystemStats(): {
cpu: number;
@@ -415,6 +427,56 @@ export function registerSystemRoutes(
};
});
app.get('/api/away-digest', async (req, reply) => {
const query = req.query as {
range?: string;
since?: string;
until?: string;
lastViewed?: string;
};
let range;
try {
range = resolveAwayDigestRange({
range: query.range,
since: parseOptionalNumber(query.since),
until: parseOptionalNumber(query.until),
lastViewed: parseOptionalNumber(query.lastViewed),
});
} catch (err) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err));
}
const lifecycleLog = getLifecycleLog();
const lifecycleEntries = await lifecycleLog.query({
since: range.since,
limit: 1000,
});
const sessions: AwayDigestSession[] = Array.from(ctx.sessions.values()).map((session) => ({
id: session.id,
name: session.name,
status: session.status,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalCost: session.totalCost,
}));
const runSummaries = Array.from(ctx.runSummaryTrackers.values()).map((tracker) => tracker.getSummary());
const digest = buildAwayDigest({
range,
lifecycleEntries,
runSummaries,
sessions,
dailyTokenStats: ctx.store.getDailyStats(30),
subagents: subagentWatcher.getRecentSubagents(60) as AwayDigestSubagent[],
now: range.until,
});
return { success: true, digest };
});
// ═══════════════════════════════════════════════════════════════
// Configuration & Settings (config, settings, model config, CPU priority)
// ═══════════════════════════════════════════════════════════════
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const indexHtml = readFileSync(join(process.cwd(), 'src/web/public/index.html'), 'utf-8');
const panelsJs = readFileSync(join(process.cwd(), 'src/web/public/panels-ui.js'), 'utf-8');
const stylesCss = readFileSync(join(process.cwd(), 'src/web/public/styles.css'), 'utf-8');
const mobileCss = readFileSync(join(process.cwd(), 'src/web/public/mobile.css'), 'utf-8');
describe('away digest UI', () => {
it('exposes a header entry point and modal shell', () => {
expect(indexHtml).toContain('onclick="app.openAwayDigest()"');
expect(indexHtml).toContain('aria-label="Open away digest"');
expect(indexHtml).toContain('id="awayDigestModal"');
expect(indexHtml).toContain('id="awayDigestSummary"');
expect(indexHtml).toContain('id="awayDigestSections"');
});
it('offers supported range controls', () => {
expect(indexHtml).toContain('data-away-range="since-last-visit"');
expect(indexHtml).toContain('data-away-range="1h"');
expect(indexHtml).toContain('data-away-range="today"');
expect(indexHtml).toContain('data-away-range="24h"');
expect(indexHtml).toContain('id="awayDigestCustomSince"');
expect(indexHtml).toContain('id="awayDigestCustomUntil"');
});
it('fetches the aggregate endpoint and preserves since-last-visit until successful close', () => {
expect(panelsJs).toContain('/api/away-digest');
expect(panelsJs).toContain('codeman-away-digest-last-viewed');
expect(panelsJs).toContain('openAwayDigest');
expect(panelsJs).toContain('closeAwayDigest');
});
it('has desktop and mobile layout styles', () => {
expect(stylesCss).toContain('.away-digest-modal');
expect(stylesCss).toContain('.away-digest-summary');
expect(stylesCss).toContain('.away-digest-item');
expect(mobileCss).toContain('.away-digest-modal');
expect(mobileCss).toContain('.away-digest-ranges');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { buildAwayDigest, resolveAwayDigestRange } from '../src/web/away-digest.js';
import type { LifecycleEntry, RunSummary } from '../src/types.js';
const NOW = Date.UTC(2026, 5, 7, 12, 0, 0);
const HOUR = 60 * 60 * 1000;
function summary(sessionId: string, events: RunSummary['events']): RunSummary {
return {
sessionId,
sessionName: `${sessionId} name`,
startedAt: NOW - 4 * HOUR,
lastUpdatedAt: NOW - HOUR,
events,
stats: {
totalRespawnCycles: 0,
totalTokensUsed: 0,
peakTokens: 0,
totalTimeActiveMs: 0,
totalTimeIdleMs: 0,
errorCount: events.filter((event) => event.severity === 'error').length,
warningCount: events.filter((event) => event.severity === 'warning').length,
aiCheckCount: 0,
lastIdleAt: null,
lastWorkingAt: null,
stateTransitions: 0,
},
};
}
describe('away digest', () => {
it('resolves since-last-visit after the stored marker and defaults to 24h', () => {
expect(resolveAwayDigestRange({ range: 'since-last-visit', lastViewed: NOW - HOUR, now: NOW })).toMatchObject({
range: 'since-last-visit',
since: NOW - HOUR,
until: NOW,
});
expect(resolveAwayDigestRange({ range: 'since-last-visit', now: NOW })).toMatchObject({
since: NOW - 24 * HOUR,
until: NOW,
});
});
it('separates action-required, completed, live, idle, and informational items', () => {
const lifecycleEntries: LifecycleEntry[] = [
{ ts: NOW - 10_000, event: 'exit', sessionId: 'failed', name: 'Failed Session', exitCode: 2 },
{ ts: NOW - 9_000, event: 'exit', sessionId: 'clean', name: 'Clean Exit', exitCode: 0 },
{ ts: NOW - 8_000, event: 'mux_died', sessionId: 'mux', name: 'Mux Session' },
];
const digest = buildAwayDigest({
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
lifecycleEntries,
runSummaries: [
summary('ralph', [
{
id: 'complete-1',
timestamp: NOW - 7_000,
type: 'ralph_completion',
severity: 'success',
title: 'Ralph completion detected',
details: 'Phrase: COMPLETE',
},
]),
summary('error-session', [
{
id: 'error-1',
timestamp: NOW - 6_000,
type: 'error',
severity: 'error',
title: 'Session error',
details: 'Tool failed',
},
]),
],
sessions: [
{ id: 'active', name: 'Active Session', status: 'working' },
{ id: 'idle', name: 'Idle Session', status: 'idle' },
],
dailyTokenStats: [
{
date: '2026-06-07',
inputTokens: 1_000,
outputTokens: 2_000,
estimatedCost: 0.18,
sessions: 2,
},
],
subagents: [
{
id: 'agent-1',
sessionId: 'active',
description: 'Research complete',
status: 'completed',
lastUpdated: NOW - 5_000,
},
],
now: NOW,
});
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['failed', 'mux', 'error-session']);
expect(digest.sections.completed.map((item) => item.sessionId)).toEqual(['ralph']);
expect(digest.sections.stillRunning.map((item) => item.sessionId)).toEqual(['active']);
expect(digest.sections.idle.map((item) => item.sessionId)).toEqual(['idle']);
expect(digest.sections.informational.map((item) => item.sessionId)).toContain('clean');
expect(digest.sections.informational.some((item) => item.source === 'subagent')).toBe(true);
expect(digest.totals).toMatchObject({
needsAttention: 3,
completed: 1,
activeSessions: 2,
inputTokens: 1_000,
outputTokens: 2_000,
estimatedCost: 0.18,
tokenWindowPrecision: 'day',
});
expect(digest.dataFreshness).toMatchObject({
lifecyclePersisted: true,
tokenStatsPersisted: true,
runSummariesLiveOnly: true,
subagentsLiveOnly: true,
});
});
it('excludes entries outside the selected range', () => {
const digest = buildAwayDigest({
range: resolveAwayDigestRange({ range: '1h', now: NOW }),
lifecycleEntries: [
{ ts: NOW - 2 * HOUR, event: 'mux_died', sessionId: 'old' },
{ ts: NOW - 1000, event: 'mux_died', sessionId: 'recent' },
],
runSummaries: [],
sessions: [],
dailyTokenStats: [],
subagents: [],
now: NOW,
});
expect(digest.sections.needsAttention.map((item) => item.sessionId)).toEqual(['recent']);
});
});
+66
View File
@@ -194,6 +194,72 @@ describe('system-routes', () => {
});
});
// ========== GET /api/away-digest ==========
describe('GET /api/away-digest', () => {
it('returns a classified digest from lifecycle, active sessions, and token stats', async () => {
const lifecycleQuery = vi.fn(async () => [
{
ts: Date.now() - 1000,
event: 'exit',
sessionId: harness.ctx._sessionId,
name: 'Route Session',
exitCode: 7,
},
]);
mockedGetLifecycleLog.mockReturnValue({
log: vi.fn(),
query: lifecycleQuery,
} as never);
harness.ctx._session.name = 'Route Session';
harness.ctx._session.status = 'working';
harness.ctx.store.getDailyStats.mockReturnValue([
{
date: new Date().toISOString().split('T')[0],
inputTokens: 100,
outputTokens: 200,
estimatedCost: 0.02,
sessions: 1,
},
]);
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=1h' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.digest.sections.needsAttention[0]).toMatchObject({
sessionId: harness.ctx._sessionId,
source: 'lifecycle',
});
expect(body.digest.sections.stillRunning[0]).toMatchObject({
sessionId: harness.ctx._sessionId,
source: 'status',
});
expect(body.digest.totals).toMatchObject({
activeSessions: 1,
needsAttention: 1,
inputTokens: 100,
outputTokens: 200,
tokenWindowPrecision: 'day',
});
expect(lifecycleQuery).toHaveBeenCalledWith(
expect.objectContaining({
limit: 1000,
})
);
});
it('rejects invalid ranges', async () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/away-digest?range=forever' });
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.message ?? body.error).toMatch(/range/i);
});
});
// ========== GET /api/debug/memory ==========
describe('GET /api/debug/memory', () => {