aoaoe 0.109.0 → 0.114.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <a href="https://github.com/Talador12/agent-of-agent-of-empires/actions/workflows/ci.yml"><img src="https://github.com/Talador12/agent-of-agent-of-empires/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
5
5
  <a href="https://www.npmjs.com/package/aoaoe"><img src="https://img.shields.io/npm/v/aoaoe" alt="npm version"></a>
6
6
  <a href="https://github.com/Talador12/agent-of-agent-of-empires/releases"><img src="https://img.shields.io/github/v/release/Talador12/agent-of-agent-of-empires" alt="GitHub release"></a>
7
- <img src="https://img.shields.io/badge/tests-1509-brightgreen" alt="tests">
7
+ <img src="https://img.shields.io/badge/tests-1739-brightgreen" alt="tests">
8
8
  <img src="https://img.shields.io/badge/node-%3E%3D20-blue" alt="Node.js >= 20">
9
9
  <img src="https://img.shields.io/badge/runtime%20deps-0-brightgreen" alt="zero runtime dependencies">
10
10
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
@@ -256,11 +256,18 @@ The daemon runs an interactive TUI with a rich command set. These commands are a
256
256
  | `/mute [N\|name]` | Mute/unmute a session's activity entries |
257
257
  | `/unmute-all` | Unmute all sessions at once |
258
258
  | `/filter [tag]` | Filter activity by tag -- presets: `errors`, `actions`, `system` (no arg = clear) |
259
- | `/who` | Show fleet status (all sessions at a glance) |
259
+ | `/who` | Show fleet status: status, uptime, idle-since, context, errors, group, note |
260
260
  | `/uptime` | Show session uptimes |
261
+ | `/top [mode]` | Rank sessions by `errors` (default), `burn`, or `idle` |
261
262
  | `/auto-pin` | Toggle auto-pin on error |
262
263
  | `/note N\|name text` | Attach a note to a session (no text = clear) |
263
264
  | `/notes` | List all session notes |
265
+ | `/group N\|name tag` | Assign session to a group (lowercase, max 16 chars; no tag = clear) |
266
+ | `/groups` | List all groups and their members |
267
+ | `/group-filter [tag]` | Show only sessions in a group (no arg = clear) |
268
+ | `/rename N\|name [display]` | Set custom TUI display name (no display = clear); persisted |
269
+ | `/watchdog [N]` | Alert if session stalls N minutes (default 10); `/watchdog off` to disable |
270
+ | `/broadcast <msg>` | Send message to all sessions; `/broadcast group:<tag> <msg>` for group |
264
271
  | `/clip [N]` | Copy last N activity entries to clipboard (default 20) |
265
272
  | `/diff N` | Show activity since bookmark N |
266
273
  | `/mark` | Bookmark current activity position |
@@ -276,7 +283,7 @@ The daemon runs an interactive TUI with a rich command set. These commands are a
276
283
 
277
284
  | Command | What it does |
278
285
  |---------|-------------|
279
- | `/status` | Show daemon state |
286
+ | `/status` | Show daemon state (mode, reasoner, poll counts, last cycle) |
280
287
  | `/dashboard` | Show full dashboard |
281
288
  | `/tasks` | Show task progress table |
282
289
  | `/t ...` `/todo ...` `/idea ...` | Aliases for `/task ...` |
@@ -284,6 +291,10 @@ The daemon runs an interactive TUI with a rich command set. These commands are a
284
291
  | `/task <session> :: <goal>` | Fast path: update/create task for an existing session and set its goal |
285
292
  | `:<goal>` | Fastest path in drill-down: set goal for that session |
286
293
  | `just type` (in drill-down) | Default behavior: update goal for the focused session |
294
+ | `/burn-rate` | Show context token burn rates (tokens/min) for all sessions |
295
+ | `/ceiling` | Show context token usage vs limit for all sessions |
296
+ | `/snapshot [md]` | Export session state snapshot to `~/.aoaoe/snapshot-<ts>.json` (or `.md`) |
297
+ | `/alias /x /cmd` | Create command alias (`/x` expands to `/cmd`); no args = list |
287
298
 
288
299
  ### Other
289
300
 
@@ -296,9 +307,21 @@ The daemon runs an interactive TUI with a rich command set. These commands are a
296
307
  ### TUI Features
297
308
 
298
309
  - **Activity sparkline** -- 10-minute activity rate chart in the separator bar (Unicode blocks with color gradient)
299
- - **Session cards** -- per-session status with pin `▲`, mute `◌`, note `✎` indicators
300
- - **Sticky preferences** -- sort mode, compact, focus, bell, auto-pin, and tag filter persist across restarts (`~/.aoaoe/tui-prefs.json`)
310
+ - **Session cards** -- per-session status with pin `▲`, mute `◌`, note `✎`, group `⊹tag`, and health score `⬡N` indicators
311
+ - **Health score** -- composite 0–100 badge per session (errors, burn rate, context ceiling, stall time); LIME ≥80, AMBER ≥60, ROSE <60
312
+ - **Error sparklines** -- ROSE 5-bucket mini-chart of recent error frequency in each card (last 5 min)
313
+ - **Idle-since** -- time since last output change shown in idle/done card status and `/who` output
314
+ - **Session grouping** -- `/group` assigns sessions to named groups; `/group-filter` narrows the panel view
315
+ - **Session rename** -- `/rename` sets a custom TUI display name (bold with original dim alongside); persisted
316
+ - **Watchdog** -- `/watchdog N` fires an alert if a session stalls for N minutes (rate-limited to once per 5 min)
317
+ - **Burn-rate alerts** -- automatic "status" alert when context token usage exceeds 5k tokens/min; `/burn-rate` for on-demand view
318
+ - **Context ceiling warning** -- automatic alert at 90% context usage when "X / Y tokens" format available; `/ceiling` for on-demand view
319
+ - **Snapshot export** -- `/snapshot [md]` exports full session state (status, group, note, uptime, context, errors, burn rate) to `~/.aoaoe/`
320
+ - **Broadcast** -- `/broadcast [group:<tag>] <message>` sends a message to all (or group-filtered) sessions via tmux
321
+ - **Ranked view** -- `/top [errors|burn|idle]` ranks sessions by composite attention score or a single metric
322
+ - **Sticky preferences** -- sort, compact, focus, bell, auto-pin, tag filter, aliases, groups, and renames persist across restarts
301
323
  - **Filter pipeline** -- mute, tag filter, and search compose: mute → tag → search
