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 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
- /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
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
- 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;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.149.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",