aoaoe 0.149.0 → 0.156.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, DRAIN_ICON } 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,54 @@ async function main() {
1023
1023
  tui.log("system", `quiet hours: ${specs.join(", ")} — watchdog+burn alerts suppressed`);
1024
1024
  persistPrefs();
1025
1025
  });
1026
+ // wire /flap-log
1027
+ input.onFlapLog(() => {
1028
+ const log = tui.getFlapLog();
1029
+ if (log.length === 0) {
1030
+ tui.log("system", "flap-log: no flap events recorded");
1031
+ return;
1032
+ }
1033
+ tui.log("system", `flap-log: ${log.length} event${log.length !== 1 ? "s" : ""}:`);
1034
+ for (const e of log.slice(-20)) {
1035
+ const time = new Date(e.ts).toLocaleTimeString();
1036
+ tui.log("system", ` ${time} ${e.title}: ${e.count} changes in window`);
1037
+ }
1038
+ });
1039
+ // wire /drain and /undrain
1040
+ input.onDrain((target, drain) => {
1041
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1042
+ const ok = drain ? tui.drainSession(num ?? target) : tui.undrainSession(num ?? target);
1043
+ if (ok) {
1044
+ tui.log("system", `${drain ? "drain" : "undrain"}: ${target} ${drain ? `marked draining (${DRAIN_ICON})` : "restored"}`);
1045
+ }
1046
+ else {
1047
+ tui.log("system", `session not found: ${target}`);
1048
+ }
1049
+ });
1050
+ // wire /export-all bulk export
1051
+ input.onExportAll(() => {
1052
+ const sessions = tui.getSessions();
1053
+ if (sessions.length === 0) {
1054
+ tui.log("system", "export-all: no sessions");
1055
+ return;
1056
+ }
1057
+ const now = Date.now();
1058
+ const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1059
+ const dir = join(homedir(), ".aoaoe");
1060
+ try {
1061
+ mkdirSync(dir, { recursive: true });
1062
+ // snapshot JSON
1063
+ const snapData = buildSnapshotData(sessions, tui.getAllGroups(), tui.getAllNotes(), tui.getAllFirstSeen(), tui.getSessionErrorCounts(), tui.getAllBurnRates(now), pkg ?? "dev", now);
1064
+ writeFileSync(join(dir, `snapshot-${ts}.json`), formatSnapshotJson(snapData), "utf-8");
1065
+ // stats JSON
1066
+ const statEntries = 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)])));
1067
+ writeFileSync(join(dir, `stats-${ts}.json`), formatStatsJson(statEntries, pkg ?? "dev", now), "utf-8");
1068
+ tui.log("system", `export-all: snapshot + stats saved to ~/.aoaoe/ (${sessions.length} sessions)`);
1069
+ }
1070
+ catch (err) {
1071
+ tui.log("error", `export-all failed: ${err}`);
1072
+ }
1073
+ });
1026
1074
  // wire /budget cost alerts
