aoaoe 0.146.0 → 0.153.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.
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, formatQuietStatus, formatSessionAge } 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, formatHealthTrendChart, isOverBudget } 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";
@@ -1023,6 +1023,123 @@ async function main() {
1023
1023
  tui.log("system", `quiet hours: ${specs.join(", ")} — watchdog+burn alerts suppressed`);
1024
1024
  persistPrefs();
1025
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 /health-trend
1066
+ input.onHealthTrend((target, height) => {
1067
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1068
+ const sessions = tui.getSessions();
1069
+ const session = num !== undefined
1070
+ ? sessions[num - 1]
1071
+ : sessions.find((s) => s.title.toLowerCase() === target.toLowerCase() || s.id.startsWith(target));
1072
+ if (!session) {
1073
+ tui.log("system", `session not found: ${target}`);
1074
+ return;
1075
+ }
1076
+ const hist = tui.getSessionHealthHistory(session.id);
1077
+ const lines = formatHealthTrendChart(hist, session.title, height);
1078
+ for (const l of lines)
1079
+ tui.log("system", l);
1080
+ });
1081
+ // wire /alert-mute
1082
+ input.onAlertMute((pattern) => {
1083
+ if (pattern === null) {
1084
+ // null = "clear" keyword
1085
+ tui.clearAlertMutePatterns();
1086
+ tui.log("system", "alert-mute: all patterns cleared");
1087
+ return;
1088
+ }
1089
+ if (!pattern) {
1090
+ // empty = list patterns
1091
+ const pats = tui.getAlertMutePatterns();
1092
+ if (pats.size === 0) {
1093
+ tui.log("system", "alert-mute: no patterns set — use /alert-mute <text> to suppress");
1094
+ }
1095
+ else {
1096
+ tui.log("system", `alert-mute: ${pats.size} pattern${pats.size !== 1 ? "s" : ""}:`);
1097
+ for (const p of pats)
1098
+ tui.log("system", ` "${p}"`);
1099
+ }
1100
+ return;
1101
+ }
1102
+ tui.addAlertMutePattern(pattern);
1103
+ tui.log("system", `alert-mute: added "${pattern}" — matching alerts hidden from /alert-log`);
1104
+ });
1105
+ // wire /budgets list
1106
+ input.onBudgetsList(() => {
1107
+ const global = tui.getGlobalBudget();
1108
+ const perSession = tui.getAllSessionBudgets();
1109
+ if (global === null && perSession.size === 0) {
1110
+ tui.log("system", "budgets: none set — use /budget $N (global) or /budget <N> $N (per-session)");
1111
+ return;
1112
+ }
1113
+ if (global !== null)
1114
+ tui.log("system", ` global: $${global.toFixed(2)}`);
1115
+ const sessions = tui.getSessions();
1116
+ for (const [id, budget] of perSession) {
1117
+ const s = sessions.find((s) => s.id === id);
1118
+ const label = s?.title ?? id.slice(0, 8);
1119
+ tui.log("system", ` ${label}: $${budget.toFixed(2)}`);
1120
+ }
1121
+ });
1122
+ // wire /budget-status
1123
+ input.onBudgetStatus(() => {
1124
+ const sessions = tui.getSessions();
1125
+ const costs = tui.getAllSessionCosts();
1126
+ const global = tui.getGlobalBudget();
1127
+ const perSession = tui.getAllSessionBudgets();
1128
+ let shown = 0;
1129
+ for (const s of sessions) {
1130
+ const budget = perSession.get(s.id) ?? global;
1131
+ const costStr = costs.get(s.id);
1132
+ if (budget === null)
1133
+ continue;
1134
+ const over = isOverBudget(costStr, budget);
1135
+ const costLabel = costStr ?? "(no data)";
1136
+ const status = over ? `OVER ($${budget.toFixed(2)} budget)` : `ok ($${budget.toFixed(2)} budget)`;
1137
+ tui.log("system", ` ${s.title}: ${costLabel} — ${status}`);
1138
+ shown++;
1139
+ }
1140
+ if (shown === 0)
1141
+ tui.log("system", "budget-status: no sessions with budgets configured");
1142
+ });
1026
1143
  // wire /quiet-status
1027
1144
  input.onQuietStatus(() => {
1028
1145
  const { active, message } = formatQuietStatus(tui.getQuietHours());
package/dist/input.d.ts CHANGED
@@ -56,6 +56,12 @@ export type CostSummaryHandler = () => void;
56
56
  export type SessionReportHandler = (target: string) => void;
57
57
  export type QuietStatusHandler = () => void;
58
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;
61
+ export type HealthTrendHandler = (target: string, height: number) => void;
62
+ export type AlertMuteHandler = (pattern: string | null) => void;
63
+ export type BudgetsListHandler = () => void;
64
+ export type BudgetStatusHandler = () => void;
59
65
  export interface MouseEvent {
60
66
  button: number;
61
67
  col: number;
@@ -134,6 +140,12 @@ export declare class InputReader {
134
140
  private sessionReportHandler;
135
141
  private quietStatusHandler;
136
142
  private alertLogHandler;
143
+ private budgetHandler;
144
+ private bulkControlHandler;
145
+ private healthTrendHandler;
146
+ private alertMuteHandler;
147
+ private budgetsListHandler;
148
+ private budgetStatusHandler;
137
149
  private aliases;
138
150
  private mouseDataListener;
139
151
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -195,6 +207,12 @@ export declare class InputReader {
195
207
  onSessionReport(handler: SessionReportHandler): void;
196
208
  onQuietStatus(handler: QuietStatusHandler): void;
197
209
  onAlertLog(handler: AlertLogHandler): void;
210
+ onBudget(handler: BudgetHandler): void;
211
+ onBulkControl(handler: BulkControlHandler): void;
212
+ onHealthTrend(handler: HealthTrendHandler): void;
213
+ onAlertMute(handler: AlertMuteHandler): void;
214
+ onBudgetsList(handler: BudgetsListHandler): void;
215
+ onBudgetStatus(handler: BudgetStatusHandler): void;
198
216
  /** Set aliases from persisted prefs. */
199
217
  setAliases(aliases: Record<string, string>): void;
200
218
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -89,6 +89,12 @@ export class InputReader {
89
89
  sessionReportHandler = null;
90
90
  quietStatusHandler = null;
91
91
  alertLogHandler = null;
92
+ budgetHandler = null;
93
+ bulkControlHandler = null;
94
+ healthTrendHandler = null;
95
+ alertMuteHandler = null;
96
+ budgetsListHandler = null;
97
+ budgetStatusHandler = null;
92
98
  aliases = new Map(); // /shortcut → /full command
93
99
  mouseDataListener = null;
94
100
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -303,6 +309,12 @@ export class InputReader {
303
309
  onSessionReport(handler) { this.sessionReportHandler = handler; }
304
310
  onQuietStatus(handler) { this.quietStatusHandler = handler; }
305
311
  onAlertLog(handler) { this.alertLogHandler = handler; }
312
+ onBudget(handler) { this.budgetHandler = handler; }
313
+ onBulkControl(handler) { this.bulkControlHandler = handler; }
314
+ onHealthTrend(handler) { this.healthTrendHandler = handler; }
315
+ onAlertMute(handler) { this.alertMuteHandler = handler; }
316
+ onBudgetsList(handler) { this.budgetsListHandler = handler; }
317
+ onBudgetStatus(handler) { this.budgetStatusHandler = handler; }
306
318
  /** Set aliases from persisted prefs. */
307
319
  setAliases(aliases) {
308
320
  this.aliases.clear();
@@ -565,8 +577,15 @@ ${BOLD}navigation:${RESET}
565
577
  /color-all [c] set accent color for all sessions (no color = clear all)
566
578
  /quiet-hours [H-H] suppress watchdog+burn alerts during hours (e.g. 22-06; no arg = clear)
567
579
  /quiet-status show whether quiet hours are currently active
568
- /alert-log [N] show last N auto-generated alerts (burn-rate/watchdog/ceiling; default 20)
569
- /history-stats show aggregate statistics from persisted activity history
580
+ /budget [N] [$] set cost budget: /budget 1 2.50 (session), /budget 2.50 (global), /budget clear
581
+ /pause-all send interrupt to all sessions
582
+ /resume-all send resume to all sessions
583
+ /alert-log [N] show last N auto-generated alerts (burn-rate/watchdog/ceiling; default 20)
584
+ /alert-mute [pat] suppress alerts containing pattern; no arg = list; clear = remove all
585
+ /health-trend N show ASCII health score chart for session N [height]
586
+ /budgets list all active cost budgets
587
+ /budget-status show which sessions are over or under budget
588
+ /history-stats show aggregate statistics from persisted activity history
570
589
  /cost-summary show total estimated spend across all sessions
571
590
  /session-report N generate full markdown report for a session → ~/.aoaoe/report-<name>-<ts>.md
572
591
  /clip [N] copy last N activity entries to clipboard (default 20)
@@ -1121,6 +1140,84 @@ ${BOLD}other:${RESET}
1121
1140
  console.error(`${DIM}history-stats not available (no TUI)${RESET}`);
1122
1141
  }
1123
1142
  break;
1143
+ case "/budget": {
1144
+ const bArgs = line.slice("/budget".length).trim().split(/\s+/).filter(Boolean);
1145
+ if (bArgs.length === 0 || bArgs[0] === "clear") {
1146
+ // clear global budget
1147
+ if (this.budgetHandler)
1148
+ this.budgetHandler(null, null);
1149
+ else
1150
+ console.error(`${DIM}budget not available${RESET}`);
1151
+ break;
1152
+ }
1153
+ if (this.budgetHandler) {
1154
+ // /budget <$N> → global, /budget <N|name> <$N> → per-session
1155
+ const maybeUSD = parseFloat(bArgs[bArgs.length - 1].replace("$", ""));
1156
+ if (!isNaN(maybeUSD) && bArgs.length === 1) {
1157
+ this.budgetHandler(null, maybeUSD); // global
1158
+ }
1159
+ else if (!isNaN(maybeUSD) && bArgs.length >= 2) {
1160
+ this.budgetHandler(bArgs[0], maybeUSD); // per-session
1161
+ }
1162
+ else {
1163
+ console.error(`${DIM}usage: /budget [$N.NN] — global, /budget <N|name> $N.NN — per-session, /budget clear — remove${RESET}`);
1164
+ }
1165
+ }
1166
+ else {
1167
+ console.error(`${DIM}budget not available${RESET}`);
1168
+ }
1169
+ break;
1170
+ }
1171
+ case "/pause-all":
1172
+ if (this.bulkControlHandler)
1173
+ this.bulkControlHandler("pause");
1174
+ else
1175
+ console.error(`${DIM}pause-all not available${RESET}`);
1176
+ break;
1177
+ case "/resume-all":
1178
+ if (this.bulkControlHandler)
1179
+ this.bulkControlHandler("resume");
1180
+ else
1181
+ console.error(`${DIM}resume-all not available${RESET}`);
1182
+ break;
1183
+ case "/health-trend": {
1184
+ const htArgs = line.slice("/health-trend".length).trim().split(/\s+/).filter(Boolean);
1185
+ const htTarget = htArgs[0] ?? "";
1186
+ const htHeight = htArgs[1] ? parseInt(htArgs[1], 10) : 6;
1187
+ if (!htTarget) {
1188
+ console.error(`${DIM}usage: /health-trend <N|name> [height]${RESET}`);
1189
+ break;
1190
+ }
1191
+ if (this.healthTrendHandler)
1192
+ this.healthTrendHandler(htTarget, isNaN(htHeight) || htHeight < 2 ? 6 : Math.min(htHeight, 20));
1193
+ else
1194
+ console.error(`${DIM}health-trend not available${RESET}`);
1195
+ break;
1196
+ }
1197
+ case "/alert-mute": {
1198
+ const amArg = line.slice("/alert-mute".length).trim();
1199
+ if (this.alertMuteHandler) {
1200
+ if (amArg.toLowerCase() === "clear")
1201
+ this.alertMuteHandler(null);
1202
+ else
1203
+ this.alertMuteHandler(amArg || null);
1204
+ }
1205
+ else
1206
+ console.error(`${DIM}alert-mute not available${RESET}`);
1207
+ break;
1208
+ }
1209
+ case "/budgets":
1210
+ if (this.budgetsListHandler)
1211
+ this.budgetsListHandler();
1212
+ else
1213
+ console.error(`${DIM}budgets not available${RESET}`);
1214
+ break;
1215
+ case "/budget-status":
1216
+ if (this.budgetStatusHandler)
1217
+ this.budgetStatusHandler();
1218
+ else
1219
+ console.error(`${DIM}budget-status not available${RESET}`);
1220
+ break;
1124
1221
  case "/quiet-status":
1125
1222
  if (this.quietStatusHandler)
1126
1223
  this.quietStatusHandler();
package/dist/tui.d.ts CHANGED
@@ -137,6 +137,34 @@ export interface HealthSnapshot {
137
137
  * Each bucket covers 1/5 of the history window. Color: LIME/AMBER/ROSE by value.
138
138
  */
139
139
  export declare function formatHealthSparkline(history: readonly HealthSnapshot[], now?: number): string;
140
+ /**
141
+ * Format health score history as a multi-line ASCII bar chart.
142
+ * Returns an array of lines suitable for logging to the activity area.
143
+ * Each column is one snapshot, ordered oldest→newest.
144
+ * Bar height 0–8 rows. Color: LIME/AMBER/ROSE.
145
+ */
146
+ export declare function formatHealthTrendChart(history: readonly HealthSnapshot[], title: string, height?: number): string[];
147
+ /** Status change entry for flap detection. */
148
+ export interface StatusChange {
149
+ status: string;
150
+ ts: number;
151
+ }
152
+ /** Max status change entries stored per session. */
153
+ export declare const MAX_STATUS_HISTORY = 30;
154
+ /** Flap detection window: check for oscillation in the last N minutes. */
155
+ export declare const FLAP_WINDOW_MS: number;
156
+ /** Min status changes in window to be considered flapping. */
157
+ export declare const FLAP_THRESHOLD = 5;
158
+ /**
159
+ * Detect if a session is "flapping" — rapidly oscillating between statuses.
160
+ * Returns true when there are >= FLAP_THRESHOLD status changes in FLAP_WINDOW_MS.
161
+ */
162
+ export declare function isFlapping(changes: readonly StatusChange[], now?: number, windowMs?: number, threshold?: number): boolean;
163
+ /**
164
+ * Check if an alert text matches any suppressed pattern (case-insensitive substring).
165
+ * Returns true when the alert should be hidden.
166
+ */
167
+ export declare function isAlertMuted(text: string, patterns: ReadonlySet<string>): boolean;
140
168
  /**
141
169
  * Parse a cost string like "$3.42" → 3.42, or null if unparseable.
142
170
  */
@@ -180,6 +208,10 @@ export interface SessionReportData {
180
208
  }
181
209
  /** Format a session report as a Markdown document. */
182
210
  export declare function formatSessionReport(data: SessionReportData): string;
211
+ /** Check if a cost string exceeds a budget value. Returns true when over budget. */
212
+ export declare function isOverBudget(costStr: string | undefined, budgetUSD: number): boolean;
213
+ /** Format a budget-exceeded alert message. */
214
+ export declare function formatBudgetAlert(title: string, costStr: string, budgetUSD: number): string;
183
215
  /**
184
216
  * Build args for duplicating a session: given a source session, return
185
217
  * { path, tool, title } for use in a create_agent action.
@@ -408,6 +440,13 @@ export declare class TUI {
408
440
  private quietHoursRanges;
409
441
  private sessionHealthHistory;
410
442
  private alertLog;
443
+ private sessionBudgets;
444
+ private globalBudget;
445
+ private budgetAlerted;
446
+ private sessionStatusHistory;
447
+ private prevSessionStatus;
448
+ private flapAlerted;
449
+ private alertMutePatterns;
411
450
  private viewMode;
412
451
  private drilldownSessionId;
413
452
  private sessionOutputs;
@@ -539,10 +578,32 @@ export declare class TUI {
539
578
  tool: string;
540
579
  title: string;
541
580
  } | null;
581
+ /** Set a per-session budget in USD. Pass null to clear. */
582
+ setSessionBudget(sessionIdOrIndex: string | number, budgetUSD: number | null): boolean;
583
+ /** Set global fallback budget (applies to all sessions without per-session budget). */
584
+ setGlobalBudget(budgetUSD: number | null): void;
585
+ /** Get the per-session budget (or null). */
586
+ getSessionBudget(id: string): number | null;
587
+ /** Get the global budget (or null if not set). */
588
+ getGlobalBudget(): number | null;
589
+ /** Return all per-session budgets. */
590
+ getAllSessionBudgets(): ReadonlyMap<string, number>;
542
591
  /** Return health history for a session (for sparkline). */
543
592
  getSessionHealthHistory(id: string): readonly HealthSnapshot[];
544
- /** Return all alert log entries (last 100 "status" tag entries). */
545
- getAlertLog(): readonly ActivityEntry[];
593
+ /** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
594
+ getAlertLog(includeAll?: boolean): readonly ActivityEntry[];
595
+ /** Return status change history for a session. */
596
+ getSessionStatusHistory(id: string): readonly StatusChange[];
597
+ /** Check if a session is currently flapping. */
598
+ isSessionFlapping(id: string, now?: number): boolean;
599
+ /** Add a pattern to suppress from alert log display. */
600
+ addAlertMutePattern(pattern: string): void;
601
+ /** Remove a pattern from alert mute list. Returns true if it was present. */
602
+ removeAlertMutePattern(pattern: string): boolean;
603
+ /** Clear all alert mute patterns. */
604
+ clearAlertMutePatterns(): void;
605
+ /** Return all alert mute patterns (for display/persistence). */
606
+ getAlertMutePatterns(): ReadonlySet<string>;
546
607
  /** Get the latest cost string for a session (or undefined). */
547
608
  getSessionCost(id: string): string | undefined;
548
609
  /** Return all session costs (for /stats). */
@@ -691,7 +752,7 @@ export declare class TUI {
691
752
  private repaintDrilldownContent;
692
753
  private paintInputLine;
693
754
  }
694
- declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string): string;
755
+ declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string, ageStr?: string): string;
695
756
  declare function formatActivity(entry: ActivityEntry, maxCols: number): string;
696
757
  declare function padBoxLine(line: string, totalWidth: number): string;
697
758
  declare function padBoxLineHover(line: string, totalWidth: number, hovered: boolean): string;
package/dist/tui.js CHANGED
@@ -428,6 +428,74 @@ export function formatHealthSparkline(history, now) {
428
428
  return `${color}${SPARK_BLOCKS[Math.min(SPARK_BLOCKS.length - 1, Math.floor(score / 100 * (SPARK_BLOCKS.length - 1)))]}${RESET}`;
429
429
  }).join("");
430
430
  }
431
+ // ── Health trend chart (pure, exported for testing) ───────────────────────────
432
+ /**
433
+ * Format health score history as a multi-line ASCII bar chart.
434
+ * Returns an array of lines suitable for logging to the activity area.
435
+ * Each column is one snapshot, ordered oldest→newest.
436
+ * Bar height 0–8 rows. Color: LIME/AMBER/ROSE.
437
+ */
438
+ export function formatHealthTrendChart(history, title, height = 6) {
439
+ if (history.length === 0)
440
+ return [` ${title}: no health history`];
441
+ const MAX_COLS = 40;
442
+ const samples = history.slice(-MAX_COLS);
443
+ const lines = [];
444
+ // header
445
+ const minScore = Math.min(...samples.map((h) => h.score));
446
+ const maxScore = Math.max(...samples.map((h) => h.score));
447
+ lines.push(` ${DIM}${title}${RESET} health trend (${samples.length} samples, ${minScore}–${maxScore})`);
448
+ // chart rows (top = high score, bottom = low score)
449
+ for (let row = height - 1; row >= 0; row--) {
450
+ const threshold = Math.round(((row + 1) / height) * 100);
451
+ const prevThreshold = Math.round((row / height) * 100);
452
+ const yLabel = row === height - 1 ? "100" : row === 0 ? " 0" : ` `;
453
+ const bar = samples.map((h) => {
454
+ if (h.score >= threshold) {
455
+ const color = h.score >= HEALTH_GOOD ? LIME : h.score >= HEALTH_WARN ? AMBER : ROSE;
456
+ return `${color}█${RESET}`;
457
+ }
458
+ if (h.score >= prevThreshold) {
459
+ const color = h.score >= HEALTH_GOOD ? LIME : h.score >= HEALTH_WARN ? AMBER : ROSE;
460
+ return `${color}▄${RESET}`;
461
+ }
462
+ return `${DIM}·${RESET}`;
463
+ }).join("");
464
+ lines.push(` ${DIM}${yLabel}${RESET}│${bar}`);
465
+ }
466
+ lines.push(` └${"─".repeat(samples.length)}`);
467
+ return lines;
468
+ }
469
+ /** Max status change entries stored per session. */
470
+ export const MAX_STATUS_HISTORY = 30;
471
+ /** Flap detection window: check for oscillation in the last N minutes. */
472
+ export const FLAP_WINDOW_MS = 10 * 60_000;
473
+ /** Min status changes in window to be considered flapping. */
474
+ export const FLAP_THRESHOLD = 5;
475
+ /**
476
+ * Detect if a session is "flapping" — rapidly oscillating between statuses.
477
+ * Returns true when there are >= FLAP_THRESHOLD status changes in FLAP_WINDOW_MS.
478
+ */
479
+ export function isFlapping(changes, now, windowMs = FLAP_WINDOW_MS, threshold = FLAP_THRESHOLD) {
480
+ const cutoff = (now ?? Date.now()) - windowMs;
481
+ const recent = changes.filter((c) => c.ts >= cutoff);
482
+ return recent.length >= threshold;
483
+ }
484
+ // ── Alert mute patterns (pure, exported for testing) ──────────────────────────
485
+ /**
486
+ * Check if an alert text matches any suppressed pattern (case-insensitive substring).
487
+ * Returns true when the alert should be hidden.
488
+ */
489
+ export function isAlertMuted(text, patterns) {
490
+ if (patterns.size === 0)
491
+ return false;
492
+ const lower = text.toLowerCase();
493
+ for (const p of patterns) {
494
+ if (lower.includes(p.toLowerCase()))
495
+ return true;
496
+ }
497
+ return false;
498
+ }
431
499
  // ── Cost summary (pure, exported for testing) ────────────────────────────────
432
500
  /**
433
501
  * Parse a cost string like "$3.42" → 3.42, or null if unparseable.
@@ -518,6 +586,18 @@ export function formatSessionReport(data) {
518
586
  }
519
587
  return lines.join("\n");
520
588
  }
589
+ // ── Session cost budget (pure, exported for testing) ─────────────────────────
590
+ /** Check if a cost string exceeds a budget value. Returns true when over budget. */
591
+ export function isOverBudget(costStr, budgetUSD) {
592
+ if (!costStr)
593
+ return false;
594
+ const val = parseCostValue(costStr);
595
+ return val !== null && val > budgetUSD;
596
+ }
597
+ /** Format a budget-exceeded alert message. */
598
+ export function formatBudgetAlert(title, costStr, budgetUSD) {
599
+ return `${title}: cost ${costStr} exceeded budget $${budgetUSD.toFixed(2)}`;
600
+ }
521
601
  // ── Duplicate session helpers (pure, exported for testing) ───────────────────
522
602
  /**
523
603
  * Build args for duplicating a session: given a source session, return
@@ -726,6 +806,8 @@ export const BUILTIN_COMMANDS = new Set([
726
806
  "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
727
807
  "/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
728
808
  "/duplicate", "/color-all", "/quiet-hours", "/quiet-status", "/history-stats", "/cost-summary", "/session-report", "/alert-log",
809
+ "/budget", "/budgets", "/budget-status", "/pause-all", "/resume-all",
810
+ "/health-trend", "/alert-mute",
729
811
  ]);
730
812
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
731
813
  export function resolveAlias(line, aliases) {
@@ -999,6 +1081,13 @@ export class TUI {
999
1081
  quietHoursRanges = []; // quiet-hour start/end pairs
1000
1082
  sessionHealthHistory = new Map(); // session ID → health snapshots
1001
1083
  alertLog = []; // recent auto-generated status alerts (ring buffer, max 100)
1084
+ sessionBudgets = new Map(); // session ID → USD budget
1085
+ globalBudget = null; // global fallback budget in USD
1086
+ budgetAlerted = new Map(); // session ID → epoch ms of last budget alert
1087
+ sessionStatusHistory = new Map(); // session ID → status change log
1088
+ prevSessionStatus = new Map(); // session ID → last known status (for change detection)
1089
+ flapAlerted = new Map(); // session ID → epoch ms of last flap alert
1090
+ alertMutePatterns = new Set(); // substrings to hide from /alert-log display
1002
1091
  // drill-down mode: show a single session's full output
1003
1092
  viewMode = "overview";
1004
1093
  drilldownSessionId = null;
@@ -1471,13 +1560,77 @@ export class TUI {
1471
1560
  return buildDuplicateArgs(this.sessions, sessionIdOrIndex, newTitle);
1472
1561
  }
1473
1562
  // ── Session timeline ─────────────────────────────────────────────────────
1563
+ // ── Session cost budget ──────────────────────────────────────────────────
1564
+ /** Set a per-session budget in USD. Pass null to clear. */
1565
+ setSessionBudget(sessionIdOrIndex, budgetUSD) {
1566
+ let sessionId;
1567
+ if (typeof sessionIdOrIndex === "number") {
1568
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1569
+ }
1570
+ else {
1571
+ const needle = sessionIdOrIndex.toLowerCase();
1572
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1573
+ sessionId = match?.id;
1574
+ }
1575
+ if (!sessionId)
1576
+ return false;
1577
+ if (budgetUSD === null)
1578
+ this.sessionBudgets.delete(sessionId);
1579
+ else
1580
+ this.sessionBudgets.set(sessionId, budgetUSD);
1581
+ return true;
1582
+ }
1583
+ /** Set global fallback budget (applies to all sessions without per-session budget). */
1584
+ setGlobalBudget(budgetUSD) {
1585
+ this.globalBudget = budgetUSD;
1586
+ }
1587
+ /** Get the per-session budget (or null). */
1588
+ getSessionBudget(id) {
1589
+ return this.sessionBudgets.get(id) ?? null;
1590
+ }
1591
+ /** Get the global budget (or null if not set). */
1592
+ getGlobalBudget() {
1593
+ return this.globalBudget;
1594
+ }
1595
+ /** Return all per-session budgets. */
1596
+ getAllSessionBudgets() {
1597
+ return this.sessionBudgets;
1598
+ }
1474
1599
  /** Return health history for a session (for sparkline). */
1475
1600
  getSessionHealthHistory(id) {
1476
1601
  return this.sessionHealthHistory.get(id) ?? [];
1477
1602
  }
1478
- /** Return all alert log entries (last 100 "status" tag entries). */
1479
- getAlertLog() {
1480
- return this.alertLog;
1603
+ /** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
1604
+ getAlertLog(includeAll = false) {
1605
+ if (includeAll || this.alertMutePatterns.size === 0)
1606
+ return this.alertLog;
1607
+ return this.alertLog.filter((e) => !isAlertMuted(e.text, this.alertMutePatterns));
1608
+ }
1609
+ /** Return status change history for a session. */
1610
+ getSessionStatusHistory(id) {
1611
+ return this.sessionStatusHistory.get(id) ?? [];
1612
+ }
1613
+ /** Check if a session is currently flapping. */
1614
+ isSessionFlapping(id, now) {
1615
+ const hist = this.sessionStatusHistory.get(id);
1616
+ return hist ? isFlapping(hist, now) : false;
1617
+ }
1618
+ // ── Alert mute patterns ──────────────────────────────────────────────────
1619
+ /** Add a pattern to suppress from alert log display. */
1620
+ addAlertMutePattern(pattern) {
1621
+ this.alertMutePatterns.add(pattern.toLowerCase().trim());
1622
+ }
1623
+ /** Remove a pattern from alert mute list. Returns true if it was present. */
1624
+ removeAlertMutePattern(pattern) {
1625
+ return this.alertMutePatterns.delete(pattern.toLowerCase().trim());
1626
+ }
1627
+ /** Clear all alert mute patterns. */
1628
+ clearAlertMutePatterns() {
1629
+ this.alertMutePatterns.clear();
1630
+ }
1631
+ /** Return all alert mute patterns (for display/persistence). */
1632
+ getAlertMutePatterns() {
1633
+ return this.alertMutePatterns;
1481
1634
  }
1482
1635
  /** Get the latest cost string for a session (or undefined). */
1483
1636
  getSessionCost(id) {
@@ -1797,15 +1950,42 @@ export class TUI {
1797
1950
  for (const s of opts.sessions) {
1798
1951
  if (!this.sessionFirstSeen.has(s.id))
1799
1952
  this.sessionFirstSeen.set(s.id, now);
1953
+ // track status changes for flap detection
1954
+ const prevStatus = this.prevSessionStatus.get(s.id);
1955
+ if (prevStatus !== undefined && prevStatus !== s.status) {
1956
+ const statusHist = this.sessionStatusHistory.get(s.id) ?? [];
1957
+ statusHist.push({ status: s.status, ts: now });
1958
+ if (statusHist.length > MAX_STATUS_HISTORY)
1959
+ statusHist.shift();
1960
+ this.sessionStatusHistory.set(s.id, statusHist);
1961
+ // check for flapping
1962
+ if (isFlapping(statusHist, now) && !quietNow) {
1963
+ const lastFlapAlert = this.flapAlerted.get(s.id) ?? 0;
1964
+ if (now - lastFlapAlert >= 5 * 60_000) {
1965
+ this.flapAlerted.set(s.id, now);
1966
+ this.log("status", `flap: ${s.title} is oscillating rapidly (${statusHist.filter((c) => c.ts >= now - FLAP_WINDOW_MS).length} status changes in ${Math.round(FLAP_WINDOW_MS / 60_000)}m)`, s.id);
1967
+ }
1968
+ }
1969
+ }
1970
+ this.prevSessionStatus.set(s.id, s.status);
1800
1971
  const prev = this.prevLastActivity.get(s.id);
1801
1972
  if (s.lastActivity !== undefined && s.lastActivity !== prev) {
1802
1973
  this.lastChangeAt.set(s.id, now);
1803
1974
  }
1804
1975
  if (s.lastActivity !== undefined)
1805
1976
  this.prevLastActivity.set(s.id, s.lastActivity);
1806
- // track cost string
1807
- if (s.costStr)
1977
+ // track cost string + check budget
1978
+ if (s.costStr) {
1808
1979
  this.sessionCosts.set(s.id, s.costStr);
1980
+ const budget = this.sessionBudgets.get(s.id) ?? this.globalBudget;
1981
+ if (budget !== null && isOverBudget(s.costStr, budget) && !quietNow) {
1982
+ const lastAlert = this.budgetAlerted.get(s.id) ?? 0;
1983
+ if (now - lastAlert >= 5 * 60_000) {
1984
+ this.budgetAlerted.set(s.id, now);
1985
+ this.log("status", formatBudgetAlert(s.title, s.costStr, budget), s.id);
1986
+ }
1987
+ }
1988
+ }
1809
1989
  // track context token history for burn-rate alerts
1810
1990
  const tokens = parseContextTokenNumber(s.contextTokens);
1811
1991
  if (tokens !== null) {
@@ -2394,7 +2574,8 @@ export class TUI {
2394
2574
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2395
2575
  const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth;
2396
2576
  const cardWidth = innerWidth - 1 - iconsWidth;
2397
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
2577
+ const cardAge = s.createdAt ? formatSessionAge(s.createdAt, nowMs) : undefined;
2578
+ 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)}`;
2398
2579
  const padded = padBoxLineHover(line, this.cols, isHovered);
2399
2580
  process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
2400
2581
  }
@@ -2464,7 +2645,8 @@ export class TUI {
2464
2645
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2465
2646
  const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2;
2466
2647
  const cardWidth = innerWidth - 1 - iconsWidth;
2467
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2)}`;
2648
+ const cardAge2 = s.createdAt ? formatSessionAge(s.createdAt, nowMs2) : undefined;
2649
+ 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)}`;
2468
2650
  const padded = padBoxLineHover(line, this.cols, isHovered);
2469
2651
  process.stderr.write(SAVE_CURSOR + moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded + RESTORE_CURSOR);
2470
2652
  }
@@ -2582,7 +2764,8 @@ export class TUI {
2582
2764
  // idleSinceMs: optional ms since last activity change (shown when idle/stopped)
2583
2765
  // healthBadge: optional pre-formatted health score badge ("⬡83" colored)
2584
2766
  // displayName: optional custom name override (from /rename)
2585
- function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName) {
2767
+ // ageStr: optional session age string (from createdAt)
2768
+ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName, ageStr) {
2586
2769
  const dot = STATUS_DOT[s.status] ?? `${AMBER}${DOT.filled}${RESET}`;
2587
2770
  const title = displayName ?? s.title;
2588
2771
  const name = displayName ? `${BOLD}${displayName}${DIM} (${s.title})${RESET}` : `${BOLD}${s.title}${RESET}`;
@@ -2622,7 +2805,8 @@ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge
2622
2805
  else {
2623
2806
  desc = `${SLATE}${s.status}${RESET}`;
2624
2807
  }
2625
- return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${sparkSuffix}`, maxWidth);
2808
+ const ageSuffix = ageStr ? ` ${DIM}age:${ageStr}${RESET}` : "";
2809
+ return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${ageSuffix}${sparkSuffix}`, maxWidth);
2626
2810
  }
2627
2811
  // colorize an activity entry based on its tag
2628
2812
  function formatActivity(entry, maxCols) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.146.0",
3
+ "version": "0.153.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",