aoaoe 0.114.0 → 0.122.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,11 +16,11 @@ 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 } 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 } 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";
23
- import { loadTuiHistory } from "./tui-history.js";
23
+ import { loadTuiHistory, searchHistory } from "./tui-history.js";
24
24
  import { ConfigWatcher, formatConfigChange } from "./config-watcher.js";
25
25
  import { parseActionLogEntries, parseActivityEntries, mergeTimeline, filterByAge, parseDuration, formatTimelineJson, formatTimelineMarkdown } from "./export.js";
26
26
  import { actionSession, actionDetail, toActionLogEntry } from "./types.js";
@@ -757,6 +757,100 @@ async function main() {
757
757
  if (!any)
758
758
  tui.log("system", ` threshold: ${CONTEXT_BURN_THRESHOLD.toLocaleString()} tokens/min`);
759
759
  });
760
+ // wire /pin-all-errors
761
+ input.onPinAllErrors(() => {
762
+ const count = tui.pinAllErrors();
763
+ if (count === 0) {
764
+ tui.log("system", "pin-all-errors: no error sessions to pin");
765
+ }
766
+ else {
767
+ tui.log("system", `pin-all-errors: pinned ${count} session${count !== 1 ? "s" : ""}`);
768
+ persistPrefs();
769
+ }
770
+ });
771
+ // wire /export-stats
772
+ input.onExportStats(() => {
773
+ const sessions = tui.getSessions();
774
+ const now = Date.now();
775
+ const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
776
+ const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
777
+ const dir = join(homedir(), ".aoaoe");
778
+ const path = join(dir, `stats-${ts}.json`);
779
+ try {
780
+ mkdirSync(dir, { recursive: true });
781
+ writeFileSync(path, formatStatsJson(entries, pkg ?? "dev", now), "utf-8");
782
+ tui.log("system", `stats exported: ~/.aoaoe/stats-${ts}.json (${entries.length} sessions)`);
783
+ }
784
+ catch (err) {
785
+ tui.log("error", `export-stats failed: ${err}`);
786
+ }
787
+ });
788
+ // wire /recall — search persisted history
789
+ input.onRecall((keyword, maxResults) => {
790
+ const matches = searchHistory(keyword, maxResults);
791
+ if (matches.length === 0) {
792
+ tui.log("system", `recall: no matches for "${keyword}" in history`);
793
+ return;
794
+ }
795
+ tui.log("system", `recall: ${matches.length} match${matches.length !== 1 ? "es" : ""} for "${keyword}":`);
796
+ for (const e of matches) {
797
+ tui.log("system", ` ${e.time} ${e.tag} ${e.text}`);
798
+ }
799
+ });
800
+ // wire /stats per-session summary
801
+ input.onStats(() => {
802
+ const sessions = tui.getSessions();
803
+ if (sessions.length === 0) {
804
+ tui.log("system", "no sessions — no stats available");
805
+ return;
806
+ }
807
+ const now = Date.now();
808
+ const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
809
+ tui.log("system", `/stats — ${entries.length} session${entries.length !== 1 ? "s" : ""}:`);
810
+ for (const line of formatSessionStatsLines(entries)) {
811
+ tui.log("system", line);
812
+ }
813
+ });
814
+ // wire /copy session pane output to clipboard
815
+ input.onCopySession((target) => {
816
+ // resolve target: null = current drill-down session
817
+ let lines = null;
818
+ let label = "current session";
819
+ if (target === null) {
820
+ const ddId = tui.getDrilldownId();
821
+ if (!ddId) {
822
+ tui.log("system", "no session in view — use /copy N or drill into a session first");
823
+ return;
824
+ }
825
+ lines = tui.getSessionOutput(ddId);
826
+ const s = tui.getSessions().find((s) => s.id === ddId);
827
+ label = s?.title ?? ddId.slice(0, 8);
828
+ }
829
+ else {
830
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
831
+ lines = tui.getSessionOutput(num ?? target);
832
+ label = target;
833
+ }
834
+ if (!lines || lines.length === 0) {
835
+ tui.log("system", `no output stored for ${label} — session may not have been polled yet`);
836
+ return;
837
+ }
838
+ const text = lines.join("\n") + "\n";
839
+ try {
840
+ execSync("pbcopy", { input: text, timeout: 5000 });
841
+ tui.log("system", `copied ${lines.length} lines from ${label} to clipboard`);
842
+ }
843
+ catch {
844
+ try {
845
+ const copyPath = join(homedir(), ".aoaoe", "copy.txt");
846
+ writeFileSync(copyPath, text, "utf-8");
847
+ tui.log("system", `saved ${lines.length} lines from ${label} to ~/.aoaoe/copy.txt`);
848
+ }
849
+ catch (writeErr) {
850
+ tui.log("error", `copy failed: ${writeErr}`);
851
+ }
852
+ }
853
+ });
760
854
  // wire /rename custom display name