324
+ - **Aliases** -- `/alias /x /cmd` creates command shortcuts; up to 50, persisted
302
325
  - **Activity heatmap** -- 24-hour colored block chart via `aoaoe stats`
303
326
  - **Bookmarks** -- mark positions in the activity stream, jump back to them, diff since a bookmark
304
327
  - **Clipboard export** -- `/clip` copies activity to system clipboard (macOS) or `~/.aoaoe/clip.txt`
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 } 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 } 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";
@@ -188,14 +188,19 @@ async function main() {
188
188
  tui.setTagFilter(prefs.tagFilter);
189
189
  if (prefs.sessionGroups)
190
190
  tui.restoreGroups(prefs.sessionGroups);
191
+ if (prefs.sessionAliases)
192
+ tui.restoreSessionAliases(prefs.sessionAliases);
191
193
  }
192
194
  const persistPrefs = () => {
193
195
  if (!tui)
194
196
  return;
195
- // persist session groups (ID → group tag)
197
+ // persist session groups (ID → group tag) and session aliases (ID → display name)
196
198
  const groupsObj = {};
197
199
  for (const [id, g] of tui.getAllGroups())
198
200
  groupsObj[id] = g;
201
+ const aliasesObj = {};
202
+ for (const [id, name] of tui.getAllSessionAliases())
203
+ aliasesObj[id] = name;
199
204
  saveTuiPrefs({
200
205
  sortMode: tui.getSortMode(),
201
206
  compact: tui.isCompact(),
@@ -205,6 +210,7 @@ async function main() {
205
210
  tagFilter: tui.getTagFilter(),
206
211
  aliases: input.getAliases(),
207
212
  sessionGroups: groupsObj,
213
+ sessionAliases: aliasesObj,
208
214
  });
209
215
  };
210
216
  if (!useTui) {
@@ -751,6 +757,68 @@ async function main() {
751
757
  if (!any)
752
758
  tui.log("system", ` threshold: ${CONTEXT_BURN_THRESHOLD.toLocaleString()} tokens/min`);
753
759
  });
760
+ // wire /rename custom display name
761
+ input.onRename((target, displayName) => {
762
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
763
+ const ok = tui.renameSession(num ?? target, displayName || null);
764
+ if (ok) {
765
+ if (displayName) {
766
+ tui.log("system", `renamed ${target} → "${displayName}"`);
767
+ }
768
+ else {
769
+ tui.log("system", `rename cleared for ${target}`);
770
+ }
771
+ persistPrefs();
772
+ }
773
+ else {
774
+ tui.log("system", `session not found: ${target}`);
775
+ }
776
+ });
777
+ // wire /ceiling context usage view
778
+ input.onCeiling(() => {
779
+ const sessions = tui.getSessions();
780
+ if (sessions.length === 0) {
781
+ tui.log("system", "no sessions — context data not available");
782
+ return;
783
+ }
784
+ const ceilings = tui.getAllContextCeilings();
785
+ let any = false;
786
+ for (const s of sessions) {
787
+ const c = ceilings.get(s.id);
788
+ if (!c) {
789
+ tui.log("system", ` ${s.title}: no ceiling data (${s.contextTokens ?? "no tokens"})`);
790
+ }
791
+ else {
792
+ const pct = Math.round((c.current / c.max) * 100);
793
+ const warn = pct >= CONTEXT_CEILING_THRESHOLD * 100 ? " ⚠" : "";
794
+ tui.log("system", ` ${s.title}: ${pct}% — ${c.current.toLocaleString()} / ${c.max.toLocaleString()} tokens${warn}`);
795
+ any = true;
796
+ }
797
+ }
798
+ if (!any)
799
+ tui.log("system", ` tip: context ceiling requires "X / Y tokens" format in session output`);
800
+ });
801
+ // wire /top ranked session view
802
+ input.onTop((modeArg) => {
803
+ const mode = TOP_SORT_MODES.includes(modeArg)
804
+ ? modeArg : "default";
805
+ const sessions = tui.getSessions();
806
+ if (sessions.length === 0) {
807
+ tui.log("system", "no sessions");
808
+ return;
809
+ }
810
+ const now = Date.now();
811
+ const entries = rankSessions(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllLastChangeAt(), mode, now);
812
+ const modeLabel = mode === "default" ? "composite" : mode;
813
+ tui.log("system", `/top (${modeLabel}) — ${entries.length} sessions:`);
814
+ for (const e of entries) {
815
+ const errStr = e.errors > 0 ? ` ${e.errors}err` : "";
816
+ const burnStr = e.burnRatePerMin !== null && e.burnRatePerMin > 0
817
+ ? ` ~${Math.round(e.burnRatePerMin / 100) * 100}tok/min` : "";
818
+ const idleStr = e.idleMs !== null ? ` ${formatIdleSince(e.idleMs) || "active"}` : "";
819
+ tui.log("system", ` #${e.rank} ${e.title} [${e.status}]${errStr}${burnStr}${idleStr}`);
820
+ }
821
+ });
754
822
  // wire /watchdog stall detection
755
823
  input.onWatchdog((thresholdMinutes) => {
756
824
  if (thresholdMinutes === null) {
package/dist/input.d.ts CHANGED
@@ -30,6 +30,9 @@ export type BurnRateHandler = () => void;
30
30
  export type SnapshotHandler = (format: "json" | "md") => void;
31
31
  export type BroadcastHandler = (message: string, group: string | null) => void;
32
32
  export type WatchdogHandler = (thresholdMinutes: number | null) => void;
33
+ export type TopHandler = (mode: string) => void;
34
+ export type CeilingHandler = () => void;
35
+ export type RenameHandler = (target: string, name: string) => void;
33
36
  export interface MouseEvent {
34
37
  button: number;
35
38
  col: number;
@@ -82,6 +85,9 @@ export declare class InputReader {
82
85
  private snapshotHandler;
83
86
  private broadcastHandler;
84
87
  private watchdogHandler;
88
+ private topHandler;
89
+ private ceilingHandler;
90
+ private renameHandler;
85
91
  private aliases;
86
92
  private mouseDataListener;
87
93
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -117,6 +123,9 @@ export declare class InputReader {
117
123
  onSnapshot(handler: SnapshotHandler): void;
118
124
  onBroadcast(handler: BroadcastHandler): void;
119
125
  onWatchdog(handler: WatchdogHandler): void;
126
+ onTop(handler: TopHandler): void;
127
+ onCeiling(handler: CeilingHandler): void;
128
+ onRename(handler: RenameHandler): void;
120
129
  /** Set aliases from persisted prefs. */
121
130
  setAliases(aliases: Record<string, string>): void;
122
131
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -63,6 +63,9 @@ export class InputReader {
63
63
  snapshotHandler = null;
64
64
  broadcastHandler = null;
65
65
  watchdogHandler = null;
66
+ topHandler = null;
67
+ ceilingHandler = null;
68
+ renameHandler = null;
66
69
  aliases = new Map(); // /shortcut → /full command
67
70
  mouseDataListener = null;
68
71
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -197,6 +200,18 @@ export class InputReader {
197
200
  onWatchdog(handler) {
198
201
  this.watchdogHandler = handler;
199
202
  }
203
+ // register a callback for /top [mode]
204
+ onTop(handler) {
205
+ this.topHandler = handler;
206
+ }
207
+ // register a callback for /ceiling
208
+ onCeiling(handler) {
209
+ this.ceilingHandler = handler;
210
+ }
211
+ // register a callback for /rename <N|name> [display name]
212
+ onRename(handler) {
213
+ this.renameHandler = handler;
214
+ }
200
215
  /** Set aliases from persisted prefs. */
201
216
  setAliases(aliases) {
202
217
  this.aliases.clear();
@@ -430,6 +445,9 @@ ${BOLD}navigation:${RESET}
430
445
  /snapshot [md] export session state snapshot to JSON (or Markdown with md)
431
446
  /broadcast <msg> send message to all sessions; /broadcast group:<tag> <msg> for group
432
447
  /watchdog [N] alert if session stalls N minutes (default 10); /watchdog off to disable
448
+ /top [mode] rank sessions by errors (default), burn, or idle
449
+ /ceiling show context token usage vs limit for all sessions
450
+ /rename N|name [display] set custom display name in TUI (no display = clear)
433
451
  /clip [N] copy last N activity entries to clipboard (default 20)
434
452
  /diff N show activity since bookmark N
435
453
  /mark bookmark current activity position
@@ -819,6 +837,47 @@ ${BOLD}other:${RESET}
819
837
  }
820
838
  break;
821
839
  }
840
+ case "/rename": {
841
+ const renameArg = line.slice("/rename".length).trim();
842
+ if (!renameArg) {
843
+ console.error(`${DIM}usage: /rename <N|name> [display name] — set custom display name (no name = clear)${RESET}`);
844
+ break;
845
+ }
846
+ if (this.renameHandler) {
847
+ const spaceIdx = renameArg.indexOf(" ");
848
+ if (spaceIdx > 0) {
849
+ const target = renameArg.slice(0, spaceIdx);
850
+ const display = renameArg.slice(spaceIdx + 1).trim();
851
+ this.renameHandler(target, display);
852
+ }
853
+ else {
854
+ // target only — clear alias
855
+ this.renameHandler(renameArg, "");
856
+ }
857
+ }
858
+ else {
859
+ console.error(`${DIM}rename not available (no TUI)${RESET}`);
860
+ }
861
+ break;
862
+ }
863
+ case "/ceiling":
864
+ if (this.ceilingHandler) {
865
+ this.ceilingHandler();
866
+ }
867
+ else {
868
+ console.error(`${DIM}ceiling not available (no TUI)${RESET}`);
869
+ }
870
+ break;
871
+ case "/top": {
872
+ const topArg = line.slice("/top".length).trim().toLowerCase() || "default";
873
+ if (this.topHandler) {
874
+ this.topHandler(topArg);
875
+ }
876
+ else {
877
+ console.error(`${DIM}top not available (no TUI)${RESET}`);
878
+ }
879
+ break;
880
+ }
822
881
  case "/watchdog": {
823
882
  const wdArg = line.slice("/watchdog".length).trim().toLowerCase();
824
883
  if (this.watchdogHandler) {
@@ -54,9 +54,12 @@ export function parseModel(output) {
54
54
  }
55
55
  // extract context info from OpenCode pane output
56
56
  export function parseContext(output) {
57
- // matches "137,918 tokens" or "111,881 tokens"
58
- const match = output.match(/([\d,]+)\s+tokens/);
59
- return match?.[1] ? `${match[1]} tokens` : undefined;
57
+ // matches "137,918 / 200,000 tokens" or plain "137,918 tokens"
58
+ const full = output.match(/([\d,]+)\s*\/\s*([\d,]+)\s+tokens/);
59
+ if (full?.[1] && full[2])
60
+ return `${full[1]} / ${full[2]} tokens`;
61
+ const simple = output.match(/([\d,]+)\s+tokens/);
62
+ return simple?.[1] ? `${simple[1]} tokens` : undefined;
60
63
  }
61
64
  // extract cost from OpenCode pane output
62
65
  export function parseCost(output) {
package/dist/tui.d.ts CHANGED
@@ -74,6 +74,10 @@ export declare function formatUptime(ms: number): string;
74
74
  * Returns empty string if under threshold (< thresholdMs, default 2 min — not worth showing).
75
75
  */
76
76
  export declare function formatIdleSince(ms: number, thresholdMs?: number): string;
77
+ /** Max visible length for a custom session display name. */
78
+ export declare const MAX_RENAME_LEN = 32;
79
+ /** Truncate a custom display name to the max length. */
80
+ export declare function truncateRename(name: string): string;
77
81
  /** Default watchdog threshold: 10 minutes of no output change triggers alert. */
78
82
  export declare const WATCHDOG_DEFAULT_MINUTES = 10;
79
83
  /** Cooldown between watchdog alerts for the same session (5 minutes). */
@@ -119,6 +123,42 @@ export declare function buildSnapshotData(sessions: readonly DaemonSessionState[
119
123
  export declare function formatSnapshotJson(data: SnapshotData): string;
120
124
  /** Format a SnapshotData as a Markdown report. */
121
125
  export declare function formatSnapshotMarkdown(data: SnapshotData): string;
126
+ /** Health score color thresholds. */
127
+ export declare const HEALTH_GOOD = 80;
128
+ export declare const HEALTH_WARN = 60;
129
+ /** Health score badge icon. */
130
+ export declare const HEALTH_ICON = "\u2B21";
131
+ /**
132
+ * Compute a composite health score (0–100) for a session.
133
+ * Higher = healthier. Deductions for: errors, high burn rate, context ceiling proximity, stall.
134
+ */
135
+ export declare function computeHealthScore(opts: {
136
+ errorCount: number;
137
+ burnRatePerMin: number | null;
138
+ contextFraction: number | null;
139
+ idleMs: number | null;
140
+ watchdogThresholdMs: number | null;
141
+ }): number;
142
+ /**
143
+ * Format a health score as a colored badge string: "⬡83" in LIME/AMBER/ROSE.
144
+ * Returns empty string for score of exactly 100 (no badge clutter on healthy sessions).
145
+ */
146
+ export declare function formatHealthBadge(score: number): string;
147
+ export type TopSortMode = "errors" | "burn" | "idle" | "default";
148
+ export declare const TOP_SORT_MODES: TopSortMode[];
149
+ export interface TopEntry {
150
+ title: string;
151
+ status: string;
152
+ rank: number;
153
+ errors: number;
154
+ burnRatePerMin: number | null;
155
+ idleMs: number | null;
156
+ }
157
+ /**
158
+ * Rank sessions for /top output. Returns entries sorted by the given mode.
159
+ * "default" = composite: errors first, then burn rate, then idle.
160
+ */
161
+ export declare function rankSessions(sessions: readonly DaemonSessionState[], errorCounts: ReadonlyMap<string, number>, burnRates: ReadonlyMap<string, number | null>, lastChangeAt: ReadonlyMap<string, number>, mode: TopSortMode, now?: number): TopEntry[];
122
162
  /** Format a broadcast summary for the activity log. */
123
163
  export declare function formatBroadcastSummary(count: number, group: string | null): string;
124
164
  export interface TuiPrefs {
@@ -130,6 +170,7 @@ export interface TuiPrefs {
130
170
  tagFilter?: string | null;
131
171
  aliases?: Record<string, string>;
132
172
  sessionGroups?: Record<string, string>;
173
+ sessionAliases?: Record<string, string>;
133
174
  }
134
175
  /** Load persisted TUI preferences. Returns empty object on any error. */
135
176
  export declare function loadTuiPrefs(): TuiPrefs;
@@ -174,8 +215,10 @@ export declare class TUI {
174
215
  private sessionErrorTimestamps;
175
216
  private sessionContextHistory;
176
217
  private burnRateAlerted;
218
+ private ceilingAlerted;
177
219
  private sessionGroups;
178
220
  private groupFilter;
221
+ private sessionAliases;
179
222
  private watchdogThresholdMs;
180
223
  private watchdogAlerted;
181
224
  private viewMode;
@@ -267,6 +310,11 @@ export declare class TUI {
267
310
  getSessionErrorTimestamps(id: string): readonly number[];
268
311
  /** Return context token history for a session (for burn-rate reporting). */
269
312
  getSessionContextHistory(id: string): readonly ContextHistoryEntry[];
313
+ /** Return parsed context ceiling for all sessions ({current, max} or null). */
314
+ getAllContextCeilings(): Map<string, {
315
+ current: number;
316
+ max: number;
317
+ } | null>;
270
318
  /** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
271
319
  getAllBurnRates(now?: number): Map<string, number | null>;
272
320
  /**
@@ -294,6 +342,17 @@ export declare class TUI {
294
342
  setGroupFilter(group: string | null): void;
295
343
  /** Get the current group filter (or null if none). */
296
344
  getGroupFilter(): string | null;
345
+ /**
346
+ * Set or clear a custom display name for a session (by 1-indexed number, ID, ID prefix, or title).
347
+ * Returns true if session found. Pass empty/null to clear.
348
+ */
349
+ renameSession(sessionIdOrIndex: string | number, displayName: string | null): boolean;
350
+ /** Get the custom display name for a session (or undefined if not renamed). */
351
+ getSessionAlias(id: string): string | undefined;
352
+ /** Return all session aliases (for persistence and listing). */
353
+ getAllSessionAliases(): ReadonlyMap<string, string>;
354
+ /** Restore session aliases from persisted prefs (bulk restore). */
355
+ restoreSessionAliases(aliases: Record<string, string>): void;
297
356
  /**
298
357
  * Add a bookmark at the current activity position.
299
358
  * Returns the bookmark number (1-indexed) or 0 if buffer is empty.
@@ -365,7 +424,7 @@ export declare class TUI {
365
424
  private repaintDrilldownContent;
366
425
  private paintInputLine;
367
426
  }
368
- declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number): string;
427
+ declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string): string;
369
428
  declare function formatActivity(entry: ActivityEntry, maxCols: number): string;
370
429
  declare function padBoxLine(line: string, totalWidth: number): string;
371
430
  declare function padBoxLineHover(line: string, totalWidth: number, hovered: boolean): string;
@@ -390,6 +449,18 @@ declare function formatDrilldownScrollIndicator(offset: number, totalLines: numb
390
449
  declare function computeSparkline(timestamps: number[], now?: number, buckets?: number, windowMs?: number): number[];
391
450
  /** Format sparkline bucket counts as a colored Unicode block string. Returns empty string if all zeros. */
392
451
  declare function formatSparkline(counts: number[]): string;
452
+ /**
453
+ * Parse a context token string that may include a ceiling: "137,918 / 200,000 tokens".
454
+ * Returns { current, max } if both values are present, null otherwise.
455
+ */
456
+ export declare function parseContextCeiling(contextTokens: string | undefined): {
457
+ current: number;
458
+ max: number;
459
+ } | null;
460
+ /** Alert threshold: fire ceiling warning when context usage exceeds this fraction. */
461
+ export declare const CONTEXT_CEILING_THRESHOLD = 0.9;
462
+ /** Format a context ceiling alert for the activity log. */
463
+ export declare function formatContextCeilingAlert(title: string, current: number, max: number): string;
393
464
  /** Parse a context token string like "137,918 tokens" to a raw number, or null if unparseable. */
394
465
  export declare function parseContextTokenNumber(contextTokens: string | undefined): number | null;
395
466
  /** Context history entry: token count at a timestamp. */
package/dist/tui.js CHANGED
@@ -259,6 +259,13 @@ export function formatIdleSince(ms, thresholdMs = 2 * 60_000) {
259
259
  return "";
260
260
  return `idle ${formatUptime(ms)}`;
261
261
  }
262
+ // ── Session rename ────────────────────────────────────────────────────────────
263
+ /** Max visible length for a custom session display name. */
264
+ export const MAX_RENAME_LEN = 32;
265
+ /** Truncate a custom display name to the max length. */
266
+ export function truncateRename(name) {
267
+ return name.length > MAX_RENAME_LEN ? name.slice(0, MAX_RENAME_LEN - 2) + ".." : name;
268
+ }
262
269
  // ── Watchdog ──────────────────────────────────────────────────────────────────
263
270
  /** Default watchdog threshold: 10 minutes of no output change triggers alert. */
264
271
  export const WATCHDOG_DEFAULT_MINUTES = 10;
@@ -274,7 +281,7 @@ export const BUILTIN_COMMANDS = new Set([
274
281
  "/pin", "/bell", "/focus", "/mute", "/unmute-all", "/filter", "/who",
275
282
  "/uptime", "/auto-pin", "/note", "/notes", "/clip", "/diff", "/mark",
276
283
  "/jump", "/marks", "/search", "/alias", "/insist", "/task", "/tasks",
277
- "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog",
284
+ "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename",
278
285
  ]);
279
286
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
280
287
  export function resolveAlias(line, aliases) {
@@ -391,6 +398,84 @@ export function formatSnapshotMarkdown(data) {
391
398
  }
392
399
  return lines.join("\n");
393
400
  }
401
+ // ── Session health score ──────────────────────────────────────────────────────
402
+ /** Health score color thresholds. */
403
+ export const HEALTH_GOOD = 80;
404
+ export const HEALTH_WARN = 60;
405
+ /** Health score badge icon. */
406
+ export const HEALTH_ICON = "⬡";
407
+ /**
408
+ * Compute a composite health score (0–100) for a session.
409
+ * Higher = healthier. Deductions for: errors, high burn rate, context ceiling proximity, stall.
410
+ */
411
+ export function computeHealthScore(opts) {
412
+ let score = 100;
413
+ // errors: -10 per error, cap deduction at 50
414
+ const errDeduction = Math.min(50, opts.errorCount * 10);
415
+ score -= errDeduction;
416
+ // burn rate: -20 when above CONTEXT_BURN_THRESHOLD
417
+ if (opts.burnRatePerMin !== null && opts.burnRatePerMin > CONTEXT_BURN_THRESHOLD) {
418
+ score -= 20;
419
+ }
420
+ // context ceiling: -10 per 10% above 70% (so 80%→-10, 90%→-20, 100%→-30)
421
+ if (opts.contextFraction !== null) {
422
+ const overPct = Math.max(0, opts.contextFraction - 0.70);
423
+ const tenPctSteps = Math.floor(overPct * 10); // number of 10% increments over 70%
424
+ const ceDeduction = Math.min(30, tenPctSteps * 10);
425
+ score -= ceDeduction;
426
+ }
427
+ // stall: -15 when idle longer than watchdog threshold
428
+ if (opts.idleMs !== null && opts.watchdogThresholdMs !== null && opts.idleMs >= opts.watchdogThresholdMs) {
429
+ score -= 15;
430
+ }
431
+ return Math.max(0, Math.min(100, score));
432
+ }
433
+ /**
434
+ * Format a health score as a colored badge string: "⬡83" in LIME/AMBER/ROSE.
435
+ * Returns empty string for score of exactly 100 (no badge clutter on healthy sessions).
436
+ */
437
+ export function formatHealthBadge(score) {
438
+ if (score >= 100)
439
+ return "";
440
+ const color = score >= HEALTH_GOOD ? LIME : score >= HEALTH_WARN ? AMBER : ROSE;
441
+ return `${color}${HEALTH_ICON}${score}${RESET}`;
442
+ }
443
+ export const TOP_SORT_MODES = ["default", "errors", "burn", "idle"];
444
+ /**
445
+ * Rank sessions for /top output. Returns entries sorted by the given mode.
446
+ * "default" = composite: errors first, then burn rate, then idle.
447
+ */
448
+ export function rankSessions(sessions, errorCounts, burnRates, lastChangeAt, mode, now) {
449
+ const nowMs = now ?? Date.now();
450
+ const entries = sessions.map((s) => ({
451
+ title: s.title,
452
+ status: s.status,
453
+ rank: 0,
454
+ errors: errorCounts.get(s.id) ?? 0,
455
+ burnRatePerMin: burnRates.get(s.id) ?? null,
456
+ idleMs: lastChangeAt.has(s.id) ? nowMs - lastChangeAt.get(s.id) : null,
457
+ }));
458
+ switch (mode) {
459
+ case "errors":
460
+ entries.sort((a, b) => b.errors - a.errors);
461
+ break;
462
+ case "burn":
463
+ entries.sort((a, b) => (b.burnRatePerMin ?? 0) - (a.burnRatePerMin ?? 0));
464
+ break;
465
+ case "idle":
466
+ entries.sort((a, b) => (b.idleMs ?? 0) - (a.idleMs ?? 0));
467
+ break;
468
+ default: {
469
+ // composite: weight errors heavily, then burn rate, then idle
470
+ const score = (e) => e.errors * 10000 +
471
+ (e.burnRatePerMin ?? 0) * 0.01 +
472
+ (e.idleMs ?? 0) * 0.0001;
473
+ entries.sort((a, b) => score(b) - score(a));
474
+ }
475
+ }
476
+ entries.forEach((e, i) => { e.rank = i + 1; });
477
+ return entries;
478
+ }
394
479
  // ── Broadcast helpers ────────────────────────────────────────────────────────
395
480
  /** Format a broadcast summary for the activity log. */
396
481
  export function formatBroadcastSummary(count, group) {
@@ -455,8 +540,10 @@ export class TUI {
455
540
  sessionErrorTimestamps = new Map(); // session ID → recent error epoch ms (last 100)
456
541
  sessionContextHistory = new Map(); // session ID → context token history
457
542
  burnRateAlerted = new Map(); // session ID → epoch ms of last burn-rate alert (cooldown)
543
+ ceilingAlerted = new Map(); // session ID → epoch ms of last ceiling alert (cooldown)
458
544
  sessionGroups = new Map(); // session ID → group tag
459
545
  groupFilter = null; // active group filter (null = show all)
546
+ sessionAliases = new Map(); // session ID → custom display name
460
547
  watchdogThresholdMs = null; // null = disabled; ms of inactivity before alert
461
548
  watchdogAlerted = new Map(); // session ID → epoch ms of last watchdog alert
462
549
  // drill-down mode: show a single session's full output
@@ -754,6 +841,14 @@ export class TUI {
754
841
  getSessionContextHistory(id) {
755
842
  return this.sessionContextHistory.get(id) ?? [];
756
843
  }
844
+ /** Return parsed context ceiling for all sessions ({current, max} or null). */
845
+ getAllContextCeilings() {
846
+ const result = new Map();
847
+ for (const s of this.sessions) {
848
+ result.set(s.id, parseContextCeiling(s.contextTokens));
849
+ }
850
+ return result;
851
+ }
757
852
  /** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
758
853
  getAllBurnRates(now) {
759
854
  const result = new Map();
@@ -835,6 +930,46 @@ export class TUI {
835
930
  getGroupFilter() {
836
931
  return this.groupFilter;
837
932
  }
933
+ /**
934
+ * Set or clear a custom display name for a session (by 1-indexed number, ID, ID prefix, or title).
935
+ * Returns true if session found. Pass empty/null to clear.
936
+ */
937
+ renameSession(sessionIdOrIndex, displayName) {
938
+ let sessionId;
939
+ if (typeof sessionIdOrIndex === "number") {
940
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
941
+ }
942
+ else {
943
+ const needle = sessionIdOrIndex.toLowerCase();
944
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
945
+ sessionId = match?.id;
946
+ }
947
+ if (!sessionId)
948
+ return false;
949
+ if (!displayName || displayName.trim() === "") {
950
+ this.sessionAliases.delete(sessionId);
951
+ }
952
+ else {
953
+ this.sessionAliases.set(sessionId, truncateRename(displayName.trim()));
954
+ }
955
+ if (this.active)
956
+ this.paintSessions();
957
+ return true;
958
+ }
959
+ /** Get the custom display name for a session (or undefined if not renamed). */
960
+ getSessionAlias(id) {
961
+ return this.sessionAliases.get(id);
962
+ }
963
+ /** Return all session aliases (for persistence and listing). */
964
+ getAllSessionAliases() {
965
+ return this.sessionAliases;
966
+ }
967
+ /** Restore session aliases from persisted prefs (bulk restore). */
968
+ restoreSessionAliases(aliases) {
969
+ this.sessionAliases.clear();
970
+ for (const [id, name] of Object.entries(aliases))
971
+ this.sessionAliases.set(id, name);
972
+ }
838
973
  /**
839
974
  * Add a bookmark at the current activity position.
840
975
  * Returns the bookmark number (1-indexed) or 0 if buffer is empty.
@@ -929,6 +1064,15 @@ export class TUI {
929
1064
  }
930
1065
  }
931
1066
  }
1067
+ // check context ceiling and emit alert if above threshold (with cooldown)
1068
+ const ceiling = parseContextCeiling(s.contextTokens);
1069
+ if (ceiling !== null && ceiling.current / ceiling.max >= CONTEXT_CEILING_THRESHOLD) {
1070
+ const lastCeiling = this.ceilingAlerted.get(s.id) ?? 0;
1071
+ if (now - lastCeiling >= BURN_ALERT_COOLDOWN_MS) {
1072
+ this.ceilingAlerted.set(s.id, now);
1073
+ this.log("status", formatContextCeilingAlert(s.title, ceiling.current, ceiling.max), s.id);
1074
+ }
1075
+ }
932
1076
  }
933
1077
  // watchdog: alert if session has been idle longer than threshold
934
1078
  if (this.watchdogThresholdMs !== null) {
@@ -1400,6 +1544,20 @@ export class TUI {
1400
1544
  const errSparkline = errTs ? formatSessionErrorSparkline(errTs, nowMs) : "";
1401
1545
  const lastChange = this.lastChangeAt.get(s.id);
1402
1546
  const idleSinceMs = lastChange !== undefined ? nowMs - lastChange : undefined;
1547
+ // compute health score
1548
+ const ceiling = parseContextCeiling(s.contextTokens);
1549
+ const contextFraction = ceiling ? ceiling.current / ceiling.max : null;
1550
+ const burnHist = this.sessionContextHistory.get(s.id);
1551
+ const burnRate = burnHist ? computeContextBurnRate(burnHist, nowMs) : null;
1552
+ const healthScore = computeHealthScore({
1553
+ errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
1554
+ burnRatePerMin: burnRate,
1555
+ contextFraction,
1556
+ idleMs: idleSinceMs ?? null,
1557
+ watchdogThresholdMs: this.watchdogThresholdMs,
1558
+ });
1559
+ const healthBadge = formatHealthBadge(healthScore);
1560
+ const displayName = this.sessionAliases.get(s.id);
1403
1561
  const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
1404
1562
  const muteBadgeWidth = muted ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 2 : 0; // "(N)" visible chars, 0 when count is 0
1405
1563
  const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0 ? muteBadgeWidth + 1 : 0; // +1 for trailing space
@@ -1411,7 +1569,7 @@ export class TUI {
1411
1569
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
1412
1570
  const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth;
1413
1571
  const cardWidth = innerWidth - 1 - iconsWidth;
1414
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs)}`;
1572
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
1415
1573
  const padded = padBoxLineHover(line, this.cols, isHovered);
1416
1574
  process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
1417
1575
  }
@@ -1451,6 +1609,19 @@ export class TUI {
1451
1609
  const errSparkline = errTs ? formatSessionErrorSparkline(errTs, nowMs2) : "";
1452
1610
  const lastChange = this.lastChangeAt.get(s.id);
1453
1611
  const idleSinceMs = lastChange !== undefined ? nowMs2 - lastChange : undefined;
1612
+ const ceiling2 = parseContextCeiling(s.contextTokens);
1613
+ const contextFraction2 = ceiling2 ? ceiling2.current / ceiling2.max : null;
1614
+ const burnHist2 = this.sessionContextHistory.get(s.id);
1615
+ const burnRate2 = burnHist2 ? computeContextBurnRate(burnHist2, nowMs2) : null;
1616
+ const healthScore2 = computeHealthScore({
1617
+ errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
1618
+ burnRatePerMin: burnRate2,
1619
+ contextFraction: contextFraction2,
1620
+ idleMs: idleSinceMs ?? null,
1621
+ watchdogThresholdMs: this.watchdogThresholdMs,
1622
+ });
1623
+ const healthBadge2 = formatHealthBadge(healthScore2);
1624
+ const displayName2 = this.sessionAliases.get(s.id);
1454
1625
  const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
1455
1626
  const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0
1456
1627
  ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 3 : 0; // "(N) " visible chars
@@ -1462,7 +1633,7 @@ export class TUI {
1462
1633
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
1463
1634
  const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth;
1464
1635
  const cardWidth = innerWidth - 1 - iconsWidth;
1465
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs)}`;
1636
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2)}`;
1466
1637
  const padded = padBoxLineHover(line, this.cols, isHovered);
1467
1638
  process.stderr.write(SAVE_CURSOR + moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded + RESTORE_CURSOR);
1468
1639
  }
@@ -1584,13 +1755,18 @@ export class TUI {
1584
1755
  // format a session as a card-style line (inside the box)
1585
1756
  // errorSparkline: optional pre-formatted ROSE sparkline string (5 chars) for recent errors
1586
1757
  // idleSinceMs: optional ms since last activity change (shown when idle/stopped)
1587
- function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs) {
1758
+ // healthBadge: optional pre-formatted health score badge ("⬡83" colored)
1759
+ // displayName: optional custom name override (from /rename)
1760
+ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName) {
1588
1761
  const dot = STATUS_DOT[s.status] ?? `${AMBER}${DOT.filled}${RESET}`;
1589
- const name = `${BOLD}${s.title}${RESET}`;
1762
+ const title = displayName ?? s.title;
1763
+ const name = displayName ? `${BOLD}${displayName}${DIM} (${s.title})${RESET}` : `${BOLD}${s.title}${RESET}`;
1590
1764
  const toolBadge = `${SLATE}${s.tool}${RESET}`;
1591
1765
  const contextBadge = s.contextTokens ? ` ${DIM}(${s.contextTokens})${RESET}` : "";
1592
1766
  const sparkSuffix = errorSparkline ? ` ${errorSparkline}` : "";
1593
- // sparkline takes fixed space so status desc gets less room
1767
+ const healthPrefix = healthBadge ? `${healthBadge} ` : "";
1768
+ const healthPrefixWidth = healthBadge ? stripAnsiForLen(healthBadge) + 1 : 0;
1769
+ // sparkline + health badge take fixed space so status desc gets less room
1594
1770
  const sparkWidth = errorSparkline ? SESSION_SPARK_BUCKETS + 1 : 0;
1595
1771
  // idle-since label: show when session is idle/stopped and stale > 2min
1596
1772
  const idleLabel = (idleSinceMs !== undefined && (s.status === "idle" || s.status === "stopped" || s.status === "done"))
@@ -1603,7 +1779,7 @@ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs) {
1603
1779
  }
1604
1780
  else if (s.status === "working" || s.status === "running") {
1605
1781
  desc = s.currentTask
1606
- ? truncatePlain(s.currentTask, Math.max(20, maxWidth - s.title.length - s.tool.length - 16 - sparkWidth))
1782
+ ? truncatePlain(s.currentTask, Math.max(20, maxWidth - title.length - s.tool.length - 16 - sparkWidth))
1607
1783
  : `${LIME}working${RESET}`;
1608
1784
  }
1609
1785
  else if (s.status === "idle" || s.status === "stopped") {
@@ -1621,7 +1797,7 @@ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs) {
1621
1797
  else {
1622
1798
  desc = `${SLATE}${s.status}${RESET}`;
1623
1799
  }
1624
- return truncateAnsi(`${dot} ${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${sparkSuffix}`, maxWidth);
1800
+ return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${sparkSuffix}`, maxWidth);
1625
1801
  }
1626
1802
  // colorize an activity entry based on its tag
1627
1803
  function formatActivity(entry, maxCols) {
@@ -1824,6 +2000,31 @@ function formatSparkline(counts) {
1824
2000
  });
1825
2001
  return blocks.join("");
1826
2002
  }
2003
+ // ── Context ceiling warning (pure, exported for testing) ─────────────────────
2004
+ /**
2005
+ * Parse a context token string that may include a ceiling: "137,918 / 200,000 tokens".
2006
+ * Returns { current, max } if both values are present, null otherwise.
2007
+ */
2008
+ export function parseContextCeiling(contextTokens) {
2009
+ if (!contextTokens)
2010
+ return null;
2011
+ const stripped = contextTokens.replace(/,/g, "");
2012
+ const match = stripped.match(/(\d+)\s*\/\s*(\d+)/);
2013
+ if (!match)
2014
+ return null;
2015
+ const current = parseInt(match[1], 10);
2016
+ const max = parseInt(match[2], 10);
2017
+ if (isNaN(current) || isNaN(max) || max <= 0)
2018
+ return null;
2019
+ return { current, max };
2020
+ }
2021
+ /** Alert threshold: fire ceiling warning when context usage exceeds this fraction. */
2022
+ export const CONTEXT_CEILING_THRESHOLD = 0.90;
2023
+ /** Format a context ceiling alert for the activity log. */
2024
+ export function formatContextCeilingAlert(title, current, max) {
2025
+ const pct = Math.round((current / max) * 100);
2026
+ return `${title}: context at ${pct}% (${current.toLocaleString()} / ${max.toLocaleString()} tokens) — approaching limit`;
2027
+ }
1827
2028
  // ── Context burn-rate tracking (pure, exported for testing) ─────────────────
1828
2029
  /** Parse a context token string like "137,918 tokens" to a raw number, or null if unparseable. */
1829
2030
  export function parseContextTokenNumber(contextTokens) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.109.0",
3
+ "version": "0.114.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",