aoaoe 0.142.0 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -148,6 +148,7 @@ export function buildSessionStates(obs) {
148
148
  tool: s.tool,
149
149
  status: s.status,
150
150
  path: snap.session.path,
151
+ createdAt: snap.session.created_at,
151
152
  currentTask: sessionTasks.get(s.id),
152
153
  lastActivity: lastActivity && lastActivity.length > 100
153
154
  ? lastActivity.slice(0, 97) + "..."
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { wakeableSleep } from "./wake.js";
16
16
  import { classifyMessages, formatUserMessages, buildReceipts, shouldSkipSleep, hasPendingFile, isInsistMessage, stripInsistPrefix } from "./message.js";
17
17
  import { TaskManager, loadTaskDefinitions, loadTaskState, formatTaskTable, importAoeSessionsToTasks } from "./task-manager.js";
18
18
  import { runTaskCli, handleTaskSlashCommand, quickTaskUpdate } from "./task-cli.js";
19
- import { TUI, hitTestSession, nextSortMode, SORT_MODES, formatUptime, formatClipText, loadTuiPrefs, saveTuiPrefs, validateGroupName, CONTEXT_BURN_THRESHOLD, buildSnapshotData, formatSnapshotJson, formatSnapshotMarkdown, formatBroadcastSummary, rankSessions, TOP_SORT_MODES, formatIdleSince, CONTEXT_CEILING_THRESHOLD, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName, computeErrorTrend, parseQuietHoursRange, computeCostSummary, formatSessionReport } from "./tui.js";
19
+ import { TUI, hitTestSession, nextSortMode, SORT_MODES, formatUptime, formatClipText, loadTuiPrefs, saveTuiPrefs, validateGroupName, CONTEXT_BURN_THRESHOLD, buildSnapshotData, formatSnapshotJson, formatSnapshotMarkdown, formatBroadcastSummary, rankSessions, TOP_SORT_MODES, formatIdleSince, CONTEXT_CEILING_THRESHOLD, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName, computeErrorTrend, parseQuietHoursRange, computeCostSummary, formatSessionReport, formatQuietStatus, formatSessionAge } from "./tui.js";
20
20
  import { isDaemonRunningFromState } from "./chat.js";
21
21
  import { sendNotification, sendTestNotification } from "./notify.js";
22
22
  import { startHealthServer } from "./health.js";
@@ -606,7 +606,9 @@ async function main() {
606
606
  const lastChange = lastChangeAt.get(s.id);
607
607
  const idleStr = (lastChange && (s.status === "idle" || s.status === "stopped" || s.status === "done"))
608
608
  ? ` idle ${formatUptime(now - lastChange)}` : "";
609
- tui.log("system", ` ${s.title}${groupStr} ${s.status} ${up}${ctxStr}${costStr}${errStr}${idleStr}${noteStr}`);
609
+ // session age from AoE created_at
610
+ const ageStr = s.createdAt ? ` age:${formatSessionAge(s.createdAt, now)}` : "";
611
+ tui.log("system", ` ${s.title}${groupStr} — ${s.status} ${up}${ctxStr}${costStr}${errStr}${idleStr}${ageStr}${noteStr}`);
610
612
  }
611
613
  });
612
614
  // wire /uptime listing
@@ -953,7 +955,7 @@ async function main() {
953
955
  input.onExportStats(() => {
954
956
  const sessions = tui.getSessions();
955
957
  const now = Date.now();
956
- const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now, new Map(sessions.map((s) => [s.id, tui.getSessionErrorTimestamps(s.id)])), tui.getAllSessionCosts());
958
+ const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now, new Map(sessions.map((s) => [s.id, tui.getSessionErrorTimestamps(s.id)])), tui.getAllSessionCosts(), new Map(sessions.map((s) => [s.id, tui.getSessionHealthHistory(s.id)])));
957
959
  const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
958
960
  const dir = join(homedir(), ".aoaoe");
959
961
  const path = join(dir, `stats-${ts}.json`);
@@ -1021,6 +1023,65 @@ async function main() {
1021
1023
  tui.log("system", `quiet hours: ${specs.join(", ")} — watchdog+burn alerts suppressed`);
1022
1024
  persistPrefs();
1023
1025
  });