761
855
  input.onRename((target, displayName) => {
762
856
  const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
package/dist/input.d.ts CHANGED
@@ -33,6 +33,11 @@ export type WatchdogHandler = (thresholdMinutes: number | null) => void;
33
33
  export type TopHandler = (mode: string) => void;
34
34
  export type CeilingHandler = () => void;
35
35
  export type RenameHandler = (target: string, name: string) => void;
36
+ export type CopySessionHandler = (target: string | null) => void;
37
+ export type StatsHandler = () => void;
38
+ export type RecallHandler = (keyword: string, maxResults: number) => void;
39
+ export type PinAllErrorsHandler = () => void;
40
+ export type ExportStatsHandler = () => void;
36
41
  export interface MouseEvent {
37
42
  button: number;
38
43
  col: number;
@@ -88,6 +93,11 @@ export declare class InputReader {
88
93
  private topHandler;
89
94
  private ceilingHandler;
90
95
  private renameHandler;
96
+ private copySessionHandler;
97
+ private statsHandler;
98
+ private recallHandler;
99
+ private pinAllErrorsHandler;
100
+ private exportStatsHandler;
91
101
  private aliases;
92
102
  private mouseDataListener;
93
103
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -126,6 +136,11 @@ export declare class InputReader {
126
136
  onTop(handler: TopHandler): void;
127
137
  onCeiling(handler: CeilingHandler): void;
128
138
  onRename(handler: RenameHandler): void;
139
+ onCopySession(handler: CopySessionHandler): void;
140
+ onStats(handler: StatsHandler): void;
141
+ onRecall(handler: RecallHandler): void;
142
+ onPinAllErrors(handler: PinAllErrorsHandler): void;
143
+ onExportStats(handler: ExportStatsHandler): void;
129
144
  /** Set aliases from persisted prefs. */
130
145
  setAliases(aliases: Record<string, string>): void;
131
146
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -66,6 +66,11 @@ export class InputReader {
66
66
  topHandler = null;
67
67
  ceilingHandler = null;
68
68
  renameHandler = null;
69
+ copySessionHandler = null;
70
+ statsHandler = null;
71
+ recallHandler = null;
72
+ pinAllErrorsHandler = null;
73
+ exportStatsHandler = null;
69
74
  aliases = new Map(); // /shortcut → /full command
70
75
  mouseDataListener = null;
71
76
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -212,6 +217,26 @@ export class InputReader {
212
217
  onRename(handler) {
213
218
  this.renameHandler = handler;
214
219
  }
220
+ // register a callback for /copy [N|name] — copy session pane output
221
+ onCopySession(handler) {
222
+ this.copySessionHandler = handler;
223
+ }
224
+ // register a callback for /stats — per-session stats summary
225
+ onStats(handler) {
226
+ this.statsHandler = handler;
227
+ }
228
+ // register a callback for /recall <keyword> [N] — search history
229
+ onRecall(handler) {
230
+ this.recallHandler = handler;
231
+ }
232
+ // register a callback for /pin-all-errors — pin all error sessions
233
+ onPinAllErrors(handler) {
234
+ this.pinAllErrorsHandler = handler;
235
+ }
236
+ // register a callback for /export-stats — export stats to JSON file
237
+ onExportStats(handler) {
238
+ this.exportStatsHandler = handler;
239
+ }
215
240
  /** Set aliases from persisted prefs. */
216
241
  setAliases(aliases) {
217
242
  this.aliases.clear();
@@ -448,6 +473,11 @@ ${BOLD}navigation:${RESET}
448
473
  /top [mode] rank sessions by errors (default), burn, or idle
449
474
  /ceiling show context token usage vs limit for all sessions
450
475
  /rename N|name [display] set custom display name in TUI (no display = clear)
476
+ /copy [N|name] copy session's current pane output to clipboard (default: current drill-down)
477
+ /stats show per-session health, errors, burn rate, context %, uptime
478
+ /recall <keyword> search persisted activity history (last 7 days) for keyword
479
+ /pin-all-errors pin every session currently in error status
480
+ /export-stats export /stats output as JSON to ~/.aoaoe/stats-<ts>.json
451
481
  /clip [N] copy last N activity entries to clipboard (default 20)
452
482
  /diff N show activity since bookmark N
453
483
  /mark bookmark current activity position
@@ -837,6 +867,57 @@ ${BOLD}other:${RESET}
837
867
  }
838
868
  break;
839
869
  }
870
+ case "/pin-all-errors":
871
+ if (this.pinAllErrorsHandler) {
872
+ this.pinAllErrorsHandler();
873
+ }
874
+ else {
875
+ console.error(`${DIM}pin-all-errors not available (no TUI)${RESET}`);
876
+ }
877
+ break;
878
+ case "/export-stats":
879
+ if (this.exportStatsHandler) {
880
+ this.exportStatsHandler();
881
+ }
882
+ else {
883
+ console.error(`${DIM}export-stats not available (no TUI)${RESET}`);
884
+ }
885
+ break;
886
+ case "/recall": {
887
+ const recallArgs = line.slice("/recall".length).trim().split(/\s+/);
888
+ const keyword = recallArgs[0] ?? "";
889
+ if (!keyword) {
890
+ console.error(`${DIM}usage: /recall <keyword> [N] — search activity history (default: last 50 matches)${RESET}`);
891
+ break;
892
+ }
893
+ const maxN = recallArgs[1] ? parseInt(recallArgs[1], 10) : 50;
894
+ const limit = isNaN(maxN) || maxN < 1 ? 50 : Math.min(maxN, 500);
895
+ if (this.recallHandler) {
896
+ this.recallHandler(keyword, limit);
897
+ }
898
+ else {
899
+ console.error(`${DIM}recall not available (no TUI)${RESET}`);
900
+ }
901
+ break;
902
+ }
903
+ case "/stats":
904
+ if (this.statsHandler) {
905
+ this.statsHandler();
906
+ }
907
+ else {
908
+ console.error(`${DIM}stats not available (no TUI)${RESET}`);
909
+ }
910
+ break;
911
+ case "/copy": {
912
+ const copyArg = line.slice("/copy".length).trim() || null;
913
+ if (this.copySessionHandler) {
914
+ this.copySessionHandler(copyArg);
915
+ }
916
+ else {
917
+ console.error(`${DIM}copy not available (no TUI)${RESET}`);
918
+ }
919
+ break;
920
+ }
840
921
  case "/rename": {
841
922
  const renameArg = line.slice("/rename".length).trim();
842
923
  if (!renameArg) {
@@ -25,4 +25,10 @@ export declare function loadTuiHistory(maxEntries?: number, filePath?: string, m
25
25
  export declare function rotateTuiHistory(filePath?: string, maxSize?: number): boolean;
26
26
  /** Default history file path (for wiring in index.ts) */
27
27
  export declare const TUI_HISTORY_FILE: string;
28
+ /**
29
+ * Search history entries by keyword (case-insensitive substring match on text and tag).
30
+ * Returns up to `maxResults` most recent matching entries (newest last).
31
+ * Searches both the current and .old history file.
32
+ */
33
+ export declare function searchHistory(keyword: string, maxResults?: number, filePath?: string, maxAgeMs?: number): HistoryEntry[];
28
34
  //# sourceMappingURL=tui-history.d.ts.map
@@ -88,4 +88,51 @@ function isValidEntry(val) {
88
88
  }
89
89
  /** Default history file path (for wiring in index.ts) */
90
90
  export const TUI_HISTORY_FILE = HISTORY_FILE;
91
+ /**
92
+ * Search history entries by keyword (case-insensitive substring match on text and tag).
93
+ * Returns up to `maxResults` most recent matching entries (newest last).
94
+ * Searches both the current and .old history file.
95
+ */
96
+ export function searchHistory(keyword, maxResults = 50, filePath = HISTORY_FILE, maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
97
+ const lower = keyword.toLowerCase();
98
+ const results = [];
99
+ // helper: collect matches from one file
100
+ const collectFrom = (fp) => {
101
+ try {
102
+ if (!existsSync(fp))
103
+ return;
104
+ const content = readFileSync(fp, "utf-8");
105
+ const cutoff = Date.now() - maxAgeMs;
106
+ for (const line of content.split("\n")) {
107
+ if (!line.trim())
108
+ continue;
109
+ try {
110
+ const e = JSON.parse(line);
111
+ if (!isValidEntry(e) || e.ts < cutoff)
112
+ continue;
113
+ if (e.text.toLowerCase().includes(lower) || e.tag.toLowerCase().includes(lower)) {
114
+ results.push(e);
115
+ }
116
+ }
117
+ catch { /* skip malformed */ }
118
+ }
119
+ }
120
+ catch { /* ignore unreadable file */ }
121
+ };
122
+ // search .old first (older), then current (newer)
123
+ collectFrom(filePath + ".old");
124
+ collectFrom(filePath);
125
+ // deduplicate by ts+text, sort newest last, cap to maxResults
126
+ const seen = new Set();
127
+ const deduped = [];
128
+ for (const e of results) {
129
+ const key = `${e.ts}:${e.text}`;
130
+ if (!seen.has(key)) {
131
+ seen.add(key);
132
+ deduped.push(e);
133
+ }
134
+ }
135
+ deduped.sort((a, b) => a.ts - b.ts);
136
+ return deduped.slice(-maxResults);
137
+ }
91
138
  //# sourceMappingURL=tui-history.js.map
package/dist/tui.d.ts CHANGED
@@ -28,12 +28,32 @@ declare const COMPACT_NAME_LEN = 10;
28
28
  declare const PIN_ICON = "\u25B2";
29
29
  /**
30
30
  * Format sessions as inline compact tokens, wrapped to fit maxWidth.
31
- * Each token: "{idx}{pin?}{mute?}{dot}{name}" — e.g. "1▲●Alpha" for pinned, "2◌●Bravo" for muted.
31
+ * Each token: "{idx}{pin?}{mute?}{dot}{name}{health?}" — e.g. "1▲●Alpha" for pinned, "2◌●Bravo" for muted.
32
32
  * Returns array of formatted row strings (one per display row).
33
33
  */
34
- declare function formatCompactRows(sessions: DaemonSessionState[], maxWidth: number, pinnedIds?: Set<string>, mutedIds?: Set<string>, noteIds?: Set<string>): string[];
34
+ declare function formatCompactRows(sessions: DaemonSessionState[], maxWidth: number, pinnedIds?: Set<string>, mutedIds?: Set<string>, noteIds?: Set<string>, healthScores?: Map<string, number>, activityRates?: Map<string, number>): string[];
35
35
  /** Compute how many display rows compact mode needs (minimum 1). */
36
36
  declare function computeCompactRowCount(sessions: DaemonSessionState[], maxWidth: number): number;
37
+ /** Window for computing per-session activity rate (5 minutes). */
38
+ export declare const ACTIVITY_RATE_WINDOW_MS: number;
39
+ /**
40
+ * Compute messages-per-minute for a session from the activity buffer.
41
+ * Only counts entries within the last ACTIVITY_RATE_WINDOW_MS.
42
+ * Returns 0 when no activity in window.
43
+ */
44
+ export declare function computeSessionActivityRate(buffer: readonly {
45
+ sessionId?: string;
46
+ }[], timestamps: readonly number[], sessionId: string, now?: number, windowMs?: number): number;
47
+ /**
48
+ * Format activity rate as a compact badge for compact mode tokens.
49
+ * Returns empty string when rate is 0 (no clutter for quiet sessions).
50
+ * Format: "3/m" (rounded to nearest integer messages/min).
51
+ */
52
+ export declare function formatActivityRateBadge(rate: number): string;
53
+ /** Format watchdog status tag for the header (empty string when disabled). */
54
+ export declare function formatWatchdogTag(thresholdMs: number | null): string;
55
+ /** Format group filter tag for the header (empty string when no filter). */
56
+ export declare function formatGroupFilterTag(groupFilter: string | null): string;
37
57
  declare function phaseDisplay(phase: DaemonPhase, paused: boolean, spinnerFrame: number): string;
38
58
  export interface ActivityEntry {
39
59
  time: string;
@@ -74,6 +94,28 @@ export declare function formatUptime(ms: number): string;
74
94
  * Returns empty string if under threshold (< thresholdMs, default 2 min — not worth showing).
75
95
  */
76
96
  export declare function formatIdleSince(ms: number, thresholdMs?: number): string;
97
+ export interface SessionStatEntry {
98
+ title: string;
99
+ displayName?: string;
100
+ status: string;
101
+ health: number;
102
+ errors: number;
103
+ burnRatePerMin: number | null;
104
+ contextPct: number | null;
105
+ uptimeMs: number | null;
106
+ idleSinceMs: number | null;
107
+ }
108
+ /**
109
+ * Build stats entries for all sessions — pure, testable, no side effects.
110
+ */
111
+ export declare function buildSessionStats(sessions: readonly DaemonSessionState[], errorCounts: ReadonlyMap<string, number>, burnRates: ReadonlyMap<string, number | null>, firstSeen: ReadonlyMap<string, number>, lastChangeAt: ReadonlyMap<string, number>, healthScores: ReadonlyMap<string, number>, sessionAliases: ReadonlyMap<string, string>, now?: number): SessionStatEntry[];
112
+ /**
113
+ * Format session stats entries as a multi-line activity-log-friendly string.
114
+ * Each line is one session summary.
115
+ */
116
+ export declare function formatSessionStatsLines(entries: SessionStatEntry[]): string[];
117
+ /** Format session stats entries as a JSON object for export. */
118
+ export declare function formatStatsJson(entries: SessionStatEntry[], version: string, now?: number): string;
77
119
  /** Max visible length for a custom session display name. */
78
120
  export declare const MAX_RENAME_LEN = 32;
79
121
  /** Truncate a custom display name to the max length. */
@@ -251,6 +293,11 @@ export declare class TUI {
251
293
  * Pinned sessions always sort to the top. Returns true if session found.
252
294
  */
253
295
  togglePin(sessionIdOrIndex: string | number): boolean;
296
+ /**
297
+ * Pin all sessions currently in "error" status (or with any cumulative errors).
298
+ * Returns the count of newly pinned sessions.
299
+ */
300
+ pinAllErrors(): number;
254
301
  /** Check if a session ID is pinned. */
255
302
  isPinned(id: string): boolean;
256
303
  /** Return count of pinned sessions. */
@@ -304,6 +351,15 @@ export declare class TUI {
304
351
  getAllFirstSeen(): ReadonlyMap<string, number>;
305
352
  /** Return the activity buffer (for /clip export). */
306
353
  getActivityBuffer(): readonly ActivityEntry[];
354
+ /** Return the activity timestamps (epoch ms per entry, parallel to activityBuffer). */
355
+ getActivityTimestamps(): readonly number[];
356
+ /**
357
+ * Return the stored pane output lines for a session (by 1-indexed number, ID, prefix, or title).
358
+ * Returns null if session not found or no output stored.
359
+ */
360
+ getSessionOutput(sessionIdOrIndex: string | number): string[] | null;
361
+ /** Return the current drill-down session ID (for /copy default target). */
362
+ getDrilldownId(): string | null;
307
363
  /** Return per-session error counts (for /who). */
308
364
  getSessionErrorCounts(): ReadonlyMap<string, number>;
309
365
  /** Return recent error timestamps for a session (for sparkline rendering). */
@@ -315,6 +371,8 @@ export declare class TUI {
315
371
  current: number;
316
372
  max: number;
317
373
  } | null>;
374
+ /** Compute health scores for all sessions and return as a map (id → score). */
375
+ getAllHealthScores(now?: number): Map<string, number>;
318
376
  /** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
319
377
  getAllBurnRates(now?: number): Map<string, number | null>;
320
378
  /**
package/dist/tui.js CHANGED
@@ -100,10 +100,10 @@ const COMPACT_NAME_LEN = 10;
100
100
  const PIN_ICON = "▲";
101
101
  /**
102
102
  * Format sessions as inline compact tokens, wrapped to fit maxWidth.
103
- * Each token: "{idx}{pin?}{mute?}{dot}{name}" — e.g. "1▲●Alpha" for pinned, "2◌●Bravo" for muted.
103
+ * Each token: "{idx}{pin?}{mute?}{dot}{name}{health?}" — e.g. "1▲●Alpha" for pinned, "2◌●Bravo" for muted.
104
104
  * Returns array of formatted row strings (one per display row).
105
105
  */
106
- function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds) {
106
+ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, healthScores, activityRates) {
107
107
  if (sessions.length === 0)
108
108
  return [`${DIM}no agents connected${RESET}`];
109
109
  const tokens = [];
@@ -119,8 +119,17 @@ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds) {
119
119
  const muteIcon = muted ? `${DIM}${MUTE_ICON}${RESET}` : "";
120
120
  const noteIcon = noted ? `${TEAL}${NOTE_ICON}${RESET}` : "";
121
121
  const name = truncatePlain(s.title, COMPACT_NAME_LEN);
122
- tokens.push(`${SLATE}${idx}${RESET}${pin}${muteIcon}${noteIcon}${dot}${BOLD}${name}${RESET}`);
123
- widths.push(idx.length + (pinned ? 1 : 0) + (muted ? 1 : 0) + (noted ? 1 : 0) + 1 + name.length);
122
+ // health indicator: single ⬡ glyph when score < HEALTH_GOOD, colored by severity
123
+ const score = healthScores?.get(s.id);
124
+ const healthGlyph = (score !== undefined && score < HEALTH_GOOD)
125
+ ? `${score < HEALTH_WARN ? ROSE : AMBER}${HEALTH_ICON}${RESET}` : "";
126
+ const healthWidth = (score !== undefined && score < HEALTH_GOOD) ? 1 : 0;
127
+ // activity rate badge: "3/m" when rate > 0
128
+ const rate = activityRates?.get(s.id) ?? 0;
129
+ const rateBadge = formatActivityRateBadge(rate);
130
+ const rateVisible = rateBadge ? stripAnsiForLen(rateBadge) : 0;
131
+ tokens.push(`${SLATE}${idx}${RESET}${pin}${muteIcon}${noteIcon}${dot}${BOLD}${name}${RESET}${healthGlyph}${rateBadge}`);
132
+ widths.push(idx.length + (pinned ? 1 : 0) + (muted ? 1 : 0) + (noted ? 1 : 0) + 1 + name.length + healthWidth + rateVisible);
124
133
  }
125
134
  const rows = [];
126
135
  let currentRow = "";
@@ -145,6 +154,49 @@ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds) {
145
154
  function computeCompactRowCount(sessions, maxWidth) {
146
155
  return Math.max(1, formatCompactRows(sessions, maxWidth).length);
147
156
  }
157
+ // ── Activity rate helpers (pure, exported for testing) ───────────────────────
158
+ /** Window for computing per-session activity rate (5 minutes). */
159
+ export const ACTIVITY_RATE_WINDOW_MS = 5 * 60_000;
160
+ /**
161
+ * Compute messages-per-minute for a session from the activity buffer.
162
+ * Only counts entries within the last ACTIVITY_RATE_WINDOW_MS.
163
+ * Returns 0 when no activity in window.
164
+ */
165
+ export function computeSessionActivityRate(buffer, timestamps, sessionId, now, windowMs = ACTIVITY_RATE_WINDOW_MS) {
166
+ const nowMs = now ?? Date.now();
167
+ const cutoff = nowMs - windowMs;
168
+ let count = 0;
169
+ for (let i = 0; i < buffer.length; i++) {
170
+ if (buffer[i].sessionId === sessionId && (timestamps[i] ?? 0) >= cutoff)
171
+ count++;
172
+ }
173
+ return count === 0 ? 0 : (count / windowMs) * 60_000;
174
+ }
175
+ /**
176
+ * Format activity rate as a compact badge for compact mode tokens.
177
+ * Returns empty string when rate is 0 (no clutter for quiet sessions).
178
+ * Format: "3/m" (rounded to nearest integer messages/min).
179
+ */
180
+ export function formatActivityRateBadge(rate) {
181
+ if (rate <= 0)
182
+ return "";
183
+ const rounded = Math.max(1, Math.round(rate));
184
+ return `${DIM}${rounded}/m${RESET}`;
185
+ }
186
+ // ── Header status tag helpers (pure, exported for testing) ──────────────────
187
+ /** Format watchdog status tag for the header (empty string when disabled). */
188
+ export function formatWatchdogTag(thresholdMs) {
189
+ if (thresholdMs === null)
190
+ return "";
191
+ const mins = Math.round(thresholdMs / 60_000);
192
+ return `⊛${mins}m`;
193
+ }
194
+ /** Format group filter tag for the header (empty string when no filter). */
195
+ export function formatGroupFilterTag(groupFilter) {
196
+ if (!groupFilter)
197
+ return "";
198
+ return `${GROUP_ICON}${groupFilter}`;
199
+ }
148
200
  // ── Status rendering ────────────────────────────────────────────────────────
149
201
  const STATUS_DOT = {
150
202
  working: `${LIME}${DOT.filled}${RESET}`,
@@ -259,6 +311,56 @@ export function formatIdleSince(ms, thresholdMs = 2 * 60_000) {
259
311
  return "";
260
312
  return `idle ${formatUptime(ms)}`;
261
313
  }
314
+ /**
315
+ * Build stats entries for all sessions — pure, testable, no side effects.
316
+ */
317
+ export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now) {
318
+ const nowMs = now ?? Date.now();
319
+ return sessions.map((s) => {
320
+ const ceiling = parseContextCeiling(s.contextTokens);
321
+ const ctxPct = ceiling ? Math.round((ceiling.current / ceiling.max) * 100) : null;
322
+ const fs = firstSeen.get(s.id);
323
+ const lc = lastChangeAt.get(s.id);
324
+ return {
325
+ title: s.title,
326
+ displayName: sessionAliases.get(s.id),
327
+ status: s.status,
328
+ health: healthScores.get(s.id) ?? 100,
329
+ errors: errorCounts.get(s.id) ?? 0,
330
+ burnRatePerMin: burnRates.get(s.id) ?? null,
331
+ contextPct: ctxPct,
332
+ uptimeMs: fs !== undefined ? nowMs - fs : null,
333
+ idleSinceMs: lc !== undefined ? nowMs - lc : null,
334
+ };
335
+ });
336
+ }
337
+ /**
338
+ * Format session stats entries as a multi-line activity-log-friendly string.
339
+ * Each line is one session summary.
340
+ */
341
+ export function formatSessionStatsLines(entries) {
342
+ if (entries.length === 0)
343
+ return [" no sessions"];
344
+ return entries.map((e) => {
345
+ const label = e.displayName ? `${e.displayName} (${e.title})` : e.title;
346
+ const healthStr = `⬡${e.health}`;
347
+ const errStr = e.errors > 0 ? ` ${e.errors}err` : "";
348
+ const burnStr = e.burnRatePerMin !== null && e.burnRatePerMin > 0
349
+ ? ` ${Math.round(e.burnRatePerMin / 100) * 100}tok/min` : "";
350
+ const ctxStr = e.contextPct !== null ? ` ctx:${e.contextPct}%` : "";
351
+ const upStr = e.uptimeMs !== null ? ` up:${formatUptime(e.uptimeMs)}` : "";
352
+ const idleStr = e.idleSinceMs !== null ? ` ${formatIdleSince(e.idleSinceMs)}` : "";
353
+ return ` ${label} [${e.status}] ${healthStr}${errStr}${burnStr}${ctxStr}${upStr}${idleStr}`;
354
+ });
355
+ }
356
+ /** Format session stats entries as a JSON object for export. */
357
+ export function formatStatsJson(entries, version, now) {
358
+ return JSON.stringify({
359
+ version,
360
+ exportedAt: new Date(now ?? Date.now()).toISOString(),
361
+ sessions: entries,
362
+ }, null, 2) + "\n";
363
+ }
262
364
  // ── Session rename ────────────────────────────────────────────────────────────
263
365
  /** Max visible length for a custom session display name. */
264
366
  export const MAX_RENAME_LEN = 32;
@@ -281,7 +383,7 @@ export const BUILTIN_COMMANDS = new Set([
281
383
  "/pin", "/bell", "/focus", "/mute", "/unmute-all", "/filter", "/who",
282
384
  "/uptime", "/auto-pin", "/note", "/notes", "/clip", "/diff", "/mark",
283
385
  "/jump", "/marks", "/search", "/alias", "/insist", "/task", "/tasks",
284
- "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename",
386
+ "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
285
387
  ]);
286
388
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
287
389
  export function resolveAlias(line, aliases) {
@@ -660,6 +762,25 @@ export class TUI {
660
762
  }
661
763
  return true;
662
764
  }
765
+ /**
766
+ * Pin all sessions currently in "error" status (or with any cumulative errors).
767
+ * Returns the count of newly pinned sessions.
768
+ */
769
+ pinAllErrors() {
770
+ let pinned = 0;
771
+ for (const s of this.sessions) {
772
+ if ((s.status === "error" || (this.sessionErrorCounts.get(s.id) ?? 0) > 0) && !this.pinnedIds.has(s.id)) {
773
+ this.pinnedIds.add(s.id);
774
+ pinned++;
775
+ }
776
+ }
777
+ if (pinned > 0) {
778
+ this.sessions = sortSessions(this.sessions, this.sortMode, this.lastChangeAt, this.pinnedIds);
779
+ if (this.active)
780
+ this.paintSessions();
781
+ }
782
+ return pinned;
783
+ }
663
784
  /** Check if a session ID is pinned. */
664
785
  isPinned(id) {
665
786
  return this.pinnedIds.has(id);
@@ -829,6 +950,32 @@ export class TUI {
829
950
  getActivityBuffer() {
830
951
  return this.activityBuffer;
831
952
  }
953
+ /** Return the activity timestamps (epoch ms per entry, parallel to activityBuffer). */
954
+ getActivityTimestamps() {
955
+ return this.activityTimestamps;
956
+ }
957
+ /**
958
+ * Return the stored pane output lines for a session (by 1-indexed number, ID, prefix, or title).
959
+ * Returns null if session not found or no output stored.
960
+ */
961
+ getSessionOutput(sessionIdOrIndex) {
962
+ let sessionId;
963
+ if (typeof sessionIdOrIndex === "number") {
964
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
965
+ }
966
+ else {
967
+ const needle = sessionIdOrIndex.toLowerCase();
968
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
969
+ sessionId = match?.id;
970
+ }
971
+ if (!sessionId)
972
+ return null;
973
+ return this.sessionOutputs.get(sessionId) ?? null;
974
+ }
975
+ /** Return the current drill-down session ID (for /copy default target). */
976
+ getDrilldownId() {
977
+ return this.drilldownSessionId;
978
+ }
832
979
  /** Return per-session error counts (for /who). */
833
980
  getSessionErrorCounts() {
834
981
  return this.sessionErrorCounts;
@@ -849,6 +996,27 @@ export class TUI {
849
996
  }
850
997
  return result;
851
998
  }
999
+ /** Compute health scores for all sessions and return as a map (id → score). */
1000
+ getAllHealthScores(now) {
1001
+ const nowMs = now ?? Date.now();
1002
+ const result = new Map();
1003
+ for (const s of this.sessions) {
1004
+ const ceiling = parseContextCeiling(s.contextTokens);
1005
+ const cf = ceiling ? ceiling.current / ceiling.max : null;
1006
+ const bh = this.sessionContextHistory.get(s.id);
1007
+ const br = bh ? computeContextBurnRate(bh, nowMs) : null;
1008
+ const lc = this.lastChangeAt.get(s.id);
1009
+ const idle = lc !== undefined ? nowMs - lc : null;
1010
+ result.set(s.id, computeHealthScore({
1011
+ errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
1012
+ burnRatePerMin: br,
1013
+ contextFraction: cf,
1014
+ idleMs: idle,
1015
+ watchdogThresholdMs: this.watchdogThresholdMs,
1016
+ }));
1017
+ }
1018
+ return result;
1019
+ }
852
1020
  /** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
853
1021
  getAllBurnRates(now) {
854
1022
  const result = new Map();
@@ -1483,7 +1651,12 @@ export class TUI {
1483
1651
  }
1484
1652
  // reasoner badge
1485
1653
  const reasonerTag = this.reasonerName ? ` ${SLATE}│${RESET} ${TEAL}${this.reasonerName}${RESET}` : "";
1486
- line = ` ${INDIGO}${BOLD}aoaoe${RESET} ${SLATE}${this.version}${RESET} ${SLATE}│${RESET} #${this.pollCount} ${SLATE}│${RESET} ${sessCount} ${SLATE}│${RESET} ${phaseText}${activeTag}${countdownTag}${reasonerTag}`;
1654
+ // watchdog indicator show threshold when active
1655
+ const wdMin = this.watchdogThresholdMs !== null ? Math.round(this.watchdogThresholdMs / 60_000) : null;
1656
+ const watchdogTag = wdMin !== null ? ` ${SLATE}│${RESET} ${AMBER}⊛${wdMin}m${RESET}` : "";
1657
+ // group filter indicator
1658
+ const groupFilterTag = this.groupFilter ? ` ${SLATE}│${RESET} ${TEAL}${GROUP_ICON}${this.groupFilter}${RESET}` : "";
1659
+ line = ` ${INDIGO}${BOLD}aoaoe${RESET} ${SLATE}${this.version}${RESET} ${SLATE}│${RESET} #${this.pollCount} ${SLATE}│${RESET} ${sessCount} ${SLATE}│${RESET} ${phaseText}${activeTag}${countdownTag}${watchdogTag}${groupFilterTag}${reasonerTag}`;
1487
1660
  }
1488
1661
  process.stderr.write(SAVE_CURSOR +
1489
1662
  moveTo(1, 1) + CLEAR_LINE + BG_DARK + WHITE + truncateAnsi(line, this.cols) + padToWidth(line, this.cols) + RESET +
@@ -1521,9 +1694,30 @@ export class TUI {
1521
1694
  process.stderr.write(moveTo(startRow + 1, 1) + CLEAR_LINE + padded);
1522
1695
  }
1523
1696
  else if (this.compactMode) {
1524
- // compact: inline tokens, multiple per row (with pin indicators)
1697
+ // compact: inline tokens, multiple per row (with pin indicators + health glyphs)
1698
+ const nowMsCompact = Date.now();
1525
1699
  const noteIdSet = new Set(this.sessionNotes.keys());
1526
- const compactRows = formatCompactRows(visibleSessions, innerWidth - 1, this.pinnedIds, this.mutedIds, noteIdSet);
1700
+ const compactHealthScores = new Map();
1701
+ for (const s of visibleSessions) {
1702
+ const ceilingC = parseContextCeiling(s.contextTokens);
1703
+ const cfC = ceilingC ? ceilingC.current / ceilingC.max : null;
1704
+ const bhC = this.sessionContextHistory.get(s.id);
1705
+ const brC = bhC ? computeContextBurnRate(bhC, nowMsCompact) : null;
1706
+ const lcC = this.lastChangeAt.get(s.id);
1707
+ const idleC = lcC !== undefined ? nowMsCompact - lcC : null;
1708
+ compactHealthScores.set(s.id, computeHealthScore({
1709
+ errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
1710
+ burnRatePerMin: brC,
1711
+ contextFraction: cfC,
1712
+ idleMs: idleC,
1713
+ watchdogThresholdMs: this.watchdogThresholdMs,
1714
+ }));
1715
+ }
1716
+ const compactActivityRates = new Map();
1717
+ for (const s of visibleSessions) {
1718
+ compactActivityRates.set(s.id, computeSessionActivityRate(this.activityBuffer, this.activityTimestamps, s.id, nowMsCompact));
1719
+ }
1720
+ const compactRows = formatCompactRows(visibleSessions, innerWidth - 1, this.pinnedIds, this.mutedIds, noteIdSet, compactHealthScores, compactActivityRates);
1527
1721
  for (let r = 0; r < compactRows.length; r++) {
1528
1722
  const line = `${SLATE}${BOX.v}${RESET} ${compactRows[r]}`;
1529
1723
  const padded = padBoxLine(line, this.cols);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.114.0",
3
+ "version": "0.122.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",