aoaoe 0.116.0 → 0.126.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, validateSessionTag } 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";
@@ -190,17 +190,22 @@ async function main() {
190
190
  tui.restoreGroups(prefs.sessionGroups);
191
191
  if (prefs.sessionAliases)
192
192
  tui.restoreSessionAliases(prefs.sessionAliases);
193
+ if (prefs.sessionTags)
194
+ tui.restoreSessionTags(prefs.sessionTags);
193
195
  }
194
196
  const persistPrefs = () => {
195
197
  if (!tui)
196
198
  return;
197
- // persist session groups (ID → group tag) and session aliases (ID → display name)
199
+ // persist session groups, aliases, and multi-tags
198
200
  const groupsObj = {};
199
201
  for (const [id, g] of tui.getAllGroups())
200
202
  groupsObj[id] = g;
201
203
  const aliasesObj = {};
202
204
  for (const [id, name] of tui.getAllSessionAliases())
203
205
  aliasesObj[id] = name;
206
+ const sTagsObj = {};
207
+ for (const [id, tset] of tui.getAllSessionTags())
208
+ sTagsObj[id] = [...tset];
204
209
  saveTuiPrefs({
205
210
  sortMode: tui.getSortMode(),
206
211
  compact: tui.isCompact(),
@@ -211,6 +216,7 @@ async function main() {
211
216
  aliases: input.getAliases(),
212
217
  sessionGroups: groupsObj,
213
218
  sessionAliases: aliasesObj,
219
+ sessionTags: sTagsObj,
214
220
  });
215
221
  };
216
222
  if (!useTui) {
@@ -757,6 +763,123 @@ async function main() {
757
763
  if (!any)
758
764
  tui.log("system", ` threshold: ${CONTEXT_BURN_THRESHOLD.toLocaleString()} tokens/min`);
759
765
  });
766
+ // wire /mute-errors toggle
767
+ input.onMuteErrors(() => {
768
+ const muted = tui.toggleMuteErrors();
769
+ tui.log("system", `mute-errors: ${muted ? "on — error entries hidden from activity log" : "off — all entries visible"}`);
770
+ });
771
+ // wire /prev-goal
772
+ input.onPrevGoal((target, nBack) => {
773
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
774
+ const sessions = tui.getSessions();
775
+ const session = num !== undefined
776
+ ? sessions[num - 1]
777
+ : sessions.find((s) => s.title.toLowerCase() === target.toLowerCase() || s.id.startsWith(target));
778
+ if (!session) {
779
+ tui.log("system", `session not found: ${target}`);
780
+ return;
781
+ }
782
+ const goal = tui.getPreviousGoal(session.id, nBack);
783
+ if (!goal) {
784
+ tui.log("system", `no goal history for ${session.title} (${nBack} back)`);
785
+ return;
786
+ }
787
+ // queue as a task update for that session
788
+ input.inject(`__CMD_QUICKTASK__${goal}`);
789
+ tui.log("system", `prev-goal restored for ${session.title}: "${goal}"`);
790
+ });
791
+ // wire /tag set session tags
792
+ input.onTag((target, tags) => {
793
+ for (const t of tags) {
794
+ const err = validateSessionTag(t);
795
+ if (err) {
796
+ tui.log("system", `invalid tag "${t}": ${err}`);
797
+ return;
798
+ }
799
+ }
800
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
801
+ const ok = tui.setSessionTags(num ?? target, tags);
802
+ if (ok) {
803
+ if (tags.length > 0) {
804
+ tui.log("system", `tags set for ${target}: ${tags.join(", ")}`);
805
+ }
806
+ else {
807
+ tui.log("system", `tags cleared for ${target}`);
808
+ }
809
+ persistPrefs();
810
+ }
811
+ else {
812
+ tui.log("system", `session not found: ${target}`);
813
+ }
814
+ });
815
+ // wire /tags list
816
+ input.onTagsList(() => {
817
+ const allTags = tui.getAllSessionTags();
818
+ if (allTags.size === 0) {
819
+ tui.log("system", "no tags — use /tag <N|name> <tag1,tag2> to assign");
820
+ return;
821
+ }
822
+ const sessions = tui.getSessions();
823
+ for (const [id, tset] of allTags) {
824
+ const s = sessions.find((s) => s.id === id);
825
+ const label = s?.title ?? id.slice(0, 8);
826
+ tui.log("system", ` ${label}: ${[...tset].sort().join(", ")}`);
827
+ }
828
+ });
829
+ // wire /pin-all-errors
830
+ input.onPinAllErrors(() => {
831
+ const count = tui.pinAllErrors();
832
+ if (count === 0) {
833
+ tui.log("system", "pin-all-errors: no error sessions to pin");
834
+ }
835
+ else {
836
+ tui.log("system", `pin-all-errors: pinned ${count} session${count !== 1 ? "s" : ""}`);
837
+ persistPrefs();
838
+ }
839
+ });
840
+ // wire /export-stats
841
+ input.onExportStats(() => {
842
+ const sessions = tui.getSessions();
843
+ const now = Date.now();
844
+ const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
845
+ const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
846
+ const dir = join(homedir(), ".aoaoe");
847
+ const path = join(dir, `stats-${ts}.json`);
848
+ try {
849
+ mkdirSync(dir, { recursive: true });
850
+ writeFileSync(path, formatStatsJson(entries, pkg ?? "dev", now), "utf-8");
851
+ tui.log("system", `stats exported: ~/.aoaoe/stats-${ts}.json (${entries.length} sessions)`);
852
+ }
853
+ catch (err) {
854
+ tui.log("error", `export-stats failed: ${err}`);
855
+ }
856
+ });
857
+ // wire /recall — search persisted history
858
+ input.onRecall((keyword, maxResults) => {
859
+ const matches = searchHistory(keyword, maxResults);
860
+ if (matches.length === 0) {
861
+ tui.log("system", `recall: no matches for "${keyword}" in history`);
862
+ return;
863
+ }
864
+ tui.log("system", `recall: ${matches.length} match${matches.length !== 1 ? "es" : ""} for "${keyword}":`);
865
+ for (const e of matches) {
866
+ tui.log("system", ` ${e.time} ${e.tag} ${e.text}`);
867
+ }
868
+ });
869
+ // wire /stats per-session summary
870
+ input.onStats(() => {
871
+ const sessions = tui.getSessions();
872
+ if (sessions.length === 0) {
873
+ tui.log("system", "no sessions — no stats available");
874
+ return;
875
+ }
876
+ const now = Date.now();
877
+ const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
878
+ tui.log("system", `/stats — ${entries.length} session${entries.length !== 1 ? "s" : ""}:`);
879
+ for (const line of formatSessionStatsLines(entries)) {
880
+ tui.log("system", line);
881
+ }
882
+ });
760
883
  // wire /copy session pane output to clipboard
761
884
  input.onCopySession((target) => {
762
885
  // resolve target: null = current drill-down session
package/dist/input.d.ts CHANGED
@@ -34,6 +34,14 @@ export type TopHandler = (mode: string) => void;
34
34
  export type CeilingHandler = () => void;
35
35
  export type RenameHandler = (target: string, name: string) => void;
36
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;
41
+ export type MuteErrorsHandler = () => void;
42
+ export type PrevGoalHandler = (target: string, nBack: number) => void;
43
+ export type TagHandler = (target: string, tags: string[]) => void;
44
+ export type TagsListHandler = () => void;
37
45
  export interface MouseEvent {
38
46
  button: number;
39
47
  col: number;
@@ -90,6 +98,14 @@ export declare class InputReader {
90
98
  private ceilingHandler;
91
99
  private renameHandler;
92
100
  private copySessionHandler;
101
+ private statsHandler;
102
+ private recallHandler;
103
+ private pinAllErrorsHandler;
104
+ private exportStatsHandler;
105
+ private muteErrorsHandler;
106
+ private prevGoalHandler;
107
+ private tagHandler;
108
+ private tagsListHandler;
93
109
  private aliases;
94
110
  private mouseDataListener;
95
111
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -129,6 +145,14 @@ export declare class InputReader {
129
145
  onCeiling(handler: CeilingHandler): void;
130
146
  onRename(handler: RenameHandler): void;
131
147
  onCopySession(handler: CopySessionHandler): void;
148
+ onStats(handler: StatsHandler): void;
149
+ onRecall(handler: RecallHandler): void;
150
+ onPinAllErrors(handler: PinAllErrorsHandler): void;
151
+ onExportStats(handler: ExportStatsHandler): void;
152
+ onMuteErrors(handler: MuteErrorsHandler): void;
153
+ onPrevGoal(handler: PrevGoalHandler): void;
154
+ onTag(handler: TagHandler): void;
155
+ onTagsList(handler: TagsListHandler): void;
132
156
  /** Set aliases from persisted prefs. */
133
157
  setAliases(aliases: Record<string, string>): void;
134
158
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -67,6 +67,14 @@ export class InputReader {
67
67
  ceilingHandler = null;
68
68
  renameHandler = null;
69
69
  copySessionHandler = null;
70
+ statsHandler = null;
71
+ recallHandler = null;
72
+ pinAllErrorsHandler = null;
73
+ exportStatsHandler = null;
74
+ muteErrorsHandler = null;
75
+ prevGoalHandler = null;
76
+ tagHandler = null;
77
+ tagsListHandler = null;
70
78
  aliases = new Map(); // /shortcut → /full command
71
79
  mouseDataListener = null;
72
80
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -217,6 +225,38 @@ export class InputReader {
217
225
  onCopySession(handler) {
218
226
  this.copySessionHandler = handler;
219
227
  }
228
+ // register a callback for /stats — per-session stats summary
229
+ onStats(handler) {
230
+ this.statsHandler = handler;
231
+ }
232
+ // register a callback for /recall <keyword> [N] — search history
233
+ onRecall(handler) {
234
+ this.recallHandler = handler;
235
+ }
236
+ // register a callback for /pin-all-errors — pin all error sessions
237
+ onPinAllErrors(handler) {
238
+ this.pinAllErrorsHandler = handler;
239
+ }
240
+ // register a callback for /export-stats — export stats to JSON file
241
+ onExportStats(handler) {
242
+ this.exportStatsHandler = handler;
243
+ }
244
+ // register a callback for /mute-errors — toggle error-tag suppression
245
+ onMuteErrors(handler) {
246
+ this.muteErrorsHandler = handler;
247
+ }
248
+ // register a callback for /prev-goal <N|name> [nBack] — restore previous goal
249
+ onPrevGoal(handler) {
250
+ this.prevGoalHandler = handler;
251
+ }
252
+ // register a callback for /tag <N|name> [tag1,tag2] — set session tags
253
+ onTag(handler) {
254
+ this.tagHandler = handler;
255
+ }
256
+ // register a callback for /tags — list all session tags
257
+ onTagsList(handler) {
258
+ this.tagsListHandler = handler;
259
+ }
220
260
  /** Set aliases from persisted prefs. */
221
261
  setAliases(aliases) {
222
262
  this.aliases.clear();
@@ -363,7 +403,13 @@ export class InputReader {
363
403
  this.rl?.prompt();
364
404
  return;
365
405
  }
366
- // quick-switch: bare digit 1-9 jumps to that session
406
+ // quick-switch: bare digit 1-9, or g+N for sessions 1-99
407
+ const gSwitch = line.match(/^g([1-9]\d?)$/);
408
+ if (gSwitch && this.quickSwitchHandler) {
409
+ this.quickSwitchHandler(parseInt(gSwitch[1], 10));
410
+ this.rl?.prompt();
411
+ return;
412
+ }
367
413
  if (/^[1-9]$/.test(line) && this.quickSwitchHandler) {
368
414
  this.quickSwitchHandler(parseInt(line, 10));
369
415
  this.rl?.prompt();
@@ -428,6 +474,7 @@ ${BOLD}controls:${RESET}
428
474
 
429
475
  ${BOLD}navigation:${RESET}
430
476
  1-9 quick-switch: jump to session N (type digit + Enter)
477
+ g1-g99 quick-switch for sessions 10+ (e.g. g12 jumps to session 12)
431
478
  /view [N|name] drill into a session's live output (default: 1)
432
479
  /back return to overview from drill-down
433
480
  /sort [mode] sort sessions: status, name, activity, default (or cycle)
@@ -454,6 +501,14 @@ ${BOLD}navigation:${RESET}
454
501
  /ceiling show context token usage vs limit for all sessions
455
502
  /rename N|name [display] set custom display name in TUI (no display = clear)
456
503
  /copy [N|name] copy session's current pane output to clipboard (default: current drill-down)
504
+ /stats show per-session health, errors, burn rate, context %, uptime
505
+ /recall <keyword> search persisted activity history (last 7 days) for keyword
506
+ /pin-all-errors pin every session currently in error status
507
+ /export-stats export /stats output as JSON to ~/.aoaoe/stats-<ts>.json
508
+ /mute-errors toggle suppression of error/! action entries in activity log
509
+ /prev-goal N [n] restore Nth session's goal from history (n=1 most recent)
510
+ /tag N tag1,tag2 set freeform tags on a session (no tags = clear)
511
+ /tags list all session tags
457
512
  /clip [N] copy last N activity entries to clipboard (default 20)
458
513
  /diff N show activity since bookmark N
459
514
  /mark bookmark current activity position
@@ -843,6 +898,103 @@ ${BOLD}other:${RESET}
843
898
  }
844
899
  break;
845
900
  }
901
+ case "/mute-errors":
902
+ if (this.muteErrorsHandler) {
903
+ this.muteErrorsHandler();
904
+ }
905
+ else {
906
+ console.error(`${DIM}mute-errors not available (no TUI)${RESET}`);
907
+ }
908
+ break;
909
+ case "/prev-goal": {
910
+ const pgArgs = line.slice("/prev-goal".length).trim().split(/\s+/);
911
+ const pgTarget = pgArgs[0] ?? "";
912
+ const pgN = pgArgs[1] ? parseInt(pgArgs[1], 10) : 1;
913
+ if (!pgTarget) {
914
+ console.error(`${DIM}usage: /prev-goal <N|name> [n] — restore nth most-recent goal (default 1)${RESET}`);
915
+ break;
916
+ }
917
+ if (this.prevGoalHandler) {
918
+ this.prevGoalHandler(pgTarget, isNaN(pgN) || pgN < 1 ? 1 : pgN);
919
+ }
920
+ else {
921
+ console.error(`${DIM}prev-goal not available (no TUI)${RESET}`);
922
+ }
923
+ break;
924
+ }
925
+ case "/tag": {
926
+ const tagArgs = line.slice("/tag".length).trim();
927
+ if (!tagArgs) {
928
+ console.error(`${DIM}usage: /tag <N|name> [tag1,tag2,...] — set tags (no tags = clear)${RESET}`);
929
+ break;
930
+ }
931
+ if (this.tagHandler) {
932
+ const spaceIdx = tagArgs.indexOf(" ");
933
+ if (spaceIdx > 0) {
934
+ const target = tagArgs.slice(0, spaceIdx);
935
+ const rawTags = tagArgs.slice(spaceIdx + 1).trim();
936
+ const tags = rawTags ? rawTags.split(",").map((t) => t.trim()).filter(Boolean) : [];
937
+ this.tagHandler(target, tags);
938
+ }
939
+ else {
940
+ // target only — clear tags
941
+ this.tagHandler(tagArgs, []);
942
+ }
943
+ }
944
+ else {
945
+ console.error(`${DIM}tag not available (no TUI)${RESET}`);
946
+ }
947
+ break;
948
+ }
949
+ case "/tags":
950
+ if (this.tagsListHandler) {
951
+ this.tagsListHandler();
952
+ }
953
+ else {
954
+ console.error(`${DIM}tags not available (no TUI)${RESET}`);
955
+ }
956
+ break;
957
+ case "/pin-all-errors":
958
+ if (this.pinAllErrorsHandler) {
959
+ this.pinAllErrorsHandler();
960
+ }
961
+ else {
962
+ console.error(`${DIM}pin-all-errors not available (no TUI)${RESET}`);
963
+ }
964
+ break;
965
+ case "/export-stats":
966
+ if (this.exportStatsHandler) {
967
+ this.exportStatsHandler();
968
+ }
969
+ else {
970
+ console.error(`${DIM}export-stats not available (no TUI)${RESET}`);
971
+ }
972
+ break;
973
+ case "/recall": {
974
+ const recallArgs = line.slice("/recall".length).trim().split(/\s+/);
975
+ const keyword = recallArgs[0] ?? "";
976
+ if (!keyword) {
977
+ console.error(`${DIM}usage: /recall <keyword> [N] — search activity history (default: last 50 matches)${RESET}`);
978
+ break;
979
+ }
980
+ const maxN = recallArgs[1] ? parseInt(recallArgs[1], 10) : 50;
981
+ const limit = isNaN(maxN) || maxN < 1 ? 50 : Math.min(maxN, 500);
982
+ if (this.recallHandler) {
983
+ this.recallHandler(keyword, limit);
984
+ }
985
+ else {
986
+ console.error(`${DIM}recall not available (no TUI)${RESET}`);
987
+ }
988
+ break;
989
+ }
990
+ case "/stats":
991
+ if (this.statsHandler) {
992
+ this.statsHandler();
993
+ }
994
+ else {
995
+ console.error(`${DIM}stats not available (no TUI)${RESET}`);
996
+ }
997
+ break;
846
998
  case "/copy": {
847
999
  const copyArg = line.slice("/copy".length).trim() || null;
848
1000
  if (this.copySessionHandler) {
@@ -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
@@ -31,9 +31,29 @@ declare const PIN_ICON = "\u25B2";
31
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>, healthScores?: Map<string, number>): 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,45 @@ 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
+ /**
98
+ * Check if an activity entry's tag matches any tag in the suppressed set.
99
+ * Handles pipe-separated patterns (same logic as matchesTagFilter).
100
+ */
101
+ export declare function isSuppressedEntry(entry: ActivityEntry, suppressedTags: ReadonlySet<string>): boolean;
102
+ /** Default error-suppression pattern matching "! action" and "error" tags. */
103
+ export declare const MUTE_ERRORS_PATTERN = "error|! action";
104
+ /** Max previous goals stored per session. */
105
+ export declare const MAX_GOAL_HISTORY = 5;
106
+ /** Max tags per session. */
107
+ export declare const MAX_SESSION_TAGS = 10;
108
+ /** Max length of a single session tag. */
109
+ export declare const MAX_SESSION_TAG_LEN = 20;
110
+ /** Validate a session tag name. Returns null if valid, error string if not. */
111
+ export declare function validateSessionTag(tag: string): string | null;
112
+ /** Format a session tags badge for display in cards. Returns empty string if no tags. */
113
+ export declare function formatSessionTagsBadge(tags: ReadonlySet<string>): string;
114
+ export interface SessionStatEntry {
115
+ title: string;
116
+ displayName?: string;
117
+ status: string;
118
+ health: number;
119
+ errors: number;
120
+ burnRatePerMin: number | null;
121
+ contextPct: number | null;
122
+ uptimeMs: number | null;
123
+ idleSinceMs: number | null;
124
+ }
125
+ /**
126
+ * Build stats entries for all sessions — pure, testable, no side effects.
127
+ */
128
+ 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[];
129
+ /**
130
+ * Format session stats entries as a multi-line activity-log-friendly string.
131
+ * Each line is one session summary.
132
+ */
133
+ export declare function formatSessionStatsLines(entries: SessionStatEntry[]): string[];
134
+ /** Format session stats entries as a JSON object for export. */
135
+ export declare function formatStatsJson(entries: SessionStatEntry[], version: string, now?: number): string;
77
136
  /** Max visible length for a custom session display name. */
78
137
  export declare const MAX_RENAME_LEN = 32;
79
138
  /** Truncate a custom display name to the max length. */
@@ -171,6 +230,7 @@ export interface TuiPrefs {
171
230
  aliases?: Record<string, string>;
172
231
  sessionGroups?: Record<string, string>;
173
232
  sessionAliases?: Record<string, string>;
233
+ sessionTags?: Record<string, string[]>;
174
234
  }
175
235
  /** Load persisted TUI preferences. Returns empty object on any error. */
176
236
  export declare function loadTuiPrefs(): TuiPrefs;
@@ -221,6 +281,9 @@ export declare class TUI {
221
281
  private sessionAliases;
222
282
  private watchdogThresholdMs;
223
283
  private watchdogAlerted;
284
+ private suppressedTags;
285
+ private sessionGoalHistory;
286
+ private sessionTags;
224
287
  private viewMode;
225
288
  private drilldownSessionId;
226
289
  private sessionOutputs;
@@ -251,6 +314,11 @@ export declare class TUI {
251
314
  * Pinned sessions always sort to the top. Returns true if session found.
252
315
  */
253
316
  togglePin(sessionIdOrIndex: string | number): boolean;
317
+ /**
318
+ * Pin all sessions currently in "error" status (or with any cumulative errors).
319
+ * Returns the count of newly pinned sessions.
320
+ */
321
+ pinAllErrors(): number;
254
322
  /** Check if a session ID is pinned. */
255
323
  isPinned(id: string): boolean;
256
324
  /** Return count of pinned sessions. */
@@ -304,6 +372,33 @@ export declare class TUI {
304
372
  getAllFirstSeen(): ReadonlyMap<string, number>;
305
373
  /** Return the activity buffer (for /clip export). */
306
374
  getActivityBuffer(): readonly ActivityEntry[];
375
+ /** Toggle suppression of error-tagged entries ("! action" and "error"). */
376
+ toggleMuteErrors(): boolean;
377
+ /** Return whether error tags are currently suppressed. */
378
+ isErrorsMuted(): boolean;
379
+ /** Return the full suppressed-tags set (readonly). */
380
+ getSuppressedTags(): ReadonlySet<string>;
381
+ /** Record a goal for a session (push to front of history, cap at MAX_GOAL_HISTORY). */
382
+ pushGoalHistory(sessionId: string, goal: string): void;
383
+ /** Get goal history for a session (oldest first, most recent last). */
384
+ getGoalHistory(sessionId: string): readonly string[];
385
+ /** Restore a previous goal (1 = most recent, 2 = two back, etc.). Returns the goal string or null. */
386
+ getPreviousGoal(sessionId: string, nBack?: number): string | null;
387
+ /**
388
+ * Set tags for a session (replaces existing). Pass empty array to clear.
389
+ * Returns true if session found.
390
+ */
391
+ setSessionTags(sessionIdOrIndex: string | number, tags: string[]): boolean;
392
+ /** Get tags for a session ID (empty set if none). */
393
+ getSessionTags(id: string): ReadonlySet<string>;
394
+ /** Return all session tags (id → tag set). */
395
+ getAllSessionTags(): ReadonlyMap<string, ReadonlySet<string>>;
396
+ /** Return sessions that have a given tag. */
397
+ getSessionsWithTag(tag: string): DaemonSessionState[];
398
+ /** Restore session tags from persisted prefs. */
399
+ restoreSessionTags(tags: Record<string, string[]>): void;
400
+ /** Return the activity timestamps (epoch ms per entry, parallel to activityBuffer). */
401
+ getActivityTimestamps(): readonly number[];
307
402
  /**
308
403
  * Return the stored pane output lines for a session (by 1-indexed number, ID, prefix, or title).
309
404
  * Returns null if session not found or no output stored.
@@ -322,6 +417,8 @@ export declare class TUI {
322
417
  current: number;
323
418
  max: number;
324
419
  } | null>;
420
+ /** Compute health scores for all sessions and return as a map (id → score). */
421
+ getAllHealthScores(now?: number): Map<string, number>;
325
422
  /** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
326
423
  getAllBurnRates(now?: number): Map<string, number | null>;
327
424
  /**
@@ -385,6 +482,8 @@ export declare class TUI {
385
482
  }): void;
386
483
  log(tag: string, text: string, sessionId?: string): void;
387
484
  replayHistory(entries: HistoryEntry[]): void;
485
+ /** Apply all active display filters to an entry array: mute, suppress, tag, search. */
486
+ private applyDisplayFilters;
388
487
  scrollUp(lines?: number): void;
389
488
  scrollDown(lines?: number): void;
390
489
  scrollToTop(): void;
package/dist/tui.js CHANGED
@@ -103,7 +103,7 @@ const PIN_ICON = "▲";
103
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, healthScores) {
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 = [];
@@ -124,8 +124,12 @@ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, hea
124
124
  const healthGlyph = (score !== undefined && score < HEALTH_GOOD)
125
125
  ? `${score < HEALTH_WARN ? ROSE : AMBER}${HEALTH_ICON}${RESET}` : "";
126
126
  const healthWidth = (score !== undefined && score < HEALTH_GOOD) ? 1 : 0;
127
- tokens.push(`${SLATE}${idx}${RESET}${pin}${muteIcon}${noteIcon}${dot}${BOLD}${name}${RESET}${healthGlyph}`);
128
- widths.push(idx.length + (pinned ? 1 : 0) + (muted ? 1 : 0) + (noted ? 1 : 0) + 1 + name.length + healthWidth);
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);
129
133
  }
130
134
  const rows = [];
131
135
  let currentRow = "";
@@ -150,6 +154,49 @@ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, hea
150
154
  function computeCompactRowCount(sessions, maxWidth) {
151
155
  return Math.max(1, formatCompactRows(sessions, maxWidth).length);
152
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
+ }
153
200
  // ── Status rendering ────────────────────────────────────────────────────────
154
201
  const STATUS_DOT = {
155
202
  working: `${LIME}${DOT.filled}${RESET}`,
@@ -264,6 +311,97 @@ export function formatIdleSince(ms, thresholdMs = 2 * 60_000) {
264
311
  return "";
265
312
  return `idle ${formatUptime(ms)}`;
266
313
  }
314
+ // ── Suppressed tags (mute-errors style) ──────────────────────────────────────
315
+ /**
316
+ * Check if an activity entry's tag matches any tag in the suppressed set.
317
+ * Handles pipe-separated patterns (same logic as matchesTagFilter).
318
+ */
319
+ export function isSuppressedEntry(entry, suppressedTags) {
320
+ if (suppressedTags.size === 0)
321
+ return false;
322
+ for (const pattern of suppressedTags) {
323
+ if (matchesTagFilter(entry, pattern))
324
+ return true;
325
+ }
326
+ return false;
327
+ }
328
+ /** Default error-suppression pattern matching "! action" and "error" tags. */
329
+ export const MUTE_ERRORS_PATTERN = "error|! action";
330
+ // ── Per-session goal history ──────────────────────────────────────────────────
331
+ /** Max previous goals stored per session. */
332
+ export const MAX_GOAL_HISTORY = 5;
333
+ // ── Session multi-tags ────────────────────────────────────────────────────────
334
+ /** Max tags per session. */
335
+ export const MAX_SESSION_TAGS = 10;
336
+ /** Max length of a single session tag. */
337
+ export const MAX_SESSION_TAG_LEN = 20;
338
+ /** Validate a session tag name. Returns null if valid, error string if not. */
339
+ export function validateSessionTag(tag) {
340
+ if (!tag || tag.trim().length === 0)
341
+ return "tag cannot be empty";
342
+ const t = tag.trim();
343
+ if (t.length > MAX_SESSION_TAG_LEN)
344
+ return `tag too long (max ${MAX_SESSION_TAG_LEN})`;
345
+ if (!/^[a-z0-9_-]+$/i.test(t))
346
+ return "tag must be alphanumeric (a-z, 0-9, - _)";
347
+ return null;
348
+ }
349
+ /** Format a session tags badge for display in cards. Returns empty string if no tags. */
350
+ export function formatSessionTagsBadge(tags) {
351
+ if (tags.size === 0)
352
+ return "";
353
+ return `${DIM}[${[...tags].sort().join(",")}]${RESET}`;
354
+ }
355
+ /**
356
+ * Build stats entries for all sessions — pure, testable, no side effects.
357
+ */
358
+ export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now) {
359
+ const nowMs = now ?? Date.now();
360
+ return sessions.map((s) => {
361
+ const ceiling = parseContextCeiling(s.contextTokens);
362
+ const ctxPct = ceiling ? Math.round((ceiling.current / ceiling.max) * 100) : null;
363
+ const fs = firstSeen.get(s.id);
364
+ const lc = lastChangeAt.get(s.id);
365
+ return {
366
+ title: s.title,
367
+ displayName: sessionAliases.get(s.id),
368
+ status: s.status,
369
+ health: healthScores.get(s.id) ?? 100,
370
+ errors: errorCounts.get(s.id) ?? 0,
371
+ burnRatePerMin: burnRates.get(s.id) ?? null,
372
+ contextPct: ctxPct,
373
+ uptimeMs: fs !== undefined ? nowMs - fs : null,
374
+ idleSinceMs: lc !== undefined ? nowMs - lc : null,
375
+ };
376
+ });
377
+ }
378
+ /**
379
+ * Format session stats entries as a multi-line activity-log-friendly string.
380
+ * Each line is one session summary.
381
+ */
382
+ export function formatSessionStatsLines(entries) {
383
+ if (entries.length === 0)
384
+ return [" no sessions"];
385
+ return entries.map((e) => {
386
+ const label = e.displayName ? `${e.displayName} (${e.title})` : e.title;
387
+ const healthStr = `⬡${e.health}`;
388
+ const errStr = e.errors > 0 ? ` ${e.errors}err` : "";
389
+ const burnStr = e.burnRatePerMin !== null && e.burnRatePerMin > 0
390
+ ? ` ${Math.round(e.burnRatePerMin / 100) * 100}tok/min` : "";
391
+ const ctxStr = e.contextPct !== null ? ` ctx:${e.contextPct}%` : "";
392
+ const upStr = e.uptimeMs !== null ? ` up:${formatUptime(e.uptimeMs)}` : "";
393
+ const idleStr = e.idleSinceMs !== null ? ` ${formatIdleSince(e.idleSinceMs)}` : "";
394
+ return ` ${label} [${e.status}] ${healthStr}${errStr}${burnStr}${ctxStr}${upStr}${idleStr}`;
395
+ });
396
+ }
397
+ /** Format session stats entries as a JSON object for export. */
398
+ export function formatStatsJson(entries, version, now) {
399
+ return JSON.stringify({
400
+ version,
401
+ exportedAt: new Date(now ?? Date.now()).toISOString(),
402
+ sessions: entries,
403
+ }, null, 2) + "\n";
404
+ }
267
405
  // ── Session rename ────────────────────────────────────────────────────────────
268
406
  /** Max visible length for a custom session display name. */
269
407
  export const MAX_RENAME_LEN = 32;
@@ -286,7 +424,8 @@ export const BUILTIN_COMMANDS = new Set([
286
424
  "/pin", "/bell", "/focus", "/mute", "/unmute-all", "/filter", "/who",
287
425
  "/uptime", "/auto-pin", "/note", "/notes", "/clip", "/diff", "/mark",
288
426
  "/jump", "/marks", "/search", "/alias", "/insist", "/task", "/tasks",
289
- "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy",
427
+ "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
428
+ "/mute-errors", "/prev-goal", "/tag", "/tags",
290
429
  ]);
291
430
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
292
431
  export function resolveAlias(line, aliases) {
@@ -551,6 +690,9 @@ export class TUI {
551
690
  sessionAliases = new Map(); // session ID → custom display name
552
691
  watchdogThresholdMs = null; // null = disabled; ms of inactivity before alert
553
692
  watchdogAlerted = new Map(); // session ID → epoch ms of last watchdog alert
693
+ suppressedTags = new Set(); // activity tags excluded from display (/mute-errors)
694
+ sessionGoalHistory = new Map(); // session ID → last N goals (newest last)
695
+ sessionTags = new Map(); // session ID → freeform tag set
554
696
  // drill-down mode: show a single session's full output
555
697
  viewMode = "overview";
556
698
  drilldownSessionId = null;
@@ -665,6 +807,25 @@ export class TUI {
665
807
  }
666
808
  return true;
667
809
  }
810
+ /**
811
+ * Pin all sessions currently in "error" status (or with any cumulative errors).
812
+ * Returns the count of newly pinned sessions.
813
+ */
814
+ pinAllErrors() {
815
+ let pinned = 0;
816
+ for (const s of this.sessions) {
817
+ if ((s.status === "error" || (this.sessionErrorCounts.get(s.id) ?? 0) > 0) && !this.pinnedIds.has(s.id)) {
818
+ this.pinnedIds.add(s.id);
819
+ pinned++;
820
+ }
821
+ }
822
+ if (pinned > 0) {
823
+ this.sessions = sortSessions(this.sessions, this.sortMode, this.lastChangeAt, this.pinnedIds);
824
+ if (this.active)
825
+ this.paintSessions();
826
+ }
827
+ return pinned;
828
+ }
668
829
  /** Check if a session ID is pinned. */
669
830
  isPinned(id) {
670
831
  return this.pinnedIds.has(id);
@@ -834,6 +995,105 @@ export class TUI {
834
995
  getActivityBuffer() {
835
996
  return this.activityBuffer;
836
997
  }
998
+ // ── Suppressed tags ─────────────────────────────────────────────────────
999
+ /** Toggle suppression of error-tagged entries ("! action" and "error"). */
1000
+ toggleMuteErrors() {
1001
+ if (this.suppressedTags.has(MUTE_ERRORS_PATTERN)) {
1002
+ this.suppressedTags.delete(MUTE_ERRORS_PATTERN);
1003
+ if (this.active)
1004
+ this.repaintActivityRegion();
1005
+ return false; // now unmuted
1006
+ }
1007
+ this.suppressedTags.add(MUTE_ERRORS_PATTERN);
1008
+ if (this.active)
1009
+ this.repaintActivityRegion();
1010
+ return true; // now muted
1011
+ }
1012
+ /** Return whether error tags are currently suppressed. */
1013
+ isErrorsMuted() {
1014
+ return this.suppressedTags.has(MUTE_ERRORS_PATTERN);
1015
+ }
1016
+ /** Return the full suppressed-tags set (readonly). */
1017
+ getSuppressedTags() {
1018
+ return this.suppressedTags;
1019
+ }
1020
+ // ── Per-session goal history ─────────────────────────────────────────────
1021
+ /** Record a goal for a session (push to front of history, cap at MAX_GOAL_HISTORY). */
1022
+ pushGoalHistory(sessionId, goal) {
1023
+ if (!goal || !goal.trim())
1024
+ return;
1025
+ const hist = this.sessionGoalHistory.get(sessionId) ?? [];
1026
+ // avoid duplicating the same consecutive goal
1027
+ if (hist.length > 0 && hist[hist.length - 1] === goal.trim())
1028
+ return;
1029
+ hist.push(goal.trim());
1030
+ if (hist.length > MAX_GOAL_HISTORY)
1031
+ hist.shift();
1032
+ this.sessionGoalHistory.set(sessionId, hist);
1033
+ }
1034
+ /** Get goal history for a session (oldest first, most recent last). */
1035
+ getGoalHistory(sessionId) {
1036
+ return this.sessionGoalHistory.get(sessionId) ?? [];
1037
+ }
1038
+ /** Restore a previous goal (1 = most recent, 2 = two back, etc.). Returns the goal string or null. */
1039
+ getPreviousGoal(sessionId, nBack = 1) {
1040
+ const hist = this.sessionGoalHistory.get(sessionId) ?? [];
1041
+ const idx = hist.length - nBack;
1042
+ return idx >= 0 ? hist[idx] : null;
1043
+ }
1044
+ // ── Session multi-tags ───────────────────────────────────────────────────
1045
+ /**
1046
+ * Set tags for a session (replaces existing). Pass empty array to clear.
1047
+ * Returns true if session found.
1048
+ */
1049
+ setSessionTags(sessionIdOrIndex, tags) {
1050
+ let sessionId;
1051
+ if (typeof sessionIdOrIndex === "number") {
1052
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1053
+ }
1054
+ else {
1055
+ const needle = sessionIdOrIndex.toLowerCase();
1056
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1057
+ sessionId = match?.id;
1058
+ }
1059
+ if (!sessionId)
1060
+ return false;
1061
+ if (tags.length === 0) {
1062
+ this.sessionTags.delete(sessionId);
1063
+ }
1064
+ else {
1065
+ const tagSet = new Set(tags.map((t) => t.trim().toLowerCase()).filter(Boolean));
1066
+ this.sessionTags.set(sessionId, tagSet);
1067
+ }
1068
+ if (this.active)
1069
+ this.paintSessions();
1070
+ return true;
1071
+ }
1072
+ /** Get tags for a session ID (empty set if none). */
1073
+ getSessionTags(id) {
1074
+ return this.sessionTags.get(id) ?? new Set();
1075
+ }
1076
+ /** Return all session tags (id → tag set). */
1077
+ getAllSessionTags() {
1078
+ return this.sessionTags;
1079
+ }
1080
+ /** Return sessions that have a given tag. */
1081
+ getSessionsWithTag(tag) {
1082
+ const lower = tag.toLowerCase();
1083
+ return this.sessions.filter((s) => this.sessionTags.get(s.id)?.has(lower));
1084
+ }
1085
+ /** Restore session tags from persisted prefs. */
1086
+ restoreSessionTags(tags) {
1087
+ this.sessionTags.clear();
1088
+ for (const [id, arr] of Object.entries(tags)) {
1089
+ if (arr.length > 0)
1090
+ this.sessionTags.set(id, new Set(arr));
1091
+ }
1092
+ }
1093
+ /** Return the activity timestamps (epoch ms per entry, parallel to activityBuffer). */
1094
+ getActivityTimestamps() {
1095
+ return this.activityTimestamps;
1096
+ }
837
1097
  /**
838
1098
  * Return the stored pane output lines for a session (by 1-indexed number, ID, prefix, or title).
839
1099
  * Returns null if session not found or no output stored.
@@ -876,6 +1136,27 @@ export class TUI {
876
1136
  }
877
1137
  return result;
878
1138
  }
1139
+ /** Compute health scores for all sessions and return as a map (id → score). */
1140
+ getAllHealthScores(now) {
1141
+ const nowMs = now ?? Date.now();
1142
+ const result = new Map();
1143
+ for (const s of this.sessions) {
1144
+ const ceiling = parseContextCeiling(s.contextTokens);
1145
+ const cf = ceiling ? ceiling.current / ceiling.max : null;
1146
+ const bh = this.sessionContextHistory.get(s.id);
1147
+ const br = bh ? computeContextBurnRate(bh, nowMs) : null;
1148
+ const lc = this.lastChangeAt.get(s.id);
1149
+ const idle = lc !== undefined ? nowMs - lc : null;
1150
+ result.set(s.id, computeHealthScore({
1151
+ errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
1152
+ burnRatePerMin: br,
1153
+ contextFraction: cf,
1154
+ idleMs: idle,
1155
+ watchdogThresholdMs: this.watchdogThresholdMs,
1156
+ }));
1157
+ }
1158
+ return result;
1159
+ }
879
1160
  /** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
880
1161
  getAllBurnRates(now) {
881
1162
  const result = new Map();
@@ -1186,6 +1467,9 @@ export class TUI {
1186
1467
  if (shouldMuteEntry(entry, this.mutedIds)) {
1187
1468
  // silently skip display — entry is in buffer for scroll-back if unmuted later
1188
1469
  }
1470
+ else if (isSuppressedEntry(entry, this.suppressedTags)) {
1471
+ // suppressed tag (e.g. /mute-errors) — silently buffered, hidden from display
1472
+ }
1189
1473
  else if (this.filterTag && !matchesTagFilter(entry, this.filterTag)) {
1190
1474
  // tag filter active: silently skip non-matching entries
1191
1475
  }
@@ -1225,20 +1509,26 @@ export class TUI {
1225
1509
  this.activityBuffer = this.activityBuffer.slice(-this.maxActivity);
1226
1510
  }
1227
1511
  }
1512
+ /** Apply all active display filters to an entry array: mute, suppress, tag, search. */
1513
+ applyDisplayFilters(entries) {
1514
+ let out = entries;
1515
+ if (this.mutedIds.size > 0)
1516
+ out = out.filter((e) => !shouldMuteEntry(e, this.mutedIds));
1517
+ if (this.suppressedTags.size > 0)
1518
+ out = out.filter((e) => !isSuppressedEntry(e, this.suppressedTags));
1519
+ if (this.filterTag)
1520
+ out = out.filter((e) => matchesTagFilter(e, this.filterTag));
1521
+ if (this.searchPattern)
1522
+ out = out.filter((e) => matchesSearch(e, this.searchPattern));
1523
+ return out;
1524
+ }
1228
1525
  // ── Scroll navigation ────────────────────────────────────────────────────
1229
1526
  scrollUp(lines) {
1230
1527
  if (!this.active)
1231
1528
  return;
1232
1529
  const visibleLines = this.scrollBottom - this.scrollTop + 1;
1233
1530
  const n = lines ?? Math.max(1, Math.floor(visibleLines / 2));
1234
- let filtered = this.mutedIds.size > 0
1235
- ? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
1236
- : this.activityBuffer;
1237
- if (this.filterTag)
1238
- filtered = filtered.filter((e) => matchesTagFilter(e, this.filterTag));
1239
- const entryCount = this.searchPattern
1240
- ? filtered.filter((e) => matchesSearch(e, this.searchPattern)).length
1241
- : filtered.length;
1531
+ const entryCount = this.applyDisplayFilters(this.activityBuffer).length;
1242
1532
  const maxOffset = Math.max(0, entryCount - visibleLines);
1243
1533
  this.scrollOffset = Math.min(maxOffset, this.scrollOffset + n);
1244
1534
  this.repaintActivityRegion();
@@ -1260,14 +1550,7 @@ export class TUI {
1260
1550
  if (!this.active)
1261
1551
  return;
1262
1552
  const visibleLines = this.scrollBottom - this.scrollTop + 1;
1263
- let filtered = this.mutedIds.size > 0
1264
- ? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
1265
- : this.activityBuffer;
1266
- if (this.filterTag)
1267
- filtered = filtered.filter((e) => matchesTagFilter(e, this.filterTag));
1268
- const entryCount = this.searchPattern
1269
- ? filtered.filter((e) => matchesSearch(e, this.searchPattern)).length
1270
- : filtered.length;
1553
+ const entryCount = this.applyDisplayFilters(this.activityBuffer).length;
1271
1554
  this.scrollOffset = Math.max(0, entryCount - visibleLines);
1272
1555
  this.repaintActivityRegion();
1273
1556
  this.paintSeparator();
@@ -1510,7 +1793,12 @@ export class TUI {
1510
1793
  }
1511
1794
  // reasoner badge
1512
1795
  const reasonerTag = this.reasonerName ? ` ${SLATE}│${RESET} ${TEAL}${this.reasonerName}${RESET}` : "";
1513
- line = ` ${INDIGO}${BOLD}aoaoe${RESET} ${SLATE}${this.version}${RESET} ${SLATE}│${RESET} #${this.pollCount} ${SLATE}│${RESET} ${sessCount} ${SLATE}│${RESET} ${phaseText}${activeTag}${countdownTag}${reasonerTag}`;
1796
+ // watchdog indicator show threshold when active
1797
+ const wdMin = this.watchdogThresholdMs !== null ? Math.round(this.watchdogThresholdMs / 60_000) : null;
1798
+ const watchdogTag = wdMin !== null ? ` ${SLATE}│${RESET} ${AMBER}⊛${wdMin}m${RESET}` : "";
1799
+ // group filter indicator
1800
+ const groupFilterTag = this.groupFilter ? ` ${SLATE}│${RESET} ${TEAL}${GROUP_ICON}${this.groupFilter}${RESET}` : "";
1801
+ line = ` ${INDIGO}${BOLD}aoaoe${RESET} ${SLATE}${this.version}${RESET} ${SLATE}│${RESET} #${this.pollCount} ${SLATE}│${RESET} ${sessCount} ${SLATE}│${RESET} ${phaseText}${activeTag}${countdownTag}${watchdogTag}${groupFilterTag}${reasonerTag}`;
1514
1802
  }
1515
1803
  process.stderr.write(SAVE_CURSOR +
1516
1804
  moveTo(1, 1) + CLEAR_LINE + BG_DARK + WHITE + truncateAnsi(line, this.cols) + padToWidth(line, this.cols) + RESET +
@@ -1567,7 +1855,11 @@ export class TUI {
1567
1855
  watchdogThresholdMs: this.watchdogThresholdMs,
1568
1856
  }));
1569
1857
  }
1570
- const compactRows = formatCompactRows(visibleSessions, innerWidth - 1, this.pinnedIds, this.mutedIds, noteIdSet, compactHealthScores);
1858
+ const compactActivityRates = new Map();
1859
+ for (const s of visibleSessions) {
1860
+ compactActivityRates.set(s.id, computeSessionActivityRate(this.activityBuffer, this.activityTimestamps, s.id, nowMsCompact));
1861
+ }
1862
+ const compactRows = formatCompactRows(visibleSessions, innerWidth - 1, this.pinnedIds, this.mutedIds, noteIdSet, compactHealthScores, compactActivityRates);
1571
1863
  for (let r = 0; r < compactRows.length; r++) {
1572
1864
  const line = `${SLATE}${BOX.v}${RESET} ${compactRows[r]}`;
1573
1865
  const padded = padBoxLine(line, this.cols);
@@ -1602,6 +1894,9 @@ export class TUI {
1602
1894
  });
1603
1895
  const healthBadge = formatHealthBadge(healthScore);
1604
1896
  const displayName = this.sessionAliases.get(s.id);
1897
+ const sTags = this.sessionTags.get(s.id);
1898
+ const tagsBadge = sTags && sTags.size > 0 ? `${formatSessionTagsBadge(sTags)} ` : "";
1899
+ const tagsBadgeWidth = sTags && sTags.size > 0 ? stripAnsiForLen(formatSessionTagsBadge(sTags)) + 1 : 0;
1605
1900
  const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
1606
1901
  const muteBadgeWidth = muted ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 2 : 0; // "(N)" visible chars, 0 when count is 0
1607
1902
  const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0 ? muteBadgeWidth + 1 : 0; // +1 for trailing space
@@ -1611,9 +1906,9 @@ export class TUI {
1611
1906
  const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
1612
1907
  const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
1613
1908
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
1614
- const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth;
1909
+ const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth;
1615
1910
  const cardWidth = innerWidth - 1 - iconsWidth;
1616
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
1911
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
1617
1912
  const padded = padBoxLineHover(line, this.cols, isHovered);
1618
1913
  process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
1619
1914
  }
@@ -1684,13 +1979,14 @@ export class TUI {
1684
1979
  paintSeparator() {
1685
1980
  const prefix = `${BOX.h}${BOX.h} activity `;
1686
1981
  let hints;
1982
+ // suppressed-errors indicator when active (shown before other filters)
1983
+ const suppressedSuffix = this.suppressedTags.size > 0
1984
+ ? ` ${DIM}${MUTE_ICON}errors${RESET}` : "";
1687
1985
  if (this.filterTag) {
1688
1986
  // tag filter takes precedence in the separator display
1689
- let source = this.mutedIds.size > 0
1690
- ? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
1691
- : this.activityBuffer;
1987
+ const source = this.applyDisplayFilters(this.activityBuffer.filter((e) => !isSuppressedEntry(e, this.suppressedTags)));
1692
1988
  const matchCount = source.filter((e) => matchesTagFilter(e, this.filterTag)).length;
1693
- hints = formatTagFilterIndicator(this.filterTag, matchCount, source.length);
1989
+ hints = formatTagFilterIndicator(this.filterTag, matchCount, source.length) + suppressedSuffix;
1694
1990
  }
1695
1991
  else if (this.searchPattern) {
1696
1992
  const filtered = this.activityBuffer.filter((e) => matchesSearch(e, this.searchPattern));
@@ -1722,15 +2018,8 @@ export class TUI {
1722
2018
  }
1723
2019
  repaintActivityRegion() {
1724
2020
  const visibleLines = this.scrollBottom - this.scrollTop + 1;
1725
- // filter pipeline: muted → tag → search
1726
- let source = this.mutedIds.size > 0
1727
- ? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
1728
- : this.activityBuffer;
1729
- if (this.filterTag)
1730
- source = source.filter((e) => matchesTagFilter(e, this.filterTag));
1731
- if (this.searchPattern) {
1732
- source = source.filter((e) => matchesSearch(e, this.searchPattern));
1733
- }
2021
+ // filter pipeline: muted → suppressed → tag → search
2022
+ const source = this.applyDisplayFilters(this.activityBuffer);
1734
2023
  const { start, end } = computeScrollSlice(source.length, visibleLines, this.scrollOffset);
1735
2024
  const entries = source.slice(start, end);
1736
2025
  for (let i = 0; i < visibleLines; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.116.0",
3
+ "version": "0.126.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",