1027
1075
  input.onBudget((target, budgetUSD) => {
1028
1076
  if (budgetUSD === null) {
@@ -1062,6 +1110,84 @@ async function main() {
1062
1110
  }
1063
1111
  tui.log("system", `${action}-all: sent to ${count} session${count !== 1 ? "s" : ""}`);
1064
1112
  });
1113
+ // wire /health-trend
1114
+ input.onHealthTrend((target, height) => {
1115
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1116
+ const sessions = tui.getSessions();
1117
+ const session = num !== undefined
1118
+ ? sessions[num - 1]
1119
+ : sessions.find((s) => s.title.toLowerCase() === target.toLowerCase() || s.id.startsWith(target));
1120
+ if (!session) {
1121
+ tui.log("system", `session not found: ${target}`);
1122
+ return;
1123
+ }
1124
+ const hist = tui.getSessionHealthHistory(session.id);
1125
+ const lines = formatHealthTrendChart(hist, session.title, height);
1126
+ for (const l of lines)
1127
+ tui.log("system", l);
1128
+ });
1129
+ // wire /alert-mute
1130
+ input.onAlertMute((pattern) => {
1131
+ if (pattern === null) {
1132
+ // null = "clear" keyword
1133
+ tui.clearAlertMutePatterns();
1134
+ tui.log("system", "alert-mute: all patterns cleared");
1135
+ return;
1136
+ }
1137
+ if (!pattern) {
1138
+ // empty = list patterns
1139
+ const pats = tui.getAlertMutePatterns();
1140
+ if (pats.size === 0) {
1141
+ tui.log("system", "alert-mute: no patterns set — use /alert-mute <text> to suppress");
1142
+ }
1143
+ else {
1144
+ tui.log("system", `alert-mute: ${pats.size} pattern${pats.size !== 1 ? "s" : ""}:`);
1145
+ for (const p of pats)
1146
+ tui.log("system", ` "${p}"`);
1147
+ }
1148
+ return;
1149
+ }
1150
+ tui.addAlertMutePattern(pattern);
1151
+ tui.log("system", `alert-mute: added "${pattern}" — matching alerts hidden from /alert-log`);
1152
+ });
1153
+ // wire /budgets list
1154
+ input.onBudgetsList(() => {
1155
+ const global = tui.getGlobalBudget();
1156
+ const perSession = tui.getAllSessionBudgets();
1157
+ if (global === null && perSession.size === 0) {
1158
+ tui.log("system", "budgets: none set — use /budget $N (global) or /budget <N> $N (per-session)");
1159
+ return;
1160
+ }
1161
+ if (global !== null)
1162
+ tui.log("system", ` global: $${global.toFixed(2)}`);
1163
+ const sessions = tui.getSessions();
1164
+ for (const [id, budget] of perSession) {
1165
+ const s = sessions.find((s) => s.id === id);
1166
+ const label = s?.title ?? id.slice(0, 8);
1167
+ tui.log("system", ` ${label}: $${budget.toFixed(2)}`);
1168
+ }
1169
+ });
1170
+ // wire /budget-status
1171
+ input.onBudgetStatus(() => {
1172
+ const sessions = tui.getSessions();
1173
+ const costs = tui.getAllSessionCosts();
1174
+ const global = tui.getGlobalBudget();
1175
+ const perSession = tui.getAllSessionBudgets();
1176
+ let shown = 0;
1177
+ for (const s of sessions) {
1178
+ const budget = perSession.get(s.id) ?? global;
1179
+ const costStr = costs.get(s.id);
1180
+ if (budget === null)
1181
+ continue;
1182
+ const over = isOverBudget(costStr, budget);
1183
+ const costLabel = costStr ?? "(no data)";
1184
+ const status = over ? `OVER ($${budget.toFixed(2)} budget)` : `ok ($${budget.toFixed(2)} budget)`;
1185
+ tui.log("system", ` ${s.title}: ${costLabel} — ${status}`);
1186
+ shown++;
1187
+ }
1188
+ if (shown === 0)
1189
+ tui.log("system", "budget-status: no sessions with budgets configured");
1190
+ });
1065
1191
  // wire /quiet-status
1066
1192
  input.onQuietStatus(() => {
1067
1193
  const { active, message } = formatQuietStatus(tui.getQuietHours());
package/dist/input.d.ts CHANGED
@@ -58,6 +58,13 @@ export type QuietStatusHandler = () => void;
58
58
  export type AlertLogHandler = (count: number) => void;
59
59
  export type BudgetHandler = (target: string | null, budgetUSD: number | null) => void;
60
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;
65
+ export type FlapLogHandler = () => void;
66
+ export type DrainHandler = (target: string, drain: boolean) => void;
67
+ export type ExportAllHandler = () => void;
61
68
  export interface MouseEvent {
62
69
  button: number;
63
70
  col: number;
@@ -138,6 +145,13 @@ export declare class InputReader {
138
145
  private alertLogHandler;
139
146
  private budgetHandler;
140
147
  private bulkControlHandler;
148
+ private healthTrendHandler;
149
+ private alertMuteHandler;
150
+ private budgetsListHandler;
151
+ private budgetStatusHandler;
152
+ private flapLogHandler;
153
+ private drainHandler;
154
+ private exportAllHandler;
141
155
  private aliases;
142
156
  private mouseDataListener;
143
157
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -201,6 +215,13 @@ export declare class InputReader {
201
215
  onAlertLog(handler: AlertLogHandler): void;
202
216
  onBudget(handler: BudgetHandler): void;
203
217
  onBulkControl(handler: BulkControlHandler): void;
218
+ onHealthTrend(handler: HealthTrendHandler): void;
219
+ onAlertMute(handler: AlertMuteHandler): void;
220
+ onBudgetsList(handler: BudgetsListHandler): void;
221
+ onBudgetStatus(handler: BudgetStatusHandler): void;
222
+ onFlapLog(handler: FlapLogHandler): void;
223
+ onDrain(handler: DrainHandler): void;
224
+ onExportAll(handler: ExportAllHandler): void;
204
225
  /** Set aliases from persisted prefs. */
205
226
  setAliases(aliases: Record<string, string>): void;
206
227
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -91,6 +91,13 @@ export class InputReader {
91
91
  alertLogHandler = null;
92
92
  budgetHandler = null;
93
93
  bulkControlHandler = null;
94
+ healthTrendHandler = null;
95
+ alertMuteHandler = null;
96
+ budgetsListHandler = null;
97
+ budgetStatusHandler = null;
98
+ flapLogHandler = null;
99
+ drainHandler = null;
100
+ exportAllHandler = null;
94
101
  aliases = new Map(); // /shortcut → /full command
95
102
  mouseDataListener = null;
96
103
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -307,6 +314,13 @@ export class InputReader {
307
314
  onAlertLog(handler) { this.alertLogHandler = handler; }
308
315
  onBudget(handler) { this.budgetHandler = handler; }
309
316
  onBulkControl(handler) { this.bulkControlHandler = handler; }
317
+ onHealthTrend(handler) { this.healthTrendHandler = handler; }
318
+ onAlertMute(handler) { this.alertMuteHandler = handler; }
319
+ onBudgetsList(handler) { this.budgetsListHandler = handler; }
320
+ onBudgetStatus(handler) { this.budgetStatusHandler = handler; }
321
+ onFlapLog(handler) { this.flapLogHandler = handler; }
322
+ onDrain(handler) { this.drainHandler = handler; }
323
+ onExportAll(handler) { this.exportAllHandler = handler; }
310
324
  /** Set aliases from persisted prefs. */
311
325
  setAliases(aliases) {
312
326
  this.aliases.clear();
@@ -572,8 +586,16 @@ ${BOLD}navigation:${RESET}
572
586
  /budget [N] [$] set cost budget: /budget 1 2.50 (session), /budget 2.50 (global), /budget clear
573
587
  /pause-all send interrupt to all sessions
574
588
  /resume-all send resume to all sessions
575
- /alert-log [N] show last N auto-generated alerts (burn-rate/watchdog/ceiling; default 20)
576
- /history-stats show aggregate statistics from persisted activity history
589
+ /alert-log [N] show last N auto-generated alerts (burn-rate/watchdog/ceiling; default 20)
590
+ /alert-mute [pat] suppress alerts containing pattern; no arg = list; clear = remove all
591
+ /health-trend N show ASCII health score chart for session N [height]
592
+ /budgets list all active cost budgets
593
+ /budget-status show which sessions are over or under budget
594
+ /flap-log show sessions recently flagged as flapping
595
+ /drain N mark session N as draining (supervisor will skip it)
596
+ /undrain N remove drain mark from session N
597
+ /export-all bulk export snapshot + stats JSON for all sessions
598
+ /history-stats show aggregate statistics from persisted activity history
577
599
  /cost-summary show total estimated spend across all sessions
578
600
  /session-report N generate full markdown report for a session → ~/.aoaoe/report-<name>-<ts>.md
579
601
  /clip [N] copy last N activity entries to clipboard (default 20)
@@ -1168,6 +1190,80 @@ ${BOLD}other:${RESET}
1168
1190
  else
1169
1191
  console.error(`${DIM}resume-all not available${RESET}`);
1170
1192
  break;
1193
+ case "/health-trend": {
1194
+ const htArgs = line.slice("/health-trend".length).trim().split(/\s+/).filter(Boolean);
1195
+ const htTarget = htArgs[0] ?? "";
1196
+ const htHeight = htArgs[1] ? parseInt(htArgs[1], 10) : 6;
1197
+ if (!htTarget) {
1198
+ console.error(`${DIM}usage: /health-trend <N|name> [height]${RESET}`);
1199
+ break;
1200
+ }
1201
+ if (this.healthTrendHandler)
1202
+ this.healthTrendHandler(htTarget, isNaN(htHeight) || htHeight < 2 ? 6 : Math.min(htHeight, 20));
1203
+ else
1204
+ console.error(`${DIM}health-trend not available${RESET}`);
1205
+ break;
1206
+ }
1207
+ case "/alert-mute": {
1208
+ const amArg = line.slice("/alert-mute".length).trim();
1209
+ if (this.alertMuteHandler) {
1210
+ if (amArg.toLowerCase() === "clear")
1211
+ this.alertMuteHandler(null);
1212
+ else
1213
+ this.alertMuteHandler(amArg || null);
1214
+ }
1215
+ else
1216
+ console.error(`${DIM}alert-mute not available${RESET}`);
1217
+ break;
1218
+ }
1219
+ case "/budgets":
1220
+ if (this.budgetsListHandler)
1221
+ this.budgetsListHandler();
1222
+ else
1223
+ console.error(`${DIM}budgets not available${RESET}`);
1224
+ break;
1225
+ case "/flap-log":
1226
+ if (this.flapLogHandler)
1227
+ this.flapLogHandler();
1228
+ else
1229
+ console.error(`${DIM}flap-log not available${RESET}`);
1230
+ break;
1231
+ case "/drain": {
1232
+ const drainArg = line.slice("/drain".length).trim();
1233
+ if (!drainArg) {
1234
+ console.error(`${DIM}usage: /drain <N|name>${RESET}`);
1235
+ break;
1236
+ }
1237
+ if (this.drainHandler)
1238
+ this.drainHandler(drainArg, true);
1239
+ else
1240
+ console.error(`${DIM}drain not available${RESET}`);
1241
+ break;
1242
+ }
1243
+ case "/undrain": {
1244
+ const undrainArg = line.slice("/undrain".length).trim();
1245
+ if (!undrainArg) {
1246
+ console.error(`${DIM}usage: /undrain <N|name>${RESET}`);
1247
+ break;
1248
+ }
1249
+ if (this.drainHandler)
1250
+ this.drainHandler(undrainArg, false);
1251
+ else
1252
+ console.error(`${DIM}undrain not available${RESET}`);
1253
+ break;
1254
+ }
1255
+ case "/export-all":
1256
+ if (this.exportAllHandler)
1257
+ this.exportAllHandler();
1258
+ else
1259
+ console.error(`${DIM}export-all not available${RESET}`);
1260
+ break;
1261
+ case "/budget-status":
1262
+ if (this.budgetStatusHandler)
1263
+ this.budgetStatusHandler();
1264
+ else
1265
+ console.error(`${DIM}budget-status not available${RESET}`);
1266
+ break;
1171
1267
  case "/quiet-status":
1172
1268
  if (this.quietStatusHandler)
1173
1269
  this.quietStatusHandler();
package/dist/tui.d.ts CHANGED
@@ -137,6 +137,36 @@ 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
+ /** Drain icon shown in session cards for draining sessions. */
164
+ export declare const DRAIN_ICON = "\u21E3";
165
+ /**
166
+ * Check if an alert text matches any suppressed pattern (case-insensitive substring).
167
+ * Returns true when the alert should be hidden.
168
+ */
169
+ export declare function isAlertMuted(text: string, patterns: ReadonlySet<string>): boolean;
140
170
  /**
141
171
  * Parse a cost string like "$3.42" → 3.42, or null if unparseable.
142
172
  */
@@ -415,6 +445,12 @@ export declare class TUI {
415
445
  private sessionBudgets;
416
446
  private globalBudget;
417
447
  private budgetAlerted;
448
+ private sessionStatusHistory;
449
+ private prevSessionStatus;
450
+ private flapAlerted;
451
+ private alertMutePatterns;
452
+ private drainingIds;
453
+ private flapLog;
418
454
  private viewMode;
419
455
  private drilldownSessionId;
420
456
  private sessionOutputs;
@@ -558,8 +594,35 @@ export declare class TUI {
558
594
  getAllSessionBudgets(): ReadonlyMap<string, number>;
559
595
  /** Return health history for a session (for sparkline). */
560
596
  getSessionHealthHistory(id: string): readonly HealthSnapshot[];
561
- /** Return all alert log entries (last 100 "status" tag entries). */
562
- getAlertLog(): readonly ActivityEntry[];
597
+ /** Mark a session as draining (by index, ID, or title). Returns true if found. */
598
+ drainSession(sessionIdOrIndex: string | number): boolean;
599
+ /** Remove drain mark from a session. Returns true if it was draining. */
600
+ undrainSession(sessionIdOrIndex: string | number): boolean;
601
+ /** Check if a session is draining. */
602
+ isDraining(id: string): boolean;
603
+ /** Return all draining session IDs (for reasoner prompt and display). */
604
+ getDrainingIds(): ReadonlySet<string>;
605
+ /** Return recent flap events (newest last). */
606
+ getFlapLog(): readonly {
607
+ sessionId: string;
608
+ title: string;
609
+ ts: number;
610
+ count: number;
611
+ }[];
612
+ /** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
613
+ getAlertLog(includeAll?: boolean): readonly ActivityEntry[];
614
+ /** Return status change history for a session. */
615
+ getSessionStatusHistory(id: string): readonly StatusChange[];
616
+ /** Check if a session is currently flapping. */
617
+ isSessionFlapping(id: string, now?: number): boolean;
618
+ /** Add a pattern to suppress from alert log display. */
619
+ addAlertMutePattern(pattern: string): void;
620
+ /** Remove a pattern from alert mute list. Returns true if it was present. */
621
+ removeAlertMutePattern(pattern: string): boolean;
622
+ /** Clear all alert mute patterns. */
623
+ clearAlertMutePatterns(): void;
624
+ /** Return all alert mute patterns (for display/persistence). */
625
+ getAlertMutePatterns(): ReadonlySet<string>;
563
626
  /** Get the latest cost string for a session (or undefined). */
564
627
  getSessionCost(id: string): string | undefined;
565
628
  /** Return all session costs (for /stats). */
package/dist/tui.js CHANGED
@@ -428,6 +428,77 @@ 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
+ // ── Session drain mode helpers ────────────────────────────────────────────────
485
+ /** Drain icon shown in session cards for draining sessions. */
486
+ export const DRAIN_ICON = "⇣";
487
+ // ── Alert mute patterns (pure, exported for testing) ──────────────────────────
488
+ /**
489
+ * Check if an alert text matches any suppressed pattern (case-insensitive substring).
490
+ * Returns true when the alert should be hidden.
491
+ */
492
+ export function isAlertMuted(text, patterns) {
493
+ if (patterns.size === 0)
494
+ return false;
495
+ const lower = text.toLowerCase();
496
+ for (const p of patterns) {
497
+ if (lower.includes(p.toLowerCase()))
498
+ return true;
499
+ }
500
+ return false;
501
+ }
431
502
  // ── Cost summary (pure, exported for testing) ────────────────────────────────
432
503
  /**
433
504
  * Parse a cost string like "$3.42" → 3.42, or null if unparseable.
@@ -738,7 +809,8 @@ export const BUILTIN_COMMANDS = new Set([
738
809
  "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
739
810
  "/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
740
811
  "/duplicate", "/color-all", "/quiet-hours", "/quiet-status", "/history-stats", "/cost-summary", "/session-report", "/alert-log",
741
- "/budget", "/pause-all", "/resume-all",
812
+ "/budget", "/budgets", "/budget-status", "/pause-all", "/resume-all",
813
+ "/health-trend", "/alert-mute", "/flap-log", "/drain", "/undrain", "/export-all",
742
814
  ]);
743
815
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
744
816
  export function resolveAlias(line, aliases) {
@@ -1015,6 +1087,12 @@ export class TUI {
1015
1087
  sessionBudgets = new Map(); // session ID → USD budget
1016
1088
  globalBudget = null; // global fallback budget in USD
1017
1089
  budgetAlerted = new Map(); // session ID → epoch ms of last budget alert
1090
+ sessionStatusHistory = new Map(); // session ID → status change log
1091
+ prevSessionStatus = new Map(); // session ID → last known status (for change detection)
1092
+ flapAlerted = new Map(); // session ID → epoch ms of last flap alert
1093
+ alertMutePatterns = new Set(); // substrings to hide from /alert-log display
1094
+ drainingIds = new Set(); // session IDs marked as draining (skip by reasoner)
1095
+ flapLog = []; // recent flap events
1018
1096
  // drill-down mode: show a single session's full output
1019
1097
  viewMode = "overview";
1020
1098
  drilldownSessionId = null;
@@ -1527,9 +1605,87 @@ export class TUI {
1527
1605
  getSessionHealthHistory(id) {
1528
1606
  return this.sessionHealthHistory.get(id) ?? [];
1529
1607
  }
1530
- /** Return all alert log entries (last 100 "status" tag entries). */
1531
- getAlertLog() {
1532
- return this.alertLog;
1608
+ // ── Session drain mode ───────────────────────────────────────────────────
1609
+ /** Mark a session as draining (by index, ID, or title). Returns true if found. */
1610
+ drainSession(sessionIdOrIndex) {
1611
+ let sessionId;
1612
+ if (typeof sessionIdOrIndex === "number") {
1613
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1614
+ }
1615
+ else {
1616
+ const needle = sessionIdOrIndex.toLowerCase();
1617
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1618
+ sessionId = match?.id;
1619
+ }
1620
+ if (!sessionId)
1621
+ return false;
1622
+ this.drainingIds.add(sessionId);
1623
+ if (this.active)
1624
+ this.paintSessions();
1625
+ return true;
1626
+ }
1627
+ /** Remove drain mark from a session. Returns true if it was draining. */
1628
+ undrainSession(sessionIdOrIndex) {
1629
+ let sessionId;
1630
+ if (typeof sessionIdOrIndex === "number") {
1631
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1632
+ }
1633
+ else {
1634
+ const needle = sessionIdOrIndex.toLowerCase();
1635
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1636
+ sessionId = match?.id;
1637
+ }
1638
+ if (!sessionId)
1639
+ return false;
1640
+ const had = this.drainingIds.delete(sessionId);
1641
+ if (had && this.active)
1642
+ this.paintSessions();
1643
+ return had;
1644
+ }
1645
+ /** Check if a session is draining. */
1646
+ isDraining(id) {
1647
+ return this.drainingIds.has(id);
1648
+ }
1649
+ /** Return all draining session IDs (for reasoner prompt and display). */
1650
+ getDrainingIds() {
1651
+ return this.drainingIds;
1652
+ }
1653
+ // ── Flap log ─────────────────────────────────────────────────────────────
1654
+ /** Return recent flap events (newest last). */
1655
+ getFlapLog() {
1656
+ return this.flapLog;
1657
+ }
1658
+ /** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
1659
+ getAlertLog(includeAll = false) {
1660
+ if (includeAll || this.alertMutePatterns.size === 0)
1661
+ return this.alertLog;
1662
+ return this.alertLog.filter((e) => !isAlertMuted(e.text, this.alertMutePatterns));
1663
+ }
1664
+ /** Return status change history for a session. */
1665
+ getSessionStatusHistory(id) {
1666
+ return this.sessionStatusHistory.get(id) ?? [];
1667
+ }
1668
+ /** Check if a session is currently flapping. */
1669
+ isSessionFlapping(id, now) {
1670
+ const hist = this.sessionStatusHistory.get(id);
1671
+ return hist ? isFlapping(hist, now) : false;
1672
+ }
1673
+ // ── Alert mute patterns ──────────────────────────────────────────────────
1674
+ /** Add a pattern to suppress from alert log display. */
1675
+ addAlertMutePattern(pattern) {
1676
+ this.alertMutePatterns.add(pattern.toLowerCase().trim());
1677
+ }
1678
+ /** Remove a pattern from alert mute list. Returns true if it was present. */
1679
+ removeAlertMutePattern(pattern) {
1680
+ return this.alertMutePatterns.delete(pattern.toLowerCase().trim());
1681
+ }
1682
+ /** Clear all alert mute patterns. */
1683
+ clearAlertMutePatterns() {
1684
+ this.alertMutePatterns.clear();
1685
+ }
1686
+ /** Return all alert mute patterns (for display/persistence). */
1687
+ getAlertMutePatterns() {
1688
+ return this.alertMutePatterns;
1533
1689
  }
1534
1690
  /** Get the latest cost string for a session (or undefined). */
1535
1691
  getSessionCost(id) {
@@ -1849,6 +2005,28 @@ export class TUI {
1849
2005
  for (const s of opts.sessions) {
1850
2006
  if (!this.sessionFirstSeen.has(s.id))
1851
2007
  this.sessionFirstSeen.set(s.id, now);
2008
+ // track status changes for flap detection
2009
+ const prevStatus = this.prevSessionStatus.get(s.id);
2010
+ if (prevStatus !== undefined && prevStatus !== s.status) {
2011
+ const statusHist = this.sessionStatusHistory.get(s.id) ?? [];
2012
+ statusHist.push({ status: s.status, ts: now });
2013
+ if (statusHist.length > MAX_STATUS_HISTORY)
2014
+ statusHist.shift();
2015
+ this.sessionStatusHistory.set(s.id, statusHist);
2016
+ // check for flapping
2017
+ if (isFlapping(statusHist, now) && !quietNow) {
2018
+ const lastFlapAlert = this.flapAlerted.get(s.id) ?? 0;
2019
+ if (now - lastFlapAlert >= 5 * 60_000) {
2020
+ this.flapAlerted.set(s.id, now);
2021
+ const flapCount = statusHist.filter((c) => c.ts >= now - FLAP_WINDOW_MS).length;
2022
+ this.flapLog.push({ sessionId: s.id, title: s.title, ts: now, count: flapCount });
2023
+ if (this.flapLog.length > 50)
2024
+ this.flapLog.shift();
2025
+ this.log("status", `flap: ${s.title} is oscillating rapidly (${flapCount} status changes in ${Math.round(FLAP_WINDOW_MS / 60_000)}m)`, s.id);
2026
+ }
2027
+ }
2028
+ }
2029
+ this.prevSessionStatus.set(s.id, s.status);
1852
2030
  const prev = this.prevLastActivity.get(s.id);
1853
2031
  if (s.lastActivity !== undefined && s.lastActivity !== prev) {
1854
2032
  this.lastChangeAt.set(s.id, now);
@@ -2444,6 +2622,8 @@ export class TUI {
2444
2622
  const colorName = this.sessionColors.get(s.id);
2445
2623
  const colorDot = colorName ? formatColorDot(colorName) : "";
2446
2624
  const colorDotWidth = colorName ? 2 : 0; // dot + space
2625
+ const draining = this.drainingIds.has(s.id);
2626
+ const drainIcon = draining ? `${DIM}${DRAIN_ICON}${RESET} ` : "";
2447
2627
  const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
2448
2628
  const muteBadgeWidth = muted ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 2 : 0; // "(N)" visible chars, 0 when count is 0
2449
2629
  const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0 ? muteBadgeWidth + 1 : 0; // +1 for trailing space
@@ -2453,10 +2633,10 @@ export class TUI {
2453
2633
  const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
2454
2634
  const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
2455
2635
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2456
- const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth;
2636
+ const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth + (draining ? 2 : 0);
2457
2637
  const cardWidth = innerWidth - 1 - iconsWidth;
2458
2638
  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)}`;
2639
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${drainIcon}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName, cardAge || undefined)}`;
2460
2640
  const padded = padBoxLineHover(line, this.cols, isHovered);
2461
2641
  process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
2462
2642
  }
@@ -2524,10 +2704,12 @@ export class TUI {
2524
2704
  const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
2525
2705
  const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
2526
2706
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2527
- const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2;
2707
+ const draining2 = this.drainingIds.has(s.id);
2708
+ const drainIcon2 = draining2 ? `${DIM}${DRAIN_ICON}${RESET} ` : "";
2709
+ const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2 + (draining2 ? 2 : 0);
2528
2710
  const cardWidth = innerWidth - 1 - iconsWidth;
2529
2711
  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)}`;
2712
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${drainIcon2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2, cardAge2 || undefined)}`;
2531
2713
  const padded = padBoxLineHover(line, this.cols, isHovered);
2532
2714
  process.stderr.write(SAVE_CURSOR + moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded + RESTORE_CURSOR);
2533
2715
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.149.0",
3
+ "version": "0.156.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",