aoaoe 0.153.0 → 0.160.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { wakeableSleep } from "./wake.js";
16
16
  import { classifyMessages, formatUserMessages, buildReceipts, shouldSkipSleep, hasPendingFile, isInsistMessage, stripInsistPrefix } from "./message.js";
17
17
  import { TaskManager, loadTaskDefinitions, loadTaskState, formatTaskTable, importAoeSessionsToTasks } from "./task-manager.js";
18
18
  import { runTaskCli, handleTaskSlashCommand, quickTaskUpdate } from "./task-cli.js";
19
- import { TUI, hitTestSession, nextSortMode, SORT_MODES, formatUptime, formatClipText, loadTuiPrefs, saveTuiPrefs, validateGroupName, CONTEXT_BURN_THRESHOLD, buildSnapshotData, formatSnapshotJson, formatSnapshotMarkdown, formatBroadcastSummary, rankSessions, TOP_SORT_MODES, formatIdleSince, CONTEXT_CEILING_THRESHOLD, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName, computeErrorTrend, parseQuietHoursRange, computeCostSummary, formatSessionReport, formatQuietStatus, formatSessionAge, formatHealthTrendChart, isOverBudget } from "./tui.js";
19
+ import { TUI, hitTestSession, nextSortMode, SORT_MODES, formatUptime, formatClipText, loadTuiPrefs, saveTuiPrefs, validateGroupName, CONTEXT_BURN_THRESHOLD, buildSnapshotData, formatSnapshotJson, formatSnapshotMarkdown, formatBroadcastSummary, rankSessions, TOP_SORT_MODES, formatIdleSince, CONTEXT_CEILING_THRESHOLD, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName, computeErrorTrend, parseQuietHoursRange, computeCostSummary, formatSessionReport, formatQuietStatus, formatSessionAge, formatHealthTrendChart, isOverBudget, DRAIN_ICON, formatSessionsTable } 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";
@@ -1023,6 +1023,105 @@ async function main() {
1023
1023
  tui.log("system", `quiet hours: ${specs.join(", ")} — watchdog+burn alerts suppressed`);
1024
1024
  persistPrefs();
1025
1025
  });
1026
+ // wire /note-history
1027
+ input.onNoteHistory((target) => {
1028
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1029
+ const sessions = tui.getSessions();
1030
+ const session = num !== undefined
1031
+ ? sessions[num - 1]
1032
+ : sessions.find((s) => s.title.toLowerCase() === target.toLowerCase() || s.id.startsWith(target));
1033
+ if (!session) {
1034
+ tui.log("system", `session not found: ${target}`);
1035
+ return;
1036
+ }
1037
+ const hist = tui.getNoteHistory(session.id);
1038
+ if (hist.length === 0) {
1039
+ tui.log("system", `note-history: no previous notes for ${session.title}`);
1040
+ }
1041
+ else {
1042
+ tui.log("system", `note-history for ${session.title} (${hist.length}):`);
1043
+ for (const n of hist)
1044
+ tui.log("system", ` "${n}"`);
1045
+ }
1046
+ });
1047
+ // wire /label
1048
+ input.onLabel((target, label) => {
1049
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1050
+ const ok = tui.setLabel(num ?? target, label || null);
1051
+ if (ok) {
1052
+ tui.log("system", label ? `label set for ${target}: "${label}"` : `label cleared for ${target}`);
1053
+ }
1054
+ else {
1055
+ tui.log("system", `session not found: ${target}`);
1056
+ }
1057
+ });
1058
+ // wire /sessions enhanced table
1059
+ input.onSessionsTable(() => {
1060
+ const sessions = tui.getSessions();
1061
+ const now = Date.now();
1062
+ const lines = formatSessionsTable(sessions, {
1063
+ groups: tui.getAllGroups(),
1064
+ tags: tui.getAllSessionTags(),
1065
+ colors: tui.getAllSessionColors(),
1066
+ notes: tui.getAllNotes(),
1067
+ labels: tui.getAllLabels(),
1068
+ aliases: tui.getAllSessionAliases(),
1069
+ drainingIds: tui.getDrainingIds(),
1070
+ healthScores: tui.getAllHealthScores(now),
1071
+ costs: tui.getAllSessionCosts(),
1072
+ firstSeen: tui.getAllFirstSeen(),
1073
+ }, now);
1074
+ for (const l of lines)
1075
+ tui.log("system", l);
1076
+ });
1077
+ // wire /flap-log
1078
+ input.onFlapLog(() => {
1079
+ const log = tui.getFlapLog();
1080
+ if (log.length === 0) {
1081
+ tui.log("system", "flap-log: no flap events recorded");
1082
+ return;
1083
+ }
1084
+ tui.log("system", `flap-log: ${log.length} event${log.length !== 1 ? "s" : ""}:`);
1085
+ for (const e of log.slice(-20)) {
1086
+ const time = new Date(e.ts).toLocaleTimeString();
1087
+ tui.log("system", ` ${time} ${e.title}: ${e.count} changes in window`);
1088
+ }
1089
+ });
1090
+ // wire /drain and /undrain
1091
+ input.onDrain((target, drain) => {
1092
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
1093
+ const ok = drain ? tui.drainSession(num ?? target) : tui.undrainSession(num ?? target);
1094
+ if (ok) {
1095
+ tui.log("system", `${drain ? "drain" : "undrain"}: ${target} ${drain ? `marked draining (${DRAIN_ICON})` : "restored"}`);
1096
+ }
1097
+ else {
1098
+ tui.log("system", `session not found: ${target}`);
1099
+ }
1100
+ });
1101
+ // wire /export-all bulk export
1102
+ input.onExportAll(() => {
1103
+ const sessions = tui.getSessions();
1104
+ if (sessions.length === 0) {
1105
+ tui.log("system", "export-all: no sessions");
1106
+ return;
1107
+ }
1108
+ const now = Date.now();
1109
+ const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1110
+ const dir = join(homedir(), ".aoaoe");
1111
+ try {
1112
+ mkdirSync(dir, { recursive: true });
1113
+ // snapshot JSON
1114
+ const snapData = buildSnapshotData(sessions, tui.getAllGroups(), tui.getAllNotes(), tui.getAllFirstSeen(), tui.getSessionErrorCounts(), tui.getAllBurnRates(now), pkg ?? "dev", now);
1115
+ writeFileSync(join(dir, `snapshot-${ts}.json`), formatSnapshotJson(snapData), "utf-8");
1116
+ // stats JSON
1117
+ const statEntries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now, new Map(sessions.map((s) => [s.id, tui.getSessionErrorTimestamps(s.id)])), tui.getAllSessionCosts(), new Map(sessions.map((s) => [s.id, tui.getSessionHealthHistory(s.id)])));
1118
+ writeFileSync(join(dir, `stats-${ts}.json`), formatStatsJson(statEntries, pkg ?? "dev", now), "utf-8");
1119
+ tui.log("system", `export-all: snapshot + stats saved to ~/.aoaoe/ (${sessions.length} sessions)`);
1120
+ }
1121
+ catch (err) {
1122
+ tui.log("error", `export-all failed: ${err}`);
1123
+ }
1124
+ });
1026
1125
  // wire /budget cost alerts
1027
1126
  input.onBudget((target, budgetUSD) => {
1028
1127
  if (budgetUSD === null) {
@@ -2046,6 +2145,7 @@ async function daemonTick(config, poller, reasoner, executor, reasonerConsole, p
2046
2145
  try {
2047
2146
  tickResult = await loopTick({
2048
2147
  config, poller, reasoner: wrappedReasoner, executor, policyStates, pollCount, userMessage, taskContext, beforeExecute,
2148
+ drainingSessionIds: tui ? [...tui.getDrainingIds()] : undefined,
2049
2149
  });
2050
2150
  }
2051
2151
  catch (err) {
package/dist/input.d.ts CHANGED
@@ -62,6 +62,12 @@ export type HealthTrendHandler = (target: string, height: number) => void;
62
62
  export type AlertMuteHandler = (pattern: string | null) => void;
63
63
  export type BudgetsListHandler = () => void;
64
64
  export type BudgetStatusHandler = () => void;
65
+ export type FlapLogHandler = () => void;
66
+ export type DrainHandler = (target: string, drain: boolean) => void;
67
+ export type ExportAllHandler = () => void;
68
+ export type NoteHistoryHandler = (target: string) => void;
69
+ export type LabelHandler = (target: string, label: string) => void;
70
+ export type SessionsTableHandler = () => void;
65
71
  export interface MouseEvent {
66
72
  button: number;
67
73
  col: number;
@@ -146,6 +152,12 @@ export declare class InputReader {
146
152
  private alertMuteHandler;
147
153
  private budgetsListHandler;
148
154
  private budgetStatusHandler;
155
+ private flapLogHandler;
156
+ private drainHandler;
157
+ private exportAllHandler;
158
+ private noteHistoryHandler;
159
+ private labelHandler;
160
+ private sessionsTableHandler;
149
161
  private aliases;
150
162
  private mouseDataListener;
151
163
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -213,6 +225,12 @@ export declare class InputReader {
213
225
  onAlertMute(handler: AlertMuteHandler): void;
214
226
  onBudgetsList(handler: BudgetsListHandler): void;
215
227
  onBudgetStatus(handler: BudgetStatusHandler): void;
228
+ onFlapLog(handler: FlapLogHandler): void;
229
+ onDrain(handler: DrainHandler): void;
230
+ onExportAll(handler: ExportAllHandler): void;
231
+ onNoteHistory(handler: NoteHistoryHandler): void;
232
+ onLabel(handler: LabelHandler): void;
233
+ onSessionsTable(handler: SessionsTableHandler): void;
216
234
  /** Set aliases from persisted prefs. */
217
235
  setAliases(aliases: Record<string, string>): void;
218
236
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -95,6 +95,12 @@ export class InputReader {
95
95
  alertMuteHandler = null;
96
96
  budgetsListHandler = null;
97
97
  budgetStatusHandler = null;
98
+ flapLogHandler = null;
99
+ drainHandler = null;
100
+ exportAllHandler = null;
101
+ noteHistoryHandler = null;
102
+ labelHandler = null;
103
+ sessionsTableHandler = null;
98
104
  aliases = new Map(); // /shortcut → /full command
99
105
  mouseDataListener = null;
100
106
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -315,6 +321,12 @@ export class InputReader {
315
321
  onAlertMute(handler) { this.alertMuteHandler = handler; }
316
322
  onBudgetsList(handler) { this.budgetsListHandler = handler; }
317
323
  onBudgetStatus(handler) { this.budgetStatusHandler = handler; }
324
+ onFlapLog(handler) { this.flapLogHandler = handler; }
325
+ onDrain(handler) { this.drainHandler = handler; }
326
+ onExportAll(handler) { this.exportAllHandler = handler; }
327
+ onNoteHistory(handler) { this.noteHistoryHandler = handler; }
328
+ onLabel(handler) { this.labelHandler = handler; }
329
+ onSessionsTable(handler) { this.sessionsTableHandler = handler; }
318
330
  /** Set aliases from persisted prefs. */
319
331
  setAliases(aliases) {
320
332
  this.aliases.clear();
@@ -585,6 +597,13 @@ ${BOLD}navigation:${RESET}
585
597
  /health-trend N show ASCII health score chart for session N [height]
586
598
  /budgets list all active cost budgets
587
599
  /budget-status show which sessions are over or under budget
600
+ /flap-log show sessions recently flagged as flapping
601
+ /drain N mark session N as draining (supervisor will skip it)
602
+ /undrain N remove drain mark from session N
603
+ /export-all bulk export snapshot + stats JSON for all sessions
604
+ /note-history N show previous notes for a session (before they were cleared)
605
+ /label N [text] set a freeform label shown in the session card (no text = clear)
606
+ /sessions show rich session table (status, health, group, cost, flags)
588
607
  /history-stats show aggregate statistics from persisted activity history
589
608
  /cost-summary show total estimated spend across all sessions
590
609
  /session-report N generate full markdown report for a session → ~/.aoaoe/report-<name>-<ts>.md
@@ -1212,6 +1231,79 @@ ${BOLD}other:${RESET}
1212
1231
  else
1213
1232
  console.error(`${DIM}budgets not available${RESET}`);
1214
1233
  break;
1234
+ case "/flap-log":
1235
+ if (this.flapLogHandler)
1236
+ this.flapLogHandler();
1237
+ else
1238
+ console.error(`${DIM}flap-log not available${RESET}`);
1239
+ break;
1240
+ case "/drain": {
1241
+ const drainArg = line.slice("/drain".length).trim();
1242
+ if (!drainArg) {
1243
+ console.error(`${DIM}usage: /drain <N|name>${RESET}`);
1244
+ break;
1245
+ }
1246
+ if (this.drainHandler)
1247
+ this.drainHandler(drainArg, true);
1248
+ else
1249
+ console.error(`${DIM}drain not available${RESET}`);
1250
+ break;
1251
+ }
1252
+ case "/undrain": {
1253
+ const undrainArg = line.slice("/undrain".length).trim();
1254
+ if (!undrainArg) {
1255
+ console.error(`${DIM}usage: /undrain <N|name>${RESET}`);
1256
+ break;
1257
+ }
1258
+ if (this.drainHandler)
1259
+ this.drainHandler(undrainArg, false);
1260
+ else
1261
+ console.error(`${DIM}undrain not available${RESET}`);
1262
+ break;
1263
+ }
1264
+ case "/note-history": {
1265
+ const nhArg = line.slice("/note-history".length).trim();
1266
+ if (!nhArg) {
1267
+ console.error(`${DIM}usage: /note-history <N|name>${RESET}`);
1268
+ break;
1269
+ }
1270
+ if (this.noteHistoryHandler)
1271
+ this.noteHistoryHandler(nhArg);
1272
+ else
1273
+ console.error(`${DIM}note-history not available${RESET}`);
1274
+ break;
1275
+ }
1276
+ case "/label": {
1277
+ const lblArgs = line.slice("/label".length).trim();
1278
+ if (!lblArgs) {
1279
+ console.error(`${DIM}usage: /label <N|name> [text]${RESET}`);
1280
+ break;
1281
+ }
1282
+ if (this.labelHandler) {
1283
+ const spaceIdx = lblArgs.indexOf(" ");
1284
+ if (spaceIdx > 0) {
1285
+ this.labelHandler(lblArgs.slice(0, spaceIdx), lblArgs.slice(spaceIdx + 1).trim());
1286
+ }
1287
+ else {
1288
+ this.labelHandler(lblArgs, ""); // clear
1289
+ }
1290
+ }
1291
+ else
1292
+ console.error(`${DIM}label not available${RESET}`);
1293
+ break;
1294
+ }
1295
+ case "/sessions":
1296
+ if (this.sessionsTableHandler)
1297
+ this.sessionsTableHandler();
1298
+ else
1299
+ console.error(`${DIM}sessions not available${RESET}`);
1300
+ break;
1301
+ case "/export-all":
1302
+ if (this.exportAllHandler)
1303
+ this.exportAllHandler();
1304
+ else
1305
+ console.error(`${DIM}export-all not available${RESET}`);
1306
+ break;
1215
1307
  case "/budget-status":
1216
1308
  if (this.budgetStatusHandler)
1217
1309
  this.budgetStatusHandler();
package/dist/loop.d.ts CHANGED
@@ -32,5 +32,6 @@ export declare function tick(opts: {
32
32
  userMessage?: string;
33
33
  taskContext?: TaskState[];
34
34
  beforeExecute?: (action: Action) => Promise<boolean>;
35
+ drainingSessionIds?: string[];
35
36
  }): Promise<TickResult>;
36
37
  //# sourceMappingURL=loop.d.ts.map
package/dist/loop.js CHANGED
@@ -7,7 +7,7 @@ import { detectPermissionPrompt } from "./reasoner/prompt.js";
7
7
  * Returns a structured result for testing and inspection.
8
8
  */
9
9
  export async function tick(opts) {
10
- const { config, poller, reasoner, executor, policyStates, pollCount, userMessage, taskContext, beforeExecute } = opts;
10
+ const { config, poller, reasoner, executor, policyStates, pollCount, userMessage, taskContext, beforeExecute, drainingSessionIds } = opts;
11
11
  // 1. poll
12
12
  const observation = await poller.poll();
13
13
  const now = Date.now();
@@ -65,6 +65,10 @@ export async function tick(opts) {
65
65
  if (config.protectedSessions && config.protectedSessions.length > 0) {
66
66
  observation.protectedSessions = config.protectedSessions;
67
67
  }
68
+ // attach draining session IDs for the prompt formatter
69
+ if (drainingSessionIds && drainingSessionIds.length > 0) {
70
+ observation.drainingSessionIds = drainingSessionIds;
71
+ }
68
72
  // 2. reason
69
73
  const result = await reasoner.decide(observation);
70
74
  // 3. execute (skip wait-only results)
@@ -126,6 +126,7 @@ export function formatObservation(obs) {
126
126
  parts.push("");
127
127
  // resolve protected sessions list from config (attached by loop.ts)
128
128
  const protectedList = obs.protectedSessions ?? [];
129
+ const drainingList = new Set(obs.drainingSessionIds ?? []);
129
130
  // session summary table
130
131
  parts.push("Sessions:");
131
132
  const activeSessions = [];
@@ -133,7 +134,8 @@ export function formatObservation(obs) {
133
134
  const s = snap.session;
134
135
  const activeTag = snap.userActive ? " [USER ACTIVE]" : "";
135
136
  const protectedTag = protectedList.some((p) => p.toLowerCase() === s.title.toLowerCase()) ? " [PROTECTED]" : "";
136
- parts.push(` [${s.id.slice(0, 8)}] "${s.title}" tool=${s.tool} status=${s.status} path=${s.path}${activeTag}${protectedTag}`);
137
+ const drainingTag = drainingList.has(s.id) ? " [DRAINING — skip, do not send input]" : "";
138
+ parts.push(` [${s.id.slice(0, 8)}] "${s.title}" tool=${s.tool} status=${s.status} path=${s.path}${activeTag}${protectedTag}${drainingTag}`);
137
139
  if (snap.userActive)
138
140
  activeSessions.push(s.title);
139
141
  }
@@ -142,6 +144,15 @@ export function formatObservation(obs) {
142
144
  parts.push(`WARNING: A human user is currently interacting with: ${activeSessions.join(", ")}.`);
143
145
  parts.push("Do NOT send input to these sessions. The user is actively working and your input would interfere.");
144
146
  }
147
+ if (drainingList.size > 0) {
148
+ const drainingTitles = obs.sessions
149
+ .filter((s) => drainingList.has(s.session.id))
150
+ .map((s) => `"${s.session.title}"`);
151
+ if (drainingTitles.length > 0) {
152
+ parts.push("");
153
+ parts.push(`DRAINING: ${drainingTitles.join(", ")} — do NOT assign new tasks or send_input to these sessions.`);
154
+ }
155
+ }
145
156
  parts.push("");
146
157
  // task context (goals, progress) — injected if tasks are defined
147
158
  if (obs.taskContext && obs.taskContext.length > 0) {
package/dist/tui.d.ts CHANGED
@@ -160,6 +160,30 @@ export declare const FLAP_THRESHOLD = 5;
160
160
  * Returns true when there are >= FLAP_THRESHOLD status changes in FLAP_WINDOW_MS.
161
161
  */
162
162
  export declare function isFlapping(changes: readonly StatusChange[], now?: number, windowMs?: number, threshold?: number): boolean;
163
+ /**
164
+ * Format sessions as a rich table for /sessions command output.
165
+ * Returns array of lines (one per session + header).
166
+ */
167
+ export declare function formatSessionsTable(sessions: readonly DaemonSessionState[], opts: {
168
+ groups: ReadonlyMap<string, string>;
169
+ tags: ReadonlyMap<string, ReadonlySet<string>>;
170
+ colors: ReadonlyMap<string, string>;
171
+ notes: ReadonlyMap<string, string>;
172
+ labels: ReadonlyMap<string, string>;
173
+ aliases: ReadonlyMap<string, string>;
174
+ drainingIds: ReadonlySet<string>;
175
+ healthScores: ReadonlyMap<string, number>;
176
+ costs: ReadonlyMap<string, string>;
177
+ firstSeen: ReadonlyMap<string, number>;
178
+ }, now?: number): string[];
179
+ /** Max notes stored per session (in history before clear). */
180
+ export declare const MAX_NOTE_HISTORY = 5;
181
+ /** Max length of a session label (displayed below title in cards). */
182
+ export declare const MAX_LABEL_LEN = 40;
183
+ /** Truncate a label to the max length. */
184
+ export declare function truncateLabel(label: string): string;
185
+ /** Drain icon shown in session cards for draining sessions. */
186
+ export declare const DRAIN_ICON = "\u21E3";
163
187
  /**
164
188
  * Check if an alert text matches any suppressed pattern (case-insensitive substring).
165
189
  * Returns true when the alert should be hidden.
@@ -440,6 +464,8 @@ export declare class TUI {
440
464
  private quietHoursRanges;
441
465
  private sessionHealthHistory;
442
466
  private alertLog;
467
+ private sessionNoteHistory;
468
+ private sessionLabels;
443
469
  private sessionBudgets;
444
470
  private globalBudget;
445
471
  private budgetAlerted;
@@ -447,6 +473,8 @@ export declare class TUI {
447
473
  private prevSessionStatus;
448
474
  private flapAlerted;
449
475
  private alertMutePatterns;
476
+ private drainingIds;
477
+ private flapLog;
450
478
  private viewMode;
451
479
  private drilldownSessionId;
452
480
  private sessionOutputs;
@@ -527,6 +555,18 @@ export declare class TUI {
527
555
  getNoteCount(): number;
528
556
  /** Return all session notes (for /notes listing). */
529
557
  getAllNotes(): ReadonlyMap<string, string>;
558
+ /** Return note history for a session (oldest first). */
559
+ getNoteHistory(id: string): readonly string[];
560
+ /**
561
+ * Set or clear a label for a session (by 1-indexed number, ID, or title).
562
+ * Label is displayed below the session title in normal cards.
563
+ * Returns true if session found.
564
+ */
565
+ setLabel(sessionIdOrIndex: string | number, label: string | null): boolean;
566
+ /** Get the label for a session ID (or undefined). */
567
+ getLabel(id: string): string | undefined;
568
+ /** Return all session labels. */
569
+ getAllLabels(): ReadonlyMap<string, string>;
530
570
  /** Return the current sessions (read-only, for resolving IDs to titles in the UI). */
531
571
  getSessions(): readonly DaemonSessionState[];
532
572
  /** Return the uptime in ms for a session (0 if not tracked). */
@@ -590,6 +630,21 @@ export declare class TUI {
590
630
  getAllSessionBudgets(): ReadonlyMap<string, number>;
591
631
  /** Return health history for a session (for sparkline). */
592
632
  getSessionHealthHistory(id: string): readonly HealthSnapshot[];
633
+ /** Mark a session as draining (by index, ID, or title). Returns true if found. */
634
+ drainSession(sessionIdOrIndex: string | number): boolean;
635
+ /** Remove drain mark from a session. Returns true if it was draining. */
636
+ undrainSession(sessionIdOrIndex: string | number): boolean;
637
+ /** Check if a session is draining. */
638
+ isDraining(id: string): boolean;
639
+ /** Return all draining session IDs (for reasoner prompt and display). */
640
+ getDrainingIds(): ReadonlySet<string>;
641
+ /** Return recent flap events (newest last). */
642
+ getFlapLog(): readonly {
643
+ sessionId: string;
644
+ title: string;
645
+ ts: number;
646
+ count: number;
647
+ }[];
593
648
  /** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
594
649
  getAlertLog(includeAll?: boolean): readonly ActivityEntry[];
595
650
  /** Return status change history for a session. */
@@ -752,7 +807,7 @@ export declare class TUI {
752
807
  private repaintDrilldownContent;
753
808
  private paintInputLine;
754
809
  }
755
- declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string, ageStr?: string): string;
810
+ declare function formatSessionCard(s: DaemonSessionState, maxWidth: number, errorSparkline?: string, idleSinceMs?: number, healthBadge?: string, displayName?: string, ageStr?: string, label?: string): string;
756
811
  declare function formatActivity(entry: ActivityEntry, maxCols: number): string;
757
812
  declare function padBoxLine(line: string, totalWidth: number): string;
758
813
  declare function padBoxLineHover(line: string, totalWidth: number, hovered: boolean): string;
package/dist/tui.js CHANGED
@@ -481,6 +481,55 @@ export function isFlapping(changes, now, windowMs = FLAP_WINDOW_MS, threshold =
481
481
  const recent = changes.filter((c) => c.ts >= cutoff);
482
482
  return recent.length >= threshold;
483
483
  }
484
+ // ── Sessions table (pure, exported for testing) ────────────────────────────────
485
+ /**
486
+ * Format sessions as a rich table for /sessions command output.
487
+ * Returns array of lines (one per session + header).
488
+ */
489
+ export function formatSessionsTable(sessions, opts, now) {
490
+ if (sessions.length === 0)
491
+ return [" no sessions"];
492
+ const nowMs = now ?? Date.now();
493
+ const lines = [];
494
+ lines.push(` ${"#".padEnd(3)} ${"title".padEnd(20)} ${"status".padEnd(9)} ${"hlth".padEnd(5)} ${"group".padEnd(10)} ${"cost".padEnd(7)} ${"uptime".padEnd(8)} flags`);
495
+ lines.push(` ${"-".repeat(3)} ${"-".repeat(20)} ${"-".repeat(9)} ${"-".repeat(5)} ${"-".repeat(10)} ${"-".repeat(7)} ${"-".repeat(8)} -----`);
496
+ for (let i = 0; i < sessions.length; i++) {
497
+ const s = sessions[i];
498
+ const alias = opts.aliases.get(s.id);
499
+ const titleStr = (alias || s.title).slice(0, 20).padEnd(20);
500
+ const statusStr = s.status.slice(0, 9).padEnd(9);
501
+ const health = opts.healthScores.get(s.id) ?? 100;
502
+ const healthStr = String(health).padEnd(5);
503
+ const group = opts.groups.get(s.id) ?? "";
504
+ const groupStr = group.slice(0, 10).padEnd(10);
505
+ const cost = opts.costs.get(s.id) ?? "";
506
+ const costStr = cost.slice(0, 7).padEnd(7);
507
+ const fs = opts.firstSeen.get(s.id);
508
+ const uptimeStr = fs !== undefined ? formatUptime(nowMs - fs).padEnd(8) : "?".padEnd(8);
509
+ // flags: D=drain, ⊹=tag, ✎=note, ·=label
510
+ const flags = [
511
+ opts.drainingIds.has(s.id) ? "D" : "",
512
+ (opts.tags.get(s.id)?.size ?? 0) > 0 ? "T" : "",
513
+ opts.notes.has(s.id) ? "N" : "",
514
+ opts.labels.has(s.id) ? "L" : "",
515
+ ].filter(Boolean).join("") || "-";
516
+ lines.push(` ${String(i + 1).padEnd(3)} ${titleStr} ${statusStr} ${healthStr} ${groupStr} ${costStr} ${uptimeStr} ${flags}`);
517
+ }
518
+ return lines;
519
+ }
520
+ // ── Session note history ─────────────────────────────────────────────────────
521
+ /** Max notes stored per session (in history before clear). */
522
+ export const MAX_NOTE_HISTORY = 5;
523
+ // ── Session label ────────────────────────────────────────────────────────────
524
+ /** Max length of a session label (displayed below title in cards). */
525
+ export const MAX_LABEL_LEN = 40;
526
+ /** Truncate a label to the max length. */
527
+ export function truncateLabel(label) {
528
+ return label.length > MAX_LABEL_LEN ? label.slice(0, MAX_LABEL_LEN - 2) + ".." : label;
529
+ }
530
+ // ── Session drain mode helpers ────────────────────────────────────────────────
531
+ /** Drain icon shown in session cards for draining sessions. */
532
+ export const DRAIN_ICON = "⇣";
484
533
  // ── Alert mute patterns (pure, exported for testing) ──────────────────────────
485
534
  /**
486
535
  * Check if an alert text matches any suppressed pattern (case-insensitive substring).
@@ -807,7 +856,8 @@ export const BUILTIN_COMMANDS = new Set([
807
856
  "/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
808
857
  "/duplicate", "/color-all", "/quiet-hours", "/quiet-status", "/history-stats", "/cost-summary", "/session-report", "/alert-log",
809
858
  "/budget", "/budgets", "/budget-status", "/pause-all", "/resume-all",
810
- "/health-trend", "/alert-mute",
859
+ "/health-trend", "/alert-mute", "/flap-log", "/drain", "/undrain", "/export-all",
860
+ "/note-history", "/label", "/sessions",
811
861
  ]);
812
862
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
813
863
  export function resolveAlias(line, aliases) {
@@ -1081,6 +1131,8 @@ export class TUI {
1081
1131
  quietHoursRanges = []; // quiet-hour start/end pairs
1082
1132
  sessionHealthHistory = new Map(); // session ID → health snapshots
1083
1133
  alertLog = []; // recent auto-generated status alerts (ring buffer, max 100)
1134
+ sessionNoteHistory = new Map(); // session ID → last N notes before clear
1135
+ sessionLabels = new Map(); // session ID → freeform display label
1084
1136
  sessionBudgets = new Map(); // session ID → USD budget
1085
1137
  globalBudget = null; // global fallback budget in USD
1086
1138
  budgetAlerted = new Map(); // session ID → epoch ms of last budget alert
@@ -1088,6 +1140,8 @@ export class TUI {
1088
1140
  prevSessionStatus = new Map(); // session ID → last known status (for change detection)
1089
1141
  flapAlerted = new Map(); // session ID → epoch ms of last flap alert
1090
1142
  alertMutePatterns = new Set(); // substrings to hide from /alert-log display
1143
+ drainingIds = new Set(); // session IDs marked as draining (skip by reasoner)
1144
+ flapLog = []; // recent flap events
1091
1145
  // drill-down mode: show a single session's full output
1092
1146
  viewMode = "overview";
1093
1147
  drilldownSessionId = null;
@@ -1350,6 +1404,15 @@ export class TUI {
1350
1404
  if (!sessionId)
1351
1405
  return false;
1352
1406
  if (text.trim() === "") {
1407
+ // push current note into history before clearing
1408
+ const current = this.sessionNotes.get(sessionId);
1409
+ if (current) {
1410
+ const hist = this.sessionNoteHistory.get(sessionId) ?? [];
1411
+ hist.push(current);
1412
+ if (hist.length > MAX_NOTE_HISTORY)
1413
+ hist.shift();
1414
+ this.sessionNoteHistory.set(sessionId, hist);
1415
+ }
1353
1416
  this.sessionNotes.delete(sessionId);
1354
1417
  }
1355
1418
  else {
@@ -1375,6 +1438,46 @@ export class TUI {
1375
1438
  getAllNotes() {
1376
1439
  return this.sessionNotes;
1377
1440
  }
1441
+ /** Return note history for a session (oldest first). */
1442
+ getNoteHistory(id) {
1443
+ return this.sessionNoteHistory.get(id) ?? [];
1444
+ }
1445
+ // ── Session labels ───────────────────────────────────────────────────────
1446
+ /**
1447
+ * Set or clear a label for a session (by 1-indexed number, ID, or title).
1448
+ * Label is displayed below the session title in normal cards.
1449
+ * Returns true if session found.
1450
+ */
1451
+ setLabel(sessionIdOrIndex, label) {
1452
+ let sessionId;
1453
+ if (typeof sessionIdOrIndex === "number") {
1454
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1455
+ }
1456
+ else {
1457
+ const needle = sessionIdOrIndex.toLowerCase();
1458
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1459
+ sessionId = match?.id;
1460
+ }
1461
+ if (!sessionId)
1462
+ return false;
1463
+ if (!label || label.trim() === "") {
1464
+ this.sessionLabels.delete(sessionId);
1465
+ }
1466
+ else {
1467
+ this.sessionLabels.set(sessionId, truncateLabel(label.trim()));
1468
+ }
1469
+ if (this.active)
1470
+ this.paintSessions();
1471
+ return true;
1472
+ }
1473
+ /** Get the label for a session ID (or undefined). */
1474
+ getLabel(id) {
1475
+ return this.sessionLabels.get(id);
1476
+ }
1477
+ /** Return all session labels. */
1478
+ getAllLabels() {
1479
+ return this.sessionLabels;
1480
+ }
1378
1481
  /** Return the current sessions (read-only, for resolving IDs to titles in the UI). */
1379
1482
  getSessions() {
1380
1483
  return this.sessions;
@@ -1600,6 +1703,56 @@ export class TUI {
1600
1703
  getSessionHealthHistory(id) {
1601
1704
  return this.sessionHealthHistory.get(id) ?? [];
1602
1705
  }
1706
+ // ── Session drain mode ───────────────────────────────────────────────────
1707
+ /** Mark a session as draining (by index, ID, or title). Returns true if found. */
1708
+ drainSession(sessionIdOrIndex) {
1709
+ let sessionId;
1710
+ if (typeof sessionIdOrIndex === "number") {
1711
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1712
+ }
1713
+ else {
1714
+ const needle = sessionIdOrIndex.toLowerCase();
1715
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1716
+ sessionId = match?.id;
1717
+ }
1718
+ if (!sessionId)
1719
+ return false;
1720
+ this.drainingIds.add(sessionId);
1721
+ if (this.active)
1722
+ this.paintSessions();
1723
+ return true;
1724
+ }
1725
+ /** Remove drain mark from a session. Returns true if it was draining. */
1726
+ undrainSession(sessionIdOrIndex) {
1727
+ let sessionId;
1728
+ if (typeof sessionIdOrIndex === "number") {
1729
+ sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
1730
+ }
1731
+ else {
1732
+ const needle = sessionIdOrIndex.toLowerCase();
1733
+ const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
1734
+ sessionId = match?.id;
1735
+ }
1736
+ if (!sessionId)
1737
+ return false;
1738
+ const had = this.drainingIds.delete(sessionId);
1739
+ if (had && this.active)
1740
+ this.paintSessions();
1741
+ return had;
1742
+ }
1743
+ /** Check if a session is draining. */
1744
+ isDraining(id) {
1745
+ return this.drainingIds.has(id);
1746
+ }
1747
+ /** Return all draining session IDs (for reasoner prompt and display). */
1748
+ getDrainingIds() {
1749
+ return this.drainingIds;
1750
+ }
1751
+ // ── Flap log ─────────────────────────────────────────────────────────────
1752
+ /** Return recent flap events (newest last). */
1753
+ getFlapLog() {
1754
+ return this.flapLog;
1755
+ }
1603
1756
  /** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
1604
1757
  getAlertLog(includeAll = false) {
1605
1758
  if (includeAll || this.alertMutePatterns.size === 0)
@@ -1963,7 +2116,11 @@ export class TUI {
1963
2116
  const lastFlapAlert = this.flapAlerted.get(s.id) ?? 0;
1964
2117
  if (now - lastFlapAlert >= 5 * 60_000) {
1965
2118
  this.flapAlerted.set(s.id, now);
1966
- this.log("status", `flap: ${s.title} is oscillating rapidly (${statusHist.filter((c) => c.ts >= now - FLAP_WINDOW_MS).length} status changes in ${Math.round(FLAP_WINDOW_MS / 60_000)}m)`, s.id);
2119
+ const flapCount = statusHist.filter((c) => c.ts >= now - FLAP_WINDOW_MS).length;
2120
+ this.flapLog.push({ sessionId: s.id, title: s.title, ts: now, count: flapCount });
2121
+ if (this.flapLog.length > 50)
2122
+ this.flapLog.shift();
2123
+ this.log("status", `flap: ${s.title} is oscillating rapidly (${flapCount} status changes in ${Math.round(FLAP_WINDOW_MS / 60_000)}m)`, s.id);
1967
2124
  }
1968
2125
  }
1969
2126
  }
@@ -2563,6 +2720,8 @@ export class TUI {
2563
2720
  const colorName = this.sessionColors.get(s.id);
2564
2721
  const colorDot = colorName ? formatColorDot(colorName) : "";
2565
2722
  const colorDotWidth = colorName ? 2 : 0; // dot + space
2723
+ const draining = this.drainingIds.has(s.id);
2724
+ const drainIcon = draining ? `${DIM}${DRAIN_ICON}${RESET} ` : "";
2566
2725
  const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
2567
2726
  const muteBadgeWidth = muted ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 2 : 0; // "(N)" visible chars, 0 when count is 0
2568
2727
  const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0 ? muteBadgeWidth + 1 : 0; // +1 for trailing space
@@ -2572,10 +2731,11 @@ export class TUI {
2572
2731
  const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
2573
2732
  const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
2574
2733
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2575
- const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth;
2734
+ const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth + (draining ? 2 : 0);
2576
2735
  const cardWidth = innerWidth - 1 - iconsWidth;
2577
2736
  const cardAge = s.createdAt ? formatSessionAge(s.createdAt, nowMs) : undefined;
2578
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName, cardAge || undefined)}`;
2737
+ const sessionLabel = this.sessionLabels.get(s.id);
2738
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${drainIcon}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName, cardAge || undefined, sessionLabel)}`;
2579
2739
  const padded = padBoxLineHover(line, this.cols, isHovered);
2580
2740
  process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
2581
2741
  }
@@ -2643,10 +2803,13 @@ export class TUI {
2643
2803
  const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
2644
2804
  const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
2645
2805
  const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
2646
- const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2;
2806
+ const draining2 = this.drainingIds.has(s.id);
2807
+ const drainIcon2 = draining2 ? `${DIM}${DRAIN_ICON}${RESET} ` : "";
2808
+ const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2 + (draining2 ? 2 : 0);
2647
2809
  const cardWidth = innerWidth - 1 - iconsWidth;
2648
2810
  const cardAge2 = s.createdAt ? formatSessionAge(s.createdAt, nowMs2) : undefined;
2649
- const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2, cardAge2 || undefined)}`;
2811
+ const sessionLabel2 = this.sessionLabels.get(s.id);
2812
+ const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${drainIcon2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2, cardAge2 || undefined, sessionLabel2)}`;
2650
2813
  const padded = padBoxLineHover(line, this.cols, isHovered);
2651
2814
  process.stderr.write(SAVE_CURSOR + moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded + RESTORE_CURSOR);
2652
2815
  }
@@ -2765,7 +2928,8 @@ export class TUI {
2765
2928
  // healthBadge: optional pre-formatted health score badge ("⬡83" colored)
2766
2929
  // displayName: optional custom name override (from /rename)
2767
2930
  // ageStr: optional session age string (from createdAt)
2768
- function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName, ageStr) {
2931
+ // label: optional freeform label shown as DIM subtitle
2932
+ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge, displayName, ageStr, label) {
2769
2933
  const dot = STATUS_DOT[s.status] ?? `${AMBER}${DOT.filled}${RESET}`;
2770
2934
  const title = displayName ?? s.title;
2771
2935
  const name = displayName ? `${BOLD}${displayName}${DIM} (${s.title})${RESET}` : `${BOLD}${s.title}${RESET}`;
@@ -2806,7 +2970,8 @@ function formatSessionCard(s, maxWidth, errorSparkline, idleSinceMs, healthBadge
2806
2970
  desc = `${SLATE}${s.status}${RESET}`;
2807
2971
  }
2808
2972
  const ageSuffix = ageStr ? ` ${DIM}age:${ageStr}${RESET}` : "";
2809
- return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${ageSuffix}${sparkSuffix}`, maxWidth);
2973
+ const labelSuffix = label ? ` ${DIM}· ${label}${RESET}` : "";
2974
+ return truncateAnsi(`${dot} ${healthPrefix}${name} ${toolBadge}${contextBadge} ${SLATE}${BOX.h}${RESET} ${desc}${ageSuffix}${labelSuffix}${sparkSuffix}`, maxWidth);
2810
2975
  }
2811
2976
  // colorize an activity entry based on its tag
2812
2977
  function formatActivity(entry, maxCols) {
package/dist/types.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface Observation {
25
25
  userMessage?: string;
26
26
  taskContext?: TaskState[];
27
27
  protectedSessions?: string[];
28
+ drainingSessionIds?: string[];
28
29
  policyContext?: {
29
30
  policies: AoaoeConfig["policies"];
30
31
  sessionStates: Array<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.153.0",
3
+ "version": "0.160.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",