aoaoe 0.149.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 +79 -1
- package/dist/input.d.ts +12 -0
- package/dist/input.js +52 -2
- package/dist/tui.d.ts +46 -2
- package/dist/tui.js +123 -4
- package/package.json +1 -1
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";
|
|
@@ -1062,6 +1062,84 @@ async function main() {
|
|
|
1062
1062
|
}
|
|
1063
1063
|
tui.log("system", `${action}-all: sent to ${count} session${count !== 1 ? "s" : ""}`);
|
|
1064
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
|
+
});
|
|
1065
1143
|
// wire /quiet-status
|
|
1066
1144
|
input.onQuietStatus(() => {
|
|
1067
1145
|
const { active, message } = formatQuietStatus(tui.getQuietHours());
|
package/dist/input.d.ts
CHANGED
|
@@ -58,6 +58,10 @@ 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;
|
|
61
65
|
export interface MouseEvent {
|
|
62
66
|
button: number;
|
|
63
67
|
col: number;
|
|
@@ -138,6 +142,10 @@ export declare class InputReader {
|
|
|
138
142
|
private alertLogHandler;
|
|
139
143
|
private budgetHandler;
|
|
140
144
|
private bulkControlHandler;
|
|
145
|
+
private healthTrendHandler;
|
|
146
|
+
private alertMuteHandler;
|
|
147
|
+
private budgetsListHandler;
|
|
148
|
+
private budgetStatusHandler;
|
|
141
149
|
private aliases;
|
|
142
150
|
private mouseDataListener;
|
|
143
151
|
onScroll(handler: (dir: ScrollDirection) => void): void;
|
|
@@ -201,6 +209,10 @@ export declare class InputReader {
|
|
|
201
209
|
onAlertLog(handler: AlertLogHandler): void;
|
|
202
210
|
onBudget(handler: BudgetHandler): void;
|
|
203
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;
|
|
204
216
|
/** Set aliases from persisted prefs. */
|
|
205
217
|
setAliases(aliases: Record<string, string>): void;
|
|
206
218
|
/** Get current aliases as a plain object. */
|
package/dist/input.js
CHANGED
|
@@ -91,6 +91,10 @@ 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;
|
|
94
98
|
aliases = new Map(); // /shortcut → /full command
|
|
95
99
|
mouseDataListener = null;
|
|
96
100
|
// register a callback for scroll key events (PgUp/PgDn/Home/End)
|
|
@@ -307,6 +311,10 @@ export class InputReader {
|
|
|
307
311
|
onAlertLog(handler) { this.alertLogHandler = handler; }
|
|
308
312
|
onBudget(handler) { this.budgetHandler = handler; }
|
|
309
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; }
|
|
310
318
|
/** Set aliases from persisted prefs. */
|
|
311
319
|
setAliases(aliases) {
|
|
312
320
|
this.aliases.clear();
|
|
@@ -572,8 +580,12 @@ ${BOLD}navigation:${RESET}
|
|
|
572
580
|
/budget [N] [$] set cost budget: /budget 1 2.50 (session), /budget 2.50 (global), /budget clear
|
|
573
581
|
/pause-all send interrupt to all sessions
|
|
574
582
|
/resume-all send resume to all sessions
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
577
589
|
/cost-summary show total estimated spend across all sessions
|
|
578
590
|
/session-report N generate full markdown report for a session → ~/.aoaoe/report-<name>-<ts>.md
|
|
579
591
|
/clip [N] copy last N activity entries to clipboard (default 20)
|
|
@@ -1168,6 +1180,44 @@ ${BOLD}other:${RESET}
|
|
|
1168
1180
|
else
|
|
1169
1181
|
console.error(`${DIM}resume-all not available${RESET}`);
|
|
1170
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;
|
|
1171
1221
|
case "/quiet-status":
|
|
1172
1222
|
if (this.quietStatusHandler)
|
|
1173
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
|
*/
|
|
@@ -415,6 +443,10 @@ export declare class TUI {
|
|
|
415
443
|
private sessionBudgets;
|
|
416
444
|
private globalBudget;
|
|
417
445
|
private budgetAlerted;
|
|
446
|
+
private sessionStatusHistory;
|
|
447
|
+
private prevSessionStatus;
|
|
448
|
+
private flapAlerted;
|
|
449
|
+
private alertMutePatterns;
|
|
418
450
|
private viewMode;
|
|
419
451
|
private drilldownSessionId;
|
|
420
452
|
private sessionOutputs;
|
|
@@ -558,8 +590,20 @@ export declare class TUI {
|
|
|
558
590
|
getAllSessionBudgets(): ReadonlyMap<string, number>;
|
|
559
591
|
/** Return health history for a session (for sparkline). */
|
|
560
592
|
getSessionHealthHistory(id: string): readonly HealthSnapshot[];
|
|
561
|
-
/** Return all alert log entries (last 100 "status" tag entries). */
|
|
562
|
-
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>;
|
|
563
607
|
/** Get the latest cost string for a session (or undefined). */
|
|
564
608
|
getSessionCost(id: string): string | undefined;
|
|
565
609
|
/** Return all session costs (for /stats). */
|
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.
|
|
@@ -738,7 +806,8 @@ export const BUILTIN_COMMANDS = new Set([
|
|
|
738
806
|
"/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
|
|
739
807
|
"/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
|
|
740
808
|
"/duplicate", "/color-all", "/quiet-hours", "/quiet-status", "/history-stats", "/cost-summary", "/session-report", "/alert-log",
|
|
741
|
-
"/budget", "/pause-all", "/resume-all",
|
|
809
|
+
"/budget", "/budgets", "/budget-status", "/pause-all", "/resume-all",
|
|
810
|
+
"/health-trend", "/alert-mute",
|
|
742
811
|
]);
|
|
743
812
|
/** Resolve a slash command through the alias map. Returns the expanded command or the original. */
|
|
744
813
|
export function resolveAlias(line, aliases) {
|
|
@@ -1015,6 +1084,10 @@ export class TUI {
|
|
|
1015
1084
|
sessionBudgets = new Map(); // session ID → USD budget
|
|
1016
1085
|
globalBudget = null; // global fallback budget in USD
|
|
1017
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
|
|
1018
1091
|
// drill-down mode: show a single session's full output
|
|
1019
1092
|
viewMode = "overview";
|
|
1020
1093
|
drilldownSessionId = null;
|
|
@@ -1527,9 +1600,37 @@ export class TUI {
|
|
|
1527
1600
|
getSessionHealthHistory(id) {
|
|
1528
1601
|
return this.sessionHealthHistory.get(id) ?? [];
|
|
1529
1602
|
}
|
|
1530
|
-
/** Return all alert log entries (last 100 "status" tag entries). */
|
|
1531
|
-
getAlertLog() {
|
|
1532
|
-
|
|
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;
|
|
1533
1634
|
}
|
|
1534
1635
|
/** Get the latest cost string for a session (or undefined). */
|
|
1535
1636
|
getSessionCost(id) {
|
|
@@ -1849,6 +1950,24 @@ export class TUI {
|
|
|
1849
1950
|
for (const s of opts.sessions) {
|
|
1850
1951
|
if (!this.sessionFirstSeen.has(s.id))
|
|
1851
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);
|
|
1852
1971
|
const prev = this.prevLastActivity.get(s.id);
|
|
1853
1972
|
if (s.lastActivity !== undefined && s.lastActivity !== prev) {
|
|
1854
1973
|
this.lastChangeAt.set(s.id, now);
|