1026
+ // wire /budget cost alerts
1027
+ input.onBudget((target, budgetUSD) => {
1028
+ if (budgetUSD === null) {
1029
+ // clear global budget
1030
+ tui.setGlobalBudget(null);
1031
+ tui.log("system", "budget: global budget cleared");
1032
+ return;
1033
+ }
1034
+ if (target === null) {
1035
+ tui.setGlobalBudget(budgetUSD);
1036
+ tui.log("system", `budget: global budget set to $${budgetUSD.toFixed(2)}`);
1037
+ return;
1038
+ }
1039
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1040
+ const ok = tui.setSessionBudget(num ?? target, budgetUSD);
1041
+ if (ok)
1042
+ tui.log("system", `budget: $${budgetUSD.toFixed(2)} set for ${target}`);
1043
+ else
1044
+ tui.log("system", `session not found: ${target}`);
1045
+ });
1046
+ // wire /pause-all and /resume-all
1047
+ input.onBulkControl((action) => {
1048
+ const sessions = tui.getSessions();
1049
+ if (sessions.length === 0) {
1050
+ tui.log("system", `${action}-all: no sessions`);
1051
+ return;
1052
+ }
1053
+ // ESC for pause, Enter for resume (nudge sessions out of waiting)
1054
+ const keys = action === "pause" ? "Escape" : "Enter";
1055
+ let count = 0;
1056
+ for (const s of sessions) {
1057
+ const tmuxName = computeTmuxName(s.title, s.id);
1058
+ shellExec("tmux", ["send-keys", "-t", tmuxName, "", keys])
1059
+ .then(() => { })
1060
+ .catch((_e) => { });
1061
+ count++;
1062
+ }
1063
+ tui.log("system", `${action}-all: sent to ${count} session${count !== 1 ? "s" : ""}`);
1064
+ });
1065
+ // wire /quiet-status
1066
+ input.onQuietStatus(() => {
1067
+ const { active, message } = formatQuietStatus(tui.getQuietHours());
1068
+ tui.log("system", `quiet-status: ${message}`);
1069
+ if (active)
1070
+ tui.log("system", " watchdog, burn-rate, and ceiling alerts are suppressed");
1071
+ });
1072
+ // wire /alert-log
1073
+ input.onAlertLog((count) => {
1074
+ const alerts = tui.getAlertLog();
1075
+ const recent = alerts.slice(-count);
1076
+ if (recent.length === 0) {
1077
+ tui.log("system", "alert-log: no auto-generated alerts yet");
1078
+ return;
1079
+ }
1080
+ tui.log("system", `alert-log: last ${recent.length} alert${recent.length !== 1 ? "s" : ""}:`);
1081
+ for (const e of recent) {
1082
+ tui.log("system", ` ${e.time} ${e.text}`);
1083
+ }
1084
+ });
1024
1085
  // wire /cost-summary
1025
1086
  input.onCostSummary(() => {
1026
1087
  const sessions = tui.getSessions();
@@ -1133,7 +1194,7 @@ async function main() {
1133
1194
  return;
1134
1195
  }
1135
1196
  const now = Date.now();
1136
- const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now, new Map(sessions.map((s) => [s.id, tui.getSessionErrorTimestamps(s.id)])), tui.getAllSessionCosts());
1197
+ const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now, new Map(sessions.map((s) => [s.id, tui.getSessionErrorTimestamps(s.id)])), tui.getAllSessionCosts(), new Map(sessions.map((s) => [s.id, tui.getSessionHealthHistory(s.id)])));
1137
1198
  tui.log("system", `/stats — ${entries.length} session${entries.length !== 1 ? "s" : ""}:`);
1138
1199
  for (const line of formatSessionStatsLines(entries)) {
1139
1200
  tui.log("system", line);
package/dist/input.d.ts CHANGED
@@ -54,6 +54,10 @@ export type QuietHoursHandler = (specs: string[]) => void;
54
54
  export type HistoryStatsHandler = () => void;
55
55
  export type CostSummaryHandler = () => void;
56
56
  export type SessionReportHandler = (target: string) => void;
57
+ export type QuietStatusHandler = () => void;
58
+ export type AlertLogHandler = (count: number) => void;
59
+ export type BudgetHandler = (target: string | null, budgetUSD: number | null) => void;
60
+ export type BulkControlHandler = (action: "pause" | "resume") => void;
57
61
  export interface MouseEvent {
58
62
  button: number;
59
63
  col: number;
@@ -130,6 +134,10 @@ export declare class InputReader {
130
134
  private historyStatsHandler;
131
135
  private costSummaryHandler;
132
136
  private sessionReportHandler;
137
+ private quietStatusHandler;
138
+ private alertLogHandler;
139
+ private budgetHandler;
140
+ private bulkControlHandler;
133
141
  private aliases;
134
142
  private mouseDataListener;
135
143
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -189,6 +197,10 @@ export declare class InputReader {
189
197
  onHistoryStats(handler: HistoryStatsHandler): void;
190
198
  onCostSummary(handler: CostSummaryHandler): void;
191
199
  onSessionReport(handler: SessionReportHandler): void;
200
+ onQuietStatus(handler: QuietStatusHandler): void;
201
+ onAlertLog(handler: AlertLogHandler): void;
202
+ onBudget(handler: BudgetHandler): void;
203
+ onBulkControl(handler: BulkControlHandler): void;
192
204
  /** Set aliases from persisted prefs. */
193
205
  setAliases(aliases: Record<string, string>): void;
194
206
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -87,6 +87,10 @@ export class InputReader {
87
87
  historyStatsHandler = null;
88
88
  costSummaryHandler = null;
89
89
  sessionReportHandler = null;
90
+ quietStatusHandler = null;
91
+ alertLogHandler = null;
92
+ budgetHandler = null;
93
+ bulkControlHandler = null;
90
94
  aliases = new Map(); // /shortcut → /full command
91
95
  mouseDataListener = null;
92
96
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -299,6 +303,10 @@ export class InputReader {
299
303
  onHistoryStats(handler) { this.historyStatsHandler = handler; }
300
304
  onCostSummary(handler) { this.costSummaryHandler = handler; }
301
305
  onSessionReport(handler) { this.sessionReportHandler = handler; }
306
+ onQuietStatus(handler) { this.quietStatusHandler = handler; }
307
+ onAlertLog(handler) { this.alertLogHandler = handler; }
308
+ onBudget(handler) { this.budgetHandler = handler; }
309
+ onBulkControl(handler) { this.bulkControlHandler = handler; }
302
310
  /** Set aliases from persisted prefs. */
303
311
  setAliases(aliases) {
304
312
  this.aliases.clear();
@@ -560,6 +568,11 @@ ${BOLD}navigation:${RESET}
560
568
  /duplicate N [t] spawn a new session cloned from session N (same tool/path)
561
569
  /color-all [c] set accent color for all sessions (no color = clear all)
562
570
  /quiet-hours [H-H] suppress watchdog+burn alerts during hours (e.g. 22-06; no arg = clear)
571
+ /quiet-status show whether quiet hours are currently active
572
+ /budget [N] [$] set cost budget: /budget 1 2.50 (session), /budget 2.50 (global), /budget clear
573
+ /pause-all send interrupt to all sessions
574
+ /resume-all send resume to all sessions
575
+ /alert-log [N] show last N auto-generated alerts (burn-rate/watchdog/ceiling; default 20)
563
576
  /history-stats show aggregate statistics from persisted activity history
564
577
  /cost-summary show total estimated spend across all sessions
565
578
  /session-report N generate full markdown report for a session → ~/.aoaoe/report-<name>-<ts>.md
@@ -1115,6 +1128,61 @@ ${BOLD}other:${RESET}
1115
1128
  console.error(`${DIM}history-stats not available (no TUI)${RESET}`);
1116
1129
  }
1117
1130
  break;
1131
+ case "/budget": {
1132
+ const bArgs = line.slice("/budget".length).trim().split(/\s+/).filter(Boolean);
1133
+ if (bArgs.length === 0 || bArgs[0] === "clear") {
1134
+ // clear global budget
1135
+ if (this.budgetHandler)
1136
+ this.budgetHandler(null, null);
1137
+ else
1138
+ console.error(`${DIM}budget not available${RESET}`);
1139
+ break;
1140
+ }
1141
+ if (this.budgetHandler) {
1142
+ // /budget <$N> → global, /budget <N|name> <$N> → per-session
1143
+ const maybeUSD = parseFloat(bArgs[bArgs.length - 1].replace("$", ""));
1144
+ if (!isNaN(maybeUSD) && bArgs.length === 1) {
1145
+ this.budgetHandler(null, maybeUSD); // global
1146
+ }
1147
+ else if (!isNaN(maybeUSD) && bArgs.length >= 2) {
1148
+ this.budgetHandler(bArgs[0], maybeUSD); // per-session
1149
+ }
1150
+ else {
1151
+ console.error(`${DIM}usage: /budget [$N.NN] — global, /budget <N|name> $N.NN — per-session, /budget clear — remove${RESET}`);
1152
+ }
1153
+ }
1154
+ else {
1155
+ console.error(`${DIM}budget not available${RESET}`);
1156
+ }
1157
+ break;
1158
+ }
1159
+ case "/pause-all":
1160
+ if (this.bulkControlHandler)
1161
+ this.bulkControlHandler("pause");
1162
+ else
1163
+ console.error(`${DIM}pause-all not available${RESET}`);
1164
+ break;
1165
+ case "/resume-all":
1166
+ if (this.bulkControlHandler)
1167
+ this.bulkControlHandler("resume");
1168
+ else
1169
+ console.error(`${DIM}resume-all not available${RESET}`);
1170
+ break;
1171
+ case "/quiet-status":
1172
+ if (this.quietStatusHandler)
1173
+ this.quietStatusHandler();
1174
+ else
1175
+ console.error(`${DIM}quiet-status not available (no TUI)${RESET}`);
1176
+ break;
1177
+ case "/alert-log": {
1178
+ const alN = parseInt(line.slice("/alert-log".length).trim() || "20", 10);
1179
+ const alCount = isNaN(alN) || alN < 1 ? 20 : Math.min(alN, 200);
1180
+ if (this.alertLogHandler)
1181
+ this.alertLogHandler(alCount);
1182
+ else
1183
+ console.error(`${DIM}alert-log not available (no TUI)${RESET}`);
1184
+ break;
1185
+ }
1118
1186
  case "/cost-summary":
1119
1187
  if (this.costSummaryHandler)
1120
1188
  this.costSummaryHandler();
package/dist/tui.d.ts CHANGED
@@ -111,6 +111,32 @@ export declare const TIMELINE_DEFAULT_COUNT = 30;
111
111
  * Returns up to `count` entries.
112
112
  */
113
113
  export declare function filterSessionTimeline(buffer: readonly ActivityEntry[], sessionId: string, count?: number): ActivityEntry[];
114
+ /**
115
+ * Given quiet-hours ranges and current hour, return a human-readable status string.
116
+ * Returns { active, message } where message explains current state.
117
+ */
118
+ export declare function formatQuietStatus(ranges: ReadonlyArray<[number, number]>, now?: Date): {
119
+ active: boolean;
120
+ message: string;
121
+ };
122
+ /**
123
+ * Parse a session creation time from an ISO 8601 string.
124
+ * Returns null if unparseable.
125
+ */
126
+ export declare function parseSessionAge(createdAt: string | undefined, now?: number): number | null;
127
+ /** Format session age as compact string: "3d", "2h", "45m", "< 1m". */
128
+ export declare function formatSessionAge(createdAt: string | undefined, now?: number): string;
129
+ /** Max health score snapshots stored per session. */
130
+ export declare const MAX_HEALTH_HISTORY = 20;
131
+ export interface HealthSnapshot {
132
+ score: number;
133
+ ts: number;
134
+ }
135
+ /**
136
+ * Format health history as a compact 5-bucket sparkline.
137
+ * Each bucket covers 1/5 of the history window. Color: LIME/AMBER/ROSE by value.
138
+ */
139
+ export declare function formatHealthSparkline(history: readonly HealthSnapshot[], now?: number): string;
114
140
  /**
115
141
  * Parse a cost string like "$3.42" → 3.42, or null if unparseable.
116
142
  */
@@ -154,6 +180,10 @@ export interface SessionReportData {
154
180
  }
155
181
  /** Format a session report as a Markdown document. */
156
182
  export declare function formatSessionReport(data: SessionReportData): string;
183
+ /** Check if a cost string exceeds a budget value. Returns true when over budget. */
184
+ export declare function isOverBudget(costStr: string | undefined, budgetUSD: number): boolean;
185
+ /** Format a budget-exceeded alert message. */
186
+ export declare function formatBudgetAlert(title: string, costStr: string, budgetUSD: number): string;
157
187
  /**
158
188
  * Build args for duplicating a session: given a source session, return
159
189
  * { path, tool, title } for use in a create_agent action.
@@ -208,13 +238,14 @@ export interface SessionStatEntry {
208
238
  burnRatePerMin: number | null;
209
239
  contextPct: number | null;
210
240
  costStr?: string;
241
+ healthSparkline?: string;
211
242
  uptimeMs: number | null;
212
243
  idleSinceMs: number | null;
213
244
  }
214
245
  /**
215
246
  * Build stats entries for all sessions — pure, testable, no side effects.
216
247
  */
217
- export declare function buildSessionStats(sessions: readonly DaemonSessionState[], errorCounts: ReadonlyMap<string, number>, burnRates: ReadonlyMap<string, number | null>, firstSeen: ReadonlyMap<string, number>, lastChangeAt: ReadonlyMap<string, number>, healthScores: ReadonlyMap<string, number>, sessionAliases: ReadonlyMap<string, string>, now?: number, errorTimestamps?: ReadonlyMap<string, readonly number[]>, sessionCosts?: ReadonlyMap<string, string>): SessionStatEntry[];
248
+ export declare function buildSessionStats(sessions: readonly DaemonSessionState[], errorCounts: ReadonlyMap<string, number>, burnRates: ReadonlyMap<string, number | null>, firstSeen: ReadonlyMap<string, number>, lastChangeAt: ReadonlyMap<string, number>, healthScores: ReadonlyMap<string, number>, sessionAliases: ReadonlyMap<string, string>, now?: number, errorTimestamps?: ReadonlyMap<string, readonly number[]>, sessionCosts?: ReadonlyMap<string, string>, healthHistories?: ReadonlyMap<string, readonly HealthSnapshot[]>): SessionStatEntry[];
218
249
  /**
219
250
  * Format session stats entries as a multi-line activity-log-friendly string.
220
251
  * Each line is one session summary.
@@ -379,6 +410,11 @@ export declare class TUI {
379
410
  private sessionColors;
380
411
  private sessionCosts;
381
412
  private quietHoursRanges;
413
+ private sessionHealthHistory;
414
+ private alertLog;
415
+ private sessionBudgets;
416
+ private globalBudget;
417
+ private budgetAlerted;
382
418
  private viewMode;
383
419
  private drilldownSessionId;
384
420
  private sessionOutputs;
@@ -510,6 +546,20 @@ export declare class TUI {
510
546
  tool: string;
511
547
  title: string;
512
548
  } | null;
549
+ /** Set a per-session budget in USD. Pass null to clear. */
550
+ setSessionBudget(sessionIdOrIndex: string | number, budgetUSD: number | null): boolean;
551
+ /** Set global fallback budget (applies to all sessions without per-session budget). */
552
+ setGlobalBudget(budgetUSD: number | null): void;
553
+ /** Get the per-session budget (or null). */
554
+ getSessionBudget(id: string): number | null;
555
+ /** Get the global budget (or null if not set). */
556
+ getGlobalBudget(): number | null;
557
+ /** Return all per-session budgets. */
558
+ getAllSessionBudgets(): ReadonlyMap<string, number>;
559
+ /** Return health history for a session (for sparkline). */
560
+ getSessionHealthHistory(id: string): readonly HealthSnapshot[];
561
+ /** Return all alert log entries (last 100 "status" tag entries). */
562
+ getAlertLog(): readonly ActivityEntry[];
513
563
  /** Get the latest cost string for a session (or undefined). */
514
564
  getSessionCost(id: string): string | undefined;
515
565
  /** Return all session costs (for /stats). */
@@ -658,7 +708,7 @@ export declare class TUI {
658
708
  private repaintDrilldownContent;
659
709
  private paintInputLine;
660
710
  }
661
- declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string): string;
711
+ declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string, ageStr?: string): string;
662
712
  declare function formatActivity(entry: ActivityEntry, maxCols: number): string;
663
713
  declare function padBoxLine(line: string, totalWidth: number): string;
664
714
  declare function padBoxLineHover(line: string, totalWidth: number, hovered: boolean): string;
package/dist/tui.js CHANGED
@@ -358,6 +358,76 @@ export function filterSessionTimeline(buffer, sessionId, count = TIMELINE_DEFAUL
358
358
  const matching = buffer.filter((e) => e.sessionId === sessionId);
359
359
  return matching.slice(-count);
360
360
  }
361
+ // ── Quiet status (pure, exported for testing) ────────────────────────────────
362
+ /**
363
+ * Given quiet-hours ranges and current hour, return a human-readable status string.
364
+ * Returns { active, message } where message explains current state.
365
+ */
366
+ export function formatQuietStatus(ranges, now) {
367
+ if (ranges.length === 0)
368
+ return { active: false, message: "quiet hours not configured" };
369
+ const d = now ?? new Date();
370
+ const hour = d.getHours();
371
+ const active = isQuietHour(hour, ranges);
372
+ const rangeStrs = ranges.map(([s, e]) => `${String(s).padStart(2, "0")}:00–${String(e).padStart(2, "0")}:00`);
373
+ if (active) {
374
+ return { active: true, message: `quiet hours ACTIVE — alerts suppressed (${rangeStrs.join(", ")})` };
375
+ }
376
+ return { active: false, message: `quiet hours inactive — configured: ${rangeStrs.join(", ")}` };
377
+ }
378
+ // ── Session age (pure, exported for testing) ──────────────────────────────────
379
+ /**
380
+ * Parse a session creation time from an ISO 8601 string.
381
+ * Returns null if unparseable.
382
+ */
383
+ export function parseSessionAge(createdAt, now) {
384
+ if (!createdAt)
385
+ return null;
386
+ const ts = Date.parse(createdAt);
387
+ if (isNaN(ts))
388
+ return null;
389
+ return (now ?? Date.now()) - ts;
390
+ }
391
+ /** Format session age as compact string: "3d", "2h", "45m", "< 1m". */
392
+ export function formatSessionAge(createdAt, now) {
393
+ const ms = parseSessionAge(createdAt, now);
394
+ if (ms === null)
395
+ return "";
396
+ return formatUptime(ms);
397
+ }
398
+ // ── Health history (pure, exported for testing) ───────────────────────────────
399
+ /** Max health score snapshots stored per session. */
400
+ export const MAX_HEALTH_HISTORY = 20;
401
+ /**
402
+ * Format health history as a compact 5-bucket sparkline.
403
+ * Each bucket covers 1/5 of the history window. Color: LIME/AMBER/ROSE by value.
404
+ */
405
+ export function formatHealthSparkline(history, now) {
406
+ if (history.length === 0)
407
+ return "";
408
+ const BUCKETS = 5;
409
+ const WINDOW_MS = 30 * 60_000; // last 30 minutes
410
+ const nowMs = now ?? Date.now();
411
+ const cutoff = nowMs - WINDOW_MS;
412
+ const recent = history.filter((h) => h.ts >= cutoff);
413
+ if (recent.length === 0)
414
+ return "";
415
+ const bucketMs = WINDOW_MS / BUCKETS;
416
+ const buckets = Array(BUCKETS).fill(-1); // -1 = no data
417
+ for (const h of recent) {
418
+ const idx = Math.min(BUCKETS - 1, Math.floor((h.ts - cutoff) / bucketMs));
419
+ // take the most recent reading per bucket
420
+ if (buckets[idx] === -1 || h.ts > (recent.find((r) => r.ts >= cutoff + idx * bucketMs)?.ts ?? 0)) {
421
+ buckets[idx] = h.score;
422
+ }
423
+ }
424
+ return buckets.map((score) => {
425
+ if (score === -1)
426
+ return `${DIM}·${RESET}`;
427
+ const color = score >= HEALTH_GOOD ? LIME : score >= HEALTH_WARN ? AMBER : ROSE;
428
+ return `${color}${SPARK_BLOCKS[Math.min(SPARK_BLOCKS.length - 1, Math.floor(score / 100 * (SPARK_BLOCKS.length - 1)))]}${RESET}`;
429
+ }).join("");
430
+ }
361
431
  // ── Cost summary (pure, exported for testing) ────────────────────────────────
362
432
  /**
363
433
  * Parse a cost string like "$3.42" → 3.42, or null if unparseable.
@@ -448,6 +518,18 @@ export function formatSessionReport(data) {
448
518
  }
449
519
  return lines.join("\n");
450
520
  }
521
+ // ── Session cost budget (pure, exported for testing) ─────────────────────────
522
+ /** Check if a cost string exceeds a budget value. Returns true when over budget. */
523
+ export function isOverBudget(costStr, budgetUSD) {
524
+ if (!costStr)
525
+ return false;
526
+ const val = parseCostValue(costStr);
527
+ return val !== null && val > budgetUSD;
528
+ }
529
+ /** Format a budget-exceeded alert message. */
530
+ export function formatBudgetAlert(title, costStr, budgetUSD) {
531
+ return `${title}: cost ${costStr} exceeded budget $${budgetUSD.toFixed(2)}`;
532
+ }
451
533
  // ── Duplicate session helpers (pure, exported for testing) ───────────────────
452
534
  /**
453
535
  * Build args for duplicating a session: given a source session, return
@@ -574,7 +656,7 @@ export function formatSessionTagsBadge(tags) {
574
656
  /**
575
657
  * Build stats entries for all sessions — pure, testable, no side effects.
576
658
  */
577
- export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now, errorTimestamps, sessionCosts) {
659
+ export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now, errorTimestamps, sessionCosts, healthHistories) {
578
660
  const nowMs = now ?? Date.now();
579
661
  return sessions.map((s) => {
580
662
  const ceiling = parseContextCeiling(s.contextTokens);
@@ -583,6 +665,8 @@ export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, l
583
665
  const lc = lastChangeAt.get(s.id);
584
666
  const errTs = errorTimestamps?.get(s.id);
585
667
  const errTrend = errTs ? computeErrorTrend(errTs, nowMs) : undefined;
668
+ const healthHist = healthHistories?.get(s.id);
669
+ const healthSparkline = healthHist ? formatHealthSparkline(healthHist, nowMs) : undefined;
586
670
  return {
587
671
  title: s.title,
588
672
  displayName: sessionAliases.get(s.id),
@@ -593,6 +677,7 @@ export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, l
593
677
  burnRatePerMin: burnRates.get(s.id) ?? null,
594
678
  contextPct: ctxPct,
595
679
  costStr: sessionCosts?.get(s.id),
680
+ healthSparkline: healthSparkline || undefined,
596
681
  uptimeMs: fs !== undefined ? nowMs - fs : null,
597
682
  idleSinceMs: lc !== undefined ? nowMs - lc : null,
598
683
  };
@@ -614,9 +699,10 @@ export function formatSessionStatsLines(entries) {
614
699
  ? ` ${Math.round(e.burnRatePerMin / 100) * 100}tok/min` : "";
615
700
  const ctxStr = e.contextPct !== null ? ` ctx:${e.contextPct}%` : "";
616
701
  const costStr = e.costStr ? ` ${e.costStr}` : "";
702
+ const sparkStr = e.healthSparkline ? ` ${e.healthSparkline}` : "";
617
703
  const upStr = e.uptimeMs !== null ? ` up:${formatUptime(e.uptimeMs)}` : "";
618
704
  const idleStr = e.idleSinceMs !== null ? ` ${formatIdleSince(e.idleSinceMs)}` : "";
619
- return ` ${label} [${e.status}] ${healthStr}${errStr}${burnStr}${ctxStr}${costStr}${upStr}${idleStr}`;
705
+ return ` ${label} [${e.status}] ${healthStr}${sparkStr}${errStr}${burnStr}${ctxStr}${costStr}${upStr}${idleStr}`;
620
706
  });
621
707
  }
622
708
  /** Format session stats entries as a JSON object for export. */
@@ -651,7 +737,8 @@ export const BUILTIN_COMMANDS = new Set([
651
737
  "/jump", "/marks", "/search", "/alias", "/insist", "/task", "/tasks",
652
738
  "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
653
739
  "/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
654
- "/duplicate", "/color-all", "/quiet-hours", "/history-stats", "/cost-summary", "/session-report",
740
+ "/duplicate", "/color-all", "/quiet-hours", "/quiet-status", "/history-stats", "/cost-summary", "/session-report", "/alert-log",
741
+ "/budget", "/pause-all", "/resume-all",
655
742
  ]);
656
743
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
657
744
  export function resolveAlias(line, aliases) {
@@ -923,6 +1010,11 @@ export class TUI {
923
1010
  sessionColors = new Map(); // session ID → accent color name
924
1011
  sessionCosts = new Map(); // session ID → latest cost string ("$N.NN")
925
1012
  quietHoursRanges = []; // quiet-hour start/end pairs
1013
+ sessionHealthHistory = new Map(); // session ID → health snapshots
1014
+ alertLog = []; // recent auto-generated status alerts (ring buffer, max 100)
1015
+ sessionBudgets = new Map(); // session ID → USD budget
1016
+ globalBudget = null; // global fallback budget in USD
1017
+ budgetAlerted = new Map(); // session ID → epoch ms of last budget alert
926
1018
  // drill-down mode: show a single session's full output
927
1019
  viewMode = "overview";
928
1020
  drilldownSessionId = null;
@@ -1395,6 +1487,50 @@ export class TUI {
1395
1487
  return buildDuplicateArgs(this.sessions, sessionIdOrIndex, newTitle);
1396
1488
  }
1397
1489
  // ── Session timeline ─────────────────────────────────────────────────────
1490
+ // ── Session cost budget ──────────────────────────────────────────────────
1491
+ /** Set a per-session budget in USD. Pass null to clear. */
1492
+ setSessionBudget(sessionIdOrIndex, budgetUSD) {
1493
+ let sessionId;
1494
+ if (typeof sessionIdOrIndex === "number") {
1495
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1496
+ }
1497
+ else {
1498
+ const needle = sessionIdOrIndex.toLowerCase();
1499
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1500
+ sessionId = match?.id;
1501
+ }
1502
+ if (!sessionId)
1503
+ return false;
1504
+ if (budgetUSD === null)
1505
+ this.sessionBudgets.delete(sessionId);
1506
+ else
1507
+ this.sessionBudgets.set(sessionId, budgetUSD);
1508
+ return true;
1509
+ }
1510
+ /** Set global fallback budget (applies to all sessions without per-session budget). */
1511
+ setGlobalBudget(budgetUSD) {
1512
+ this.globalBudget = budgetUSD;
1513
+ }
1514
+ /** Get the per-session budget (or null). */
1515
+ getSessionBudget(id) {
1516
+ return this.sessionBudgets.get(id) ?? null;
1517
+ }
1518
+ /** Get the global budget (or null if not set). */
1519
+ getGlobalBudget() {
1520
+ return this.globalBudget;
1521
+ }
1522
+ /** Return all per-session budgets. */
1523
+ getAllSessionBudgets() {
1524
+ return this.sessionBudgets;
1525
+ }
1526
+ /** Return health history for a session (for sparkline). */
1527
+ getSessionHealthHistory(id) {
1528
+ return this.sessionHealthHistory.get(id) ?? [];
1529
+ }
1530
+ /** Return all alert log entries (last 100 "status" tag entries). */
1531
+ getAlertLog() {
1532
+ return this.alertLog;
1533
+ }
1398
1534
  /** Get the latest cost string for a session (or undefined). */
1399
1535
  getSessionCost(id) {
1400
1536
  return this.sessionCosts.get(id);
@@ -1719,9 +1855,18 @@ export class TUI {
1719
1855
  }
1720
1856
  if (s.lastActivity !== undefined)
1721
1857
  this.prevLastActivity.set(s.id, s.lastActivity);
1722
- // track cost string
1723
- if (s.costStr)
1858
+ // track cost string + check budget
1859
+ if (s.costStr) {
1724
1860
  this.sessionCosts.set(s.id, s.costStr);
1861
+ const budget = this.sessionBudgets.get(s.id) ?? this.globalBudget;
1862
+ if (budget !== null && isOverBudget(s.costStr, budget) && !quietNow) {
1863
+ const lastAlert = this.budgetAlerted.get(s.id) ?? 0;
1864
+ if (now - lastAlert >= 5 * 60_000) {
1865
+ this.budgetAlerted.set(s.id, now);
1866
+ this.log("status", formatBudgetAlert(s.title, s.costStr, budget), s.id);
1867
+ }
1868
+ }
1869
+ }
1725
1870
  // track context token history for burn-rate alerts
1726
1871
  const tokens = parseContextTokenNumber(s.contextTokens);
1727
1872
  if (tokens !== null) {
@@ -1773,6 +1918,27 @@ export class TUI {
1773
1918
  if (!currentIds.has(id))
1774
1919
  this.sessionContextHistory.delete(id);
1775
1920
  }
1921
+ // record health snapshots
1922
+ for (const s of opts.sessions) {
1923
+ const ceiling2 = parseContextCeiling(s.contextTokens);
1924
+ const cf2 = ceiling2 ? ceiling2.current / ceiling2.max : null;
1925
+ const bh2 = this.sessionContextHistory.get(s.id);
1926
+ const br2 = bh2 ? computeContextBurnRate(bh2, now) : null;
1927
+ const lc2 = this.lastChangeAt.get(s.id);
1928
+ const idle2 = lc2 !== undefined ? now - lc2 : null;
1929
+ const hs = computeHealthScore({
1930
+ errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
1931
+ burnRatePerMin: br2,
1932
+ contextFraction: cf2,
1933
+ idleMs: idle2,
1934
+ watchdogThresholdMs: this.watchdogThresholdMs,
1935
+ });
1936
+ const hist = this.sessionHealthHistory.get(s.id) ?? [];
1937
+ hist.push({ score: hs, ts: now });
1938
+ if (hist.length > MAX_HEALTH_HISTORY)
1939
+ hist.shift();
1940
+ this.sessionHealthHistory.set(s.id, hist);
1941
+ }
1776
1942
  const sorted = sortSessions(opts.sessions, this.sortMode, this.lastChangeAt, this.pinnedIds);
1777
1943
  const prevVisibleCount = this.getVisibleCount();
1778
1944
  this.sessions = sorted;
@@ -1811,6 +1977,12 @@ export class TUI {
1811
1977
  process.stderr.write("\x07");
1812
1978
  }
1813
1979
  }
1980
+ // collect "status" alerts into alert log (for /alert-log)
1981
+ if (tag === "status") {
1982
+ this.alertLog.push(entry);
1983
+ if (this.alertLog.length > 100)
1984
+ this.alertLog.shift();
1985
+ }
1814
1986
  // track per-session error counts + timestamps (for sparklines)
1815
1987
  if (sessionId && shouldAutoPin(tag)) {
1816
1988
  this.sessionErrorCounts.set(sessionId, (this.sessionErrorCounts.get(sessionId) ?? 0) + 1);
@@ -2283,7 +2455,8 @@ export class TUI {
2283
2455
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2284
2456
  const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth;
2285
2457
  const cardWidth = innerWidth - 1 - iconsWidth;
2286
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
2458
+ const cardAge = s.createdAt ? formatSessionAge(s.createdAt, nowMs) : undefined;
2459
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName, cardAge || undefined)}`;
2287
2460
  const padded = padBoxLineHover(line, this.cols, isHovered);
2288
2461
  process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
2289
2462
  }
@@ -2353,7 +2526,8 @@ export class TUI {
2353
2526
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2354
2527
  const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2;
2355
2528
  const cardWidth = innerWidth - 1 - iconsWidth;
2356
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2)}`;
2529
+ const cardAge2 = s.createdAt ? formatSessionAge(s.createdAt, nowMs2) : undefined;
2530
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2, cardAge2 || undefined)}`;
2357
2531
  const padded = padBoxLineHover(line, this.cols, isHovered);
2358
2532
  process.stderr.write(SAVE_CURSOR + moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded + RESTORE_CURSOR);
2359
2533
  }
@@ -2471,7 +2645,8 @@ export class TUI {
2471
2645
  // idleSinceMs: optional ms since last activity change (shown when idle/stopped)
2472
2646
  // healthBadge: optional pre-formatted health score badge ("⬡83" colored)
2473
2647
  // displayName: optional custom name override (from /rename)
2474
- function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName) {
2648
+ // ageStr: optional session age string (from createdAt)
2649
+ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName, ageStr) {
2475
2650
  const dot = STATUS_DOT[s.status] ?? `${AMBER}${DOT.filled}${RESET}`;
2476
2651
  const title = displayName ?? s.title;
2477
2652
  const name = displayName ? `${BOLD}${displayName}${DIM} (${s.title})${RESET}` : `${BOLD}${s.title}${RESET}`;
@@ -2511,7 +2686,8 @@ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge
2511
2686
  else {
2512
2687
  desc = `${SLATE}${s.status}${RESET}`;
2513
2688
  }
2514
- return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${sparkSuffix}`, maxWidth);
2689
+ const ageSuffix = ageStr ? ` ${DIM}age:${ageStr}${RESET}` : "";
2690
+ return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${ageSuffix}${sparkSuffix}`, maxWidth);
2515
2691
  }
2516
2692
  // colorize an activity entry based on its tag
2517
2693
  function formatActivity(entry, maxCols) {
package/dist/types.d.ts CHANGED
@@ -138,6 +138,7 @@ export interface DaemonSessionState {
138
138
  userActive?: boolean;
139
139
  costStr?: string;
140
140
  path?: string;
141
+ createdAt?: string;
141
142
  }
142
143
  export interface DaemonState {
143
144
  tickStartedAt: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.142.0",
3
+ "version": "0.149.0",
4
4
  "description": "Autonomous supervisor for agent-of-empires sessions using OpenCode or Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",