aoaoe 0.132.0 → 0.139.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.
@@ -5,7 +5,7 @@ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, renameS
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { toDaemonState } from "./types.js";
8
- import { parseTasks, formatTaskList, parseContext } from "./task-parser.js";
8
+ import { parseTasks, formatTaskList, parseContext, parseCost } from "./task-parser.js";
9
9
  // default state directory — overridable via setStateDir() for test isolation
10
10
  let AOAOE_DIR = join(homedir(), ".aoaoe");
11
11
  let STATE_FILE = join(AOAOE_DIR, "daemon-state.json");
@@ -52,6 +52,7 @@ const sessionTasks = new Map();
52
52
  const todoCache = new Map();
53
53
  // cache parsed context token usage for unchanged sessions
54
54
  const contextCache = new Map();
55
+ const costCache = new Map();
55
56
  export function setSessionTask(sessionId, task) {
56
57
  // keep task text short for display
57
58
  sessionTasks.set(sessionId, task.length > 80 ? task.slice(0, 77) + "..." : task);
@@ -108,6 +109,10 @@ export function buildSessionStates(obs) {
108
109
  if (!currentIds.has(id))
109
110
  contextCache.delete(id);
110
111
  }
112
+ for (const id of costCache.keys()) {
113
+ if (!currentIds.has(id))
114
+ costCache.delete(id);
115
+ }
111
116
  // only re-parse TODO items for sessions that have new output
112
117
  const changedIds = new Set(obs.changes.map((c) => c.sessionId));
113
118
  return obs.sessions.map((snap) => {
@@ -126,18 +131,23 @@ export function buildSessionStates(obs) {
126
131
  todoSummary = todoCache.get(s.id);
127
132
  }
128
133
  let contextTokens;
134
+ let costStr;
129
135
  if (changedIds.has(s.id)) {
130
136
  contextTokens = parseContext(snap.output);
131
137
  contextCache.set(s.id, contextTokens);
138
+ costStr = parseCost(snap.output);
139
+ costCache.set(s.id, costStr);
132
140
  }
133
141
  else {
134
142
  contextTokens = contextCache.get(s.id);
143
+ costStr = costCache.get(s.id);
135
144
  }
136
145
  return {
137
146
  id: s.id,
138
147
  title: s.title,
139
148
  tool: s.tool,
140
149
  status: s.status,
150
+ path: snap.session.path,
141
151
  currentTask: sessionTasks.get(s.id),
142
152
  lastActivity: lastActivity && lastActivity.length > 100
143
153
  ? lastActivity.slice(0, 97) + "..."
@@ -145,6 +155,7 @@ export function buildSessionStates(obs) {
145
155
  contextTokens,
146
156
  todoSummary,
147
157
  userActive: snap.userActive ?? false,
158
+ costStr,
148
159
  };
149
160
  });
150
161
  }
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, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName } 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 } 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, searchHistory } from "./tui-history.js";
23
+ import { loadTuiHistory, searchHistory, TUI_HISTORY_FILE, computeHistoryStats } 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";
@@ -194,6 +194,11 @@ async function main() {
194
194
  tui.restoreSessionTags(prefs.sessionTags);
195
195
  if (prefs.sessionColors)
196
196
  tui.restoreSessionColors(prefs.sessionColors);
197
+ if (prefs.quietHours && prefs.quietHours.length > 0) {
198
+ const ranges = prefs.quietHours.map(parseQuietHoursRange).filter(Boolean);
199
+ if (ranges.length > 0)
200
+ tui.setQuietHours(ranges);
201
+ }
197
202
  }
198
203
  const persistPrefs = () => {
199
204
  if (!tui)
@@ -223,6 +228,7 @@ async function main() {
223
228
  sessionAliases: aliasesObj,
224
229
  sessionTags: sTagsObj,
225
230
  sessionColors: sColorsObj,
231
+ quietHours: tui.getQuietHours().map(([s, e]) => `${s}-${e}`),
226
232
  });
227
233
  };
228
234
  if (!useTui) {
@@ -587,8 +593,11 @@ async function main() {
587
593
  for (const s of sorted) {
588
594
  const up = firstSeen.has(s.id) ? formatUptime(now - firstSeen.get(s.id)) : "?";
589
595
  const errCount = errors.get(s.id) ?? 0;
590
- const errStr = errCount > 0 ? ` ${errCount}err` : "";
596
+ const errTs = tui.getSessionErrorTimestamps(s.id);
597
+ const errTrend = errTs.length > 0 ? (computeErrorTrend(errTs, now) === "rising" ? "↑" : computeErrorTrend(errTs, now) === "falling" ? "↓" : "") : "";
598
+ const errStr = errCount > 0 ? ` ${errCount}err${errTrend}` : "";
591
599
  const ctxStr = s.contextTokens ? ` ${s.contextTokens}` : "";
600
+ const costStr = tui.getSessionCost(s.id) ? ` ${tui.getSessionCost(s.id)}` : "";
592
601
  const note = notes.get(s.id);
593
602
  const noteStr = note ? ` "${note}"` : "";
594
603
  const group = groups.get(s.id);
@@ -597,7 +606,7 @@ async function main() {
597
606
  const lastChange = lastChangeAt.get(s.id);
598
607
  const idleStr = (lastChange && (s.status === "idle" || s.status === "stopped" || s.status === "done"))
599
608
  ? ` idle ${formatUptime(now - lastChange)}` : "";
600
- tui.log("system", ` ${s.title}${groupStr} — ${s.status} ${up}${ctxStr}${errStr}${idleStr}${noteStr}`);
609
+ tui.log("system", ` ${s.title}${groupStr} — ${s.status} ${up}${ctxStr}${costStr}${errStr}${idleStr}${noteStr}`);
601
610
  }
602
611
  });
603
612
  // wire /uptime listing
@@ -944,7 +953,7 @@ async function main() {
944
953
  input.onExportStats(() => {
945
954
  const sessions = tui.getSessions();
946
955
  const now = Date.now();
947
- const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
956
+ const entries = 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());
948
957
  const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
949
958
  const dir = join(homedir(), ".aoaoe");
950
959
  const path = join(dir, `stats-${ts}.json`);
@@ -957,6 +966,87 @@ async function main() {
957
966
  tui.log("error", `export-stats failed: ${err}`);
958
967
  }
959
968
  });
969
+ // wire /duplicate — clone a session
970
+ input.onDuplicate((target, newTitle) => {
971
+ const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
972
+ const args = tui.getDuplicateArgs(num ?? target, newTitle || undefined);
973
+ if (!args) {
974
+ tui.log("system", `duplicate failed: session "${target}" not found or has no path/tool`);
975
+ return;
976
+ }
977
+ if (executor) {
978
+ executor.execute([{ action: "create_agent", path: args.path, title: args.title, tool: args.tool }], [])
979
+ .then(() => tui.log("+ action", `duplicate: spawned "${args.title}" (${args.tool}) at ${args.path}`))
980
+ .catch((err) => tui.log("! action", `duplicate failed: ${err}`));
981
+ }
982
+ else {
983
+ tui.log("system", `[dry-run] duplicate: would spawn "${args.title}" (${args.tool}) at ${args.path}`);
984
+ }
985
+ });
986
+ // wire /color-all
987
+ input.onColorAll((colorName) => {
988
+ if (!colorName) {
989
+ const count = tui.setColorAll(null);
990
+ tui.log("system", `color-all: cleared accent color for ${count} sessions`);
991
+ persistPrefs();
992
+ return;
993
+ }
994
+ const err = validateColorName(colorName);
995
+ if (err) {
996
+ tui.log("system", err);
997
+ return;
998
+ }
999
+ const count = tui.setColorAll(colorName);
1000
+ tui.log("system", `color-all: set ${colorName} for ${count} sessions`);
1001
+ persistPrefs();
1002
+ });
1003
+ // wire /quiet-hours
1004
+ input.onQuietHours((specs) => {
1005
+ if (specs.length === 0) {
1006
+ tui.setQuietHours([]);
1007
+ tui.log("system", "quiet hours: cleared — alerts active at all hours");
1008
+ persistPrefs();
1009
+ return;
1010
+ }
1011
+ const ranges = [];
1012
+ for (const spec of specs) {
1013
+ const r = parseQuietHoursRange(spec);
1014
+ if (!r) {
1015
+ tui.log("system", `quiet hours: invalid range "${spec}" — use HH-HH (e.g. 22-06)`);
1016
+ return;
1017
+ }
1018
+ ranges.push(r);
1019
+ }
1020
+ tui.setQuietHours(ranges);
1021
+ tui.log("system", `quiet hours: ${specs.join(", ")} — watchdog+burn alerts suppressed`);
1022
+ persistPrefs();
1023
+ });
1024
+ // wire /history-stats
1025
+ input.onHistoryStats(() => {
1026
+ const all = loadTuiHistory(100_000);
1027
+ if (all.length === 0) {
1028
+ tui.log("system", "history-stats: no history entries found");
1029
+ return;
1030
+ }
1031
+ const stats = computeHistoryStats(all);
1032
+ const oldest = stats.oldestTs ? new Date(stats.oldestTs).toLocaleDateString() : "?";
1033
+ const newest = stats.newestTs ? new Date(stats.newestTs).toLocaleDateString() : "?";
1034
+ tui.log("system", `history-stats: ${stats.totalEntries} entries over ${stats.spanDays} day(s) (${oldest} → ${newest})`);
1035
+ const topTags = Object.entries(stats.tagCounts).slice(0, 5);
1036
+ for (const [tag, count] of topTags) {
1037
+ tui.log("system", ` ${tag}: ${count}`);
1038
+ }
1039
+ });
1040
+ // wire /clear-history
1041
+ input.onClearHistory(() => {
1042
+ try {
1043
+ writeFileSync(TUI_HISTORY_FILE, "", "utf-8");
1044
+ tui.log("system", "history cleared — tui-history.jsonl truncated");
1045
+ }
1046
+ catch (err) {
1047
+ tui.log("error", `clear-history failed: ${err}`);
1048
+ }
1049
+ });
960
1050
  // wire /recall — search persisted history
961
1051
  input.onRecall((keyword, maxResults) => {
962
1052
  const matches = searchHistory(keyword, maxResults);
@@ -977,7 +1067,7 @@ async function main() {
977
1067
  return;
978
1068
  }
979
1069
  const now = Date.now();
980
- const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
1070
+ const entries = 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());
981
1071
  tui.log("system", `/stats — ${entries.length} session${entries.length !== 1 ? "s" : ""}:`);
982
1072
  for (const line of formatSessionStatsLines(entries)) {
983
1073
  tui.log("system", line);
package/dist/input.d.ts CHANGED
@@ -47,6 +47,11 @@ export type FindHandler = (text: string) => void;
47
47
  export type ResetHealthHandler = (target: string) => void;
48
48
  export type TimelineHandler = (target: string, count: number) => void;
49
49
  export type ColorHandler = (target: string, colorName: string) => void;
50
+ export type ClearHistoryHandler = () => void;
51
+ export type DuplicateHandler = (target: string, newTitle: string) => void;
52
+ export type ColorAllHandler = (colorName: string) => void;
53
+ export type QuietHoursHandler = (specs: string[]) => void;
54
+ export type HistoryStatsHandler = () => void;
50
55
  export interface MouseEvent {
51
56
  button: number;
52
57
  col: number;
@@ -116,6 +121,11 @@ export declare class InputReader {
116
121
  private resetHealthHandler;
117
122
  private timelineHandler;
118
123
  private colorHandler;
124
+ private clearHistoryHandler;
125
+ private duplicateHandler;
126
+ private colorAllHandler;
127
+ private quietHoursHandler;
128
+ private historyStatsHandler;
119
129
  private aliases;
120
130
  private mouseDataListener;
121
131
  onScroll(handler: (dir: ScrollDirection) => void): void;
@@ -168,6 +178,11 @@ export declare class InputReader {
168
178
  onResetHealth(handler: ResetHealthHandler): void;
169
179
  onTimeline(handler: TimelineHandler): void;
170
180
  onColor(handler: ColorHandler): void;
181
+ onClearHistory(handler: ClearHistoryHandler): void;
182
+ onDuplicate(handler: DuplicateHandler): void;
183
+ onColorAll(handler: ColorAllHandler): void;
184
+ onQuietHours(handler: QuietHoursHandler): void;
185
+ onHistoryStats(handler: HistoryStatsHandler): void;
171
186
  /** Set aliases from persisted prefs. */
172
187
  setAliases(aliases: Record<string, string>): void;
173
188
  /** Get current aliases as a plain object. */
package/dist/input.js CHANGED
@@ -80,6 +80,11 @@ export class InputReader {
80
80
  resetHealthHandler = null;
81
81
  timelineHandler = null;
82
82
  colorHandler = null;
83
+ clearHistoryHandler = null;
84
+ duplicateHandler = null;
85
+ colorAllHandler = null;
86
+ quietHoursHandler = null;
87
+ historyStatsHandler = null;
83
88
  aliases = new Map(); // /shortcut → /full command
84
89
  mouseDataListener = null;
85
90
  // register a callback for scroll key events (PgUp/PgDn/Home/End)
@@ -282,6 +287,14 @@ export class InputReader {
282
287
  onColor(handler) {
283
288
  this.colorHandler = handler;
284
289
  }
290
+ // register a callback for /clear-history
291
+ onClearHistory(handler) {
292
+ this.clearHistoryHandler = handler;
293
+ }
294
+ onDuplicate(handler) { this.duplicateHandler = handler; }
295
+ onColorAll(handler) { this.colorAllHandler = handler; }
296
+ onQuietHours(handler) { this.quietHoursHandler = handler; }
297
+ onHistoryStats(handler) { this.historyStatsHandler = handler; }
285
298
  /** Set aliases from persisted prefs. */
286
299
  setAliases(aliases) {
287
300
  this.aliases.clear();
@@ -539,6 +552,11 @@ ${BOLD}navigation:${RESET}
539
552
  /reset-health N clear error counts + context history for a session
540
553
  /timeline N [n] show last n activity entries for session N (default 30)
541
554
  /color N [color] set accent color for session (lime/amber/rose/teal/sky/slate; no color = clear)
555
+ /clear-history truncate persisted activity history (tui-history.jsonl)
556
+ /duplicate N [t] spawn a new session cloned from session N (same tool/path)
557
+ /color-all [c] set accent color for all sessions (no color = clear all)
558
+ /quiet-hours [H-H] suppress watchdog+burn alerts during hours (e.g. 22-06; no arg = clear)
559
+ /history-stats show aggregate statistics from persisted activity history
542
560
  /clip [N] copy last N activity entries to clipboard (default 20)
543
561
  /diff N show activity since bookmark N
544
562
  /mark bookmark current activity position
@@ -1045,6 +1063,60 @@ ${BOLD}other:${RESET}
1045
1063
  }
1046
1064
  break;
1047
1065
  }
1066
+ case "/duplicate": {
1067
+ const dupArg = line.slice("/duplicate".length).trim();
1068
+ if (!dupArg) {
1069
+ console.error(`${DIM}usage: /duplicate <N|name> [new-title]${RESET}`);
1070
+ break;
1071
+ }
1072
+ if (this.duplicateHandler) {
1073
+ const spaceIdx = dupArg.indexOf(" ");
1074
+ const target = spaceIdx > 0 ? dupArg.slice(0, spaceIdx) : dupArg;
1075
+ const newTitle = spaceIdx > 0 ? dupArg.slice(spaceIdx + 1).trim() : "";
1076
+ this.duplicateHandler(target, newTitle);
1077
+ }
1078
+ else {
1079
+ console.error(`${DIM}duplicate not available (no TUI)${RESET}`);
1080
+ }
1081
+ break;
1082
+ }
1083
+ case "/color-all": {
1084
+ const caArg = line.slice("/color-all".length).trim().toLowerCase() || "";
1085
+ if (this.colorAllHandler) {
1086
+ this.colorAllHandler(caArg);
1087
+ }
1088
+ else {
1089
+ console.error(`${DIM}color-all not available (no TUI)${RESET}`);
1090
+ }
1091
+ break;
1092
+ }
1093
+ case "/quiet-hours": {
1094
+ const qhArg = line.slice("/quiet-hours".length).trim();
1095
+ if (this.quietHoursHandler) {
1096
+ const specs = qhArg ? qhArg.split(/[\s,]+/).filter(Boolean) : [];
1097
+ this.quietHoursHandler(specs);
1098
+ }
1099
+ else {
1100
+ console.error(`${DIM}quiet-hours not available (no TUI)${RESET}`);
1101
+ }
1102
+ break;
1103
+ }
1104
+ case "/history-stats":
1105
+ if (this.historyStatsHandler) {
1106
+ this.historyStatsHandler();
1107
+ }
1108
+ else {
1109
+ console.error(`${DIM}history-stats not available (no TUI)${RESET}`);
1110
+ }
1111
+ break;
1112
+ case "/clear-history":
1113
+ if (this.clearHistoryHandler) {
1114
+ this.clearHistoryHandler();
1115
+ }
1116
+ else {
1117
+ console.error(`${DIM}clear-history not available (no TUI)${RESET}`);
1118
+ }
1119
+ break;
1048
1120
  case "/reset-health": {
1049
1121
  const rhArg = line.slice("/reset-health".length).trim();
1050
1122
  if (!rhArg) {
@@ -25,6 +25,20 @@ 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
+ export interface HistoryStats {
29
+ totalEntries: number;
30
+ uniqueTags: string[];
31
+ tagCounts: Record<string, number>;
32
+ entriesPerDay: Record<string, number>;
33
+ oldestTs: number | null;
34
+ newestTs: number | null;
35
+ spanDays: number;
36
+ }
37
+ /**
38
+ * Compute aggregate statistics from a list of history entries.
39
+ * Pure function — no file I/O.
40
+ */
41
+ export declare function computeHistoryStats(entries: readonly HistoryEntry[]): HistoryStats;
28
42
  /**
29
43
  * Search history entries by keyword (case-insensitive substring match on text and tag).
30
44
  * Returns up to `maxResults` most recent matching entries (newest last).
@@ -88,6 +88,39 @@ 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
+ * Compute aggregate statistics from a list of history entries.
93
+ * Pure function — no file I/O.
94
+ */
95
+ export function computeHistoryStats(entries) {
96
+ if (entries.length === 0) {
97
+ return { totalEntries: 0, uniqueTags: [], tagCounts: {}, entriesPerDay: {}, oldestTs: null, newestTs: null, spanDays: 0 };
98
+ }
99
+ const tagCounts = {};
100
+ const dayMap = {};
101
+ let oldest = Infinity, newest = -Infinity;
102
+ for (const e of entries) {
103
+ tagCounts[e.tag] = (tagCounts[e.tag] ?? 0) + 1;
104
+ const day = new Date(e.ts).toISOString().slice(0, 10);
105
+ dayMap[day] = (dayMap[day] ?? 0) + 1;
106
+ if (e.ts < oldest)
107
+ oldest = e.ts;
108
+ if (e.ts > newest)
109
+ newest = e.ts;
110
+ }
111
+ // sort tagCounts by count desc
112
+ const sortedTagCounts = Object.fromEntries(Object.entries(tagCounts).sort(([, a], [, b]) => b - a));
113
+ const spanDays = oldest === Infinity ? 0 : Math.max(1, Math.round((newest - oldest) / 86400000));
114
+ return {
115
+ totalEntries: entries.length,
116
+ uniqueTags: Object.keys(sortedTagCounts),
117
+ tagCounts: sortedTagCounts,
118
+ entriesPerDay: dayMap,
119
+ oldestTs: oldest === Infinity ? null : oldest,
120
+ newestTs: newest === -Infinity ? null : newest,
121
+ spanDays,
122
+ };
123
+ }
91
124
  /**
92
125
  * Search history entries by keyword (case-insensitive substring match on text and tag).
93
126
  * Returns up to `maxResults` most recent matching entries (newest last).
package/dist/tui.d.ts CHANGED
@@ -94,6 +94,16 @@ export declare function formatUptime(ms: number): string;
94
94
  * Returns empty string if under threshold (< thresholdMs, default 2 min — not worth showing).
95
95
  */
96
96
  export declare function formatIdleSince(ms: number, thresholdMs?: number): string;
97
+ /** Error trend: compare recent half of window vs older half. */
98
+ export type ErrorTrend = "rising" | "stable" | "falling";
99
+ /**
100
+ * Compute error trend from a list of recent error timestamps.
101
+ * Splits the window in half; if recent half has significantly more → rising, fewer → falling.
102
+ * "Significant" = difference of at least 1 error.
103
+ */
104
+ export declare function computeErrorTrend(timestamps: readonly number[], now?: number, windowMs?: number): ErrorTrend;
105
+ /** Format error trend as a directional arrow. */
106
+ export declare function formatErrorTrend(trend: ErrorTrend): string;
97
107
  /** Default number of entries shown by /timeline. */
98
108
  export declare const TIMELINE_DEFAULT_COUNT = 30;
99
109
  /**
@@ -101,6 +111,26 @@ export declare const TIMELINE_DEFAULT_COUNT = 30;
101
111
  * Returns up to `count` entries.
102
112
  */
103
113
  export declare function filterSessionTimeline(buffer: readonly ActivityEntry[], sessionId: string, count?: number): ActivityEntry[];
114
+ /**
115
+ * Build args for duplicating a session: given a source session, return
116
+ * { path, tool, title } for use in a create_agent action.
117
+ * Returns null if source session not found or path/tool missing.
118
+ */
119
+ export declare function buildDuplicateArgs(sessions: readonly DaemonSessionState[], sessionIdOrIndex: string | number, newTitle?: string): {
120
+ path: string;
121
+ tool: string;
122
+ title: string;
123
+ } | null;
124
+ /**
125
+ * Check whether a given hour (0-23) falls within any quiet-hour range.
126
+ * Ranges are inclusive, e.g. "22-06" wraps midnight.
127
+ */
128
+ export declare function isQuietHour(hour: number, ranges: ReadonlyArray<[number, number]>): boolean;
129
+ /**
130
+ * Parse a quiet-hours string like "22-06" or "09-17" into a [start, end] tuple.
131
+ * Returns null if invalid.
132
+ */
133
+ export declare function parseQuietHoursRange(spec: string): [number, number] | null;
104
134
  /** Supported accent color names for /color command. */
105
135
  export declare const SESSION_COLOR_NAMES: readonly ["lime", "amber", "rose", "teal", "sky", "slate", "indigo", "cyan"];
106
136
  export type SessionColorName = typeof SESSION_COLOR_NAMES[number];
@@ -131,15 +161,17 @@ export interface SessionStatEntry {
131
161
  status: string;
132
162
  health: number;
133
163
  errors: number;
164
+ errorTrend?: ErrorTrend;
134
165
  burnRatePerMin: number | null;
135
166
  contextPct: number | null;
167
+ costStr?: string;
136
168
  uptimeMs: number | null;
137
169
  idleSinceMs: number | null;
138
170
  }
139
171
  /**
140
172
  * Build stats entries for all sessions — pure, testable, no side effects.
141
173
  */
142
- 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[];
174
+ 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, errorTimestamps?: ReadonlyMap<string, readonly number[]>, sessionCosts?: ReadonlyMap<string, string>): SessionStatEntry[];
143
175
  /**
144
176
  * Format session stats entries as a multi-line activity-log-friendly string.
145
177
  * Each line is one session summary.
@@ -246,6 +278,7 @@ export interface TuiPrefs {
246
278
  sessionAliases?: Record<string, string>;
247
279
  sessionTags?: Record<string, string[]>;
248
280
  sessionColors?: Record<string, string>;
281
+ quietHours?: string[];
249
282
  }
250
283
  /** Load persisted TUI preferences. Returns empty object on any error. */
251
284
  export declare function loadTuiPrefs(): TuiPrefs;
@@ -301,6 +334,8 @@ export declare class TUI {
301
334
  private sessionTags;
302
335
  private tagFilter2;
303
336
  private sessionColors;
337
+ private sessionCosts;
338
+ private quietHoursRanges;
304
339
  private viewMode;
305
340
  private drilldownSessionId;
306
341
  private sessionOutputs;
@@ -418,6 +453,24 @@ export declare class TUI {
418
453
  getSessionColor(id: string): string | undefined;
419
454
  getAllSessionColors(): ReadonlyMap<string, string>;
420
455
  restoreSessionColors(colors: Record<string, string>): void;
456
+ /** Set the same accent color on all currently visible sessions (or clear all). */
457
+ setColorAll(colorName: string | null): number;
458
+ /** Set quiet-hours ranges (suppresses watchdog/burn-rate alerts). */
459
+ setQuietHours(ranges: Array<[number, number]>): void;
460
+ /** Get current quiet-hours ranges. */
461
+ getQuietHours(): ReadonlyArray<[number, number]>;
462
+ /** Check if current time is in a quiet-hours window. */
463
+ isCurrentlyQuiet(now?: Date): boolean;
464
+ /** Return the duplicate args for a session (for /duplicate wiring). */
465
+ getDuplicateArgs(sessionIdOrIndex: string | number, newTitle?: string): {
466
+ path: string;
467
+ tool: string;
468
+ title: string;
469
+ } | null;
470
+ /** Get the latest cost string for a session (or undefined). */
471
+ getSessionCost(id: string): string | undefined;
472
+ /** Return all session costs (for /stats). */
473
+ getAllSessionCosts(): ReadonlyMap<string, string>;
421
474
  getSessionTimeline(sessionIdOrIndex: string | number, count?: number): ActivityEntry[] | null;
422
475
  /** Set or clear the freeform tag filter (filters session panel). */
423
476
  setTagFilter2(tag: string | null): void;
package/dist/tui.js CHANGED
@@ -312,6 +312,41 @@ export function formatIdleSince(ms, thresholdMs = 2 * 60_000) {
312
312
  return "";
313
313
  return `idle ${formatUptime(ms)}`;
314
314
  }
315
+ /**
316
+ * Compute error trend from a list of recent error timestamps.
317
+ * Splits the window in half; if recent half has significantly more → rising, fewer → falling.
318
+ * "Significant" = difference of at least 1 error.
319
+ */
320
+ export function computeErrorTrend(timestamps, now, windowMs = 10 * 60_000) {
321
+ if (timestamps.length === 0)
322
+ return "stable";
323
+ const nowMs = now ?? Date.now();
324
+ const cutoff = nowMs - windowMs;
325
+ const halfMs = windowMs / 2;
326
+ const midpoint = nowMs - halfMs;
327
+ let older = 0, newer = 0;
328
+ for (const ts of timestamps) {
329
+ if (ts < cutoff)
330
+ continue;
331
+ if (ts < midpoint)
332
+ older++;
333
+ else
334
+ newer++;
335
+ }
336
+ if (newer > older)
337
+ return "rising";
338
+ if (older > newer)
339
+ return "falling";
340
+ return "stable";
341
+ }
342
+ /** Format error trend as a directional arrow. */
343
+ export function formatErrorTrend(trend) {
344
+ switch (trend) {
345
+ case "rising": return `${ROSE}↑${RESET}`;
346
+ case "falling": return `${LIME}↓${RESET}`;
347
+ default: return `${SLATE}→${RESET}`;
348
+ }
349
+ }
315
350
  // ── Session timeline (pure, exported for testing) ────────────────────────────
316
351
  /** Default number of entries shown by /timeline. */
317
352
  export const TIMELINE_DEFAULT_COUNT = 30;
@@ -323,6 +358,62 @@ export function filterSessionTimeline(buffer, sessionId, count = TIMELINE_DEFAUL
323
358
  const matching = buffer.filter((e) => e.sessionId === sessionId);
324
359
  return matching.slice(-count);
325
360
  }
361
+ // ── Duplicate session helpers (pure, exported for testing) ───────────────────
362
+ /**
363
+ * Build args for duplicating a session: given a source session, return
364
+ * { path, tool, title } for use in a create_agent action.
365
+ * Returns null if source session not found or path/tool missing.
366
+ */
367
+ export function buildDuplicateArgs(sessions, sessionIdOrIndex, newTitle) {
368
+ let s;
369
+ if (typeof sessionIdOrIndex === "number") {
370
+ s = sessions[sessionIdOrIndex - 1];
371
+ }
372
+ else {
373
+ const needle = sessionIdOrIndex.toLowerCase();
374
+ s = sessions.find((x) => x.id === sessionIdOrIndex || x.id.startsWith(needle) || x.title.toLowerCase() === needle);
375
+ }
376
+ if (!s || !s.path || !s.tool)
377
+ return null;
378
+ return {
379
+ path: s.path,
380
+ tool: s.tool,
381
+ title: newTitle?.trim() || `${s.title}-copy`,
382
+ };
383
+ }
384
+ // ── Quiet hours (pure, exported for testing) ──────────────────────────────────
385
+ /**
386
+ * Check whether a given hour (0-23) falls within any quiet-hour range.
387
+ * Ranges are inclusive, e.g. "22-06" wraps midnight.
388
+ */
389
+ export function isQuietHour(hour, ranges) {
390
+ for (const [start, end] of ranges) {
391
+ if (start <= end) {
392
+ if (hour >= start && hour <= end)
393
+ return true;
394
+ }
395
+ else {
396
+ // wraps midnight
397
+ if (hour >= start || hour <= end)
398
+ return true;
399
+ }
400
+ }
401
+ return false;
402
+ }
403
+ /**
404
+ * Parse a quiet-hours string like "22-06" or "09-17" into a [start, end] tuple.
405
+ * Returns null if invalid.
406
+ */
407
+ export function parseQuietHoursRange(spec) {
408
+ const m = spec.trim().match(/^(\d{1,2})-(\d{1,2})$/);
409
+ if (!m)
410
+ return null;
411
+ const start = parseInt(m[1], 10);
412
+ const end = parseInt(m[2], 10);
413
+ if (start < 0 || start > 23 || end < 0 || end > 23)
414
+ return null;
415
+ return [start, end];
416
+ }
326
417
  // ── Session accent colors (pure, exported for testing) ────────────────────────
327
418
  /** Supported accent color names for /color command. */
328
419
  export const SESSION_COLOR_NAMES = ["lime", "amber", "rose", "teal", "sky", "slate", "indigo", "cyan"];
@@ -393,21 +484,25 @@ export function formatSessionTagsBadge(tags) {
393
484
  /**
394
485
  * Build stats entries for all sessions — pure, testable, no side effects.
395
486
  */
396
- export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now) {
487
+ export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now, errorTimestamps, sessionCosts) {
397
488
  const nowMs = now ?? Date.now();
398
489
  return sessions.map((s) => {
399
490
  const ceiling = parseContextCeiling(s.contextTokens);
400
491
  const ctxPct = ceiling ? Math.round((ceiling.current / ceiling.max) * 100) : null;
401
492
  const fs = firstSeen.get(s.id);
402
493
  const lc = lastChangeAt.get(s.id);
494
+ const errTs = errorTimestamps?.get(s.id);
495
+ const errTrend = errTs ? computeErrorTrend(errTs, nowMs) : undefined;
403
496
  return {
404
497
  title: s.title,
405
498
  displayName: sessionAliases.get(s.id),
406
499
  status: s.status,
407
500
  health: healthScores.get(s.id) ?? 100,
408
501
  errors: errorCounts.get(s.id) ?? 0,
502
+ errorTrend: errTrend,
409
503
  burnRatePerMin: burnRates.get(s.id) ?? null,
410
504
  contextPct: ctxPct,
505
+ costStr: sessionCosts?.get(s.id),
411
506
  uptimeMs: fs !== undefined ? nowMs - fs : null,
412
507
  idleSinceMs: lc !== undefined ? nowMs - lc : null,
413
508
  };
@@ -423,13 +518,15 @@ export function formatSessionStatsLines(entries) {
423
518
  return entries.map((e) => {
424
519
  const label = e.displayName ? `${e.displayName} (${e.title})` : e.title;
425
520
  const healthStr = `⬡${e.health}`;
426
- const errStr = e.errors > 0 ? ` ${e.errors}err` : "";
521
+ const trendStr = e.errorTrend ? ` ${e.errorTrend === "rising" ? "↑" : e.errorTrend === "falling" ? "↓" : "→"}` : "";
522
+ const errStr = e.errors > 0 ? ` ${e.errors}err${trendStr}` : "";
427
523
  const burnStr = e.burnRatePerMin !== null && e.burnRatePerMin > 0
428
524
  ? ` ${Math.round(e.burnRatePerMin / 100) * 100}tok/min` : "";
429
525
  const ctxStr = e.contextPct !== null ? ` ctx:${e.contextPct}%` : "";
526
+ const costStr = e.costStr ? ` ${e.costStr}` : "";
430
527
  const upStr = e.uptimeMs !== null ? ` up:${formatUptime(e.uptimeMs)}` : "";
431
528
  const idleStr = e.idleSinceMs !== null ? ` ${formatIdleSince(e.idleSinceMs)}` : "";
432
- return ` ${label} [${e.status}] ${healthStr}${errStr}${burnStr}${ctxStr}${upStr}${idleStr}`;
529
+ return ` ${label} [${e.status}] ${healthStr}${errStr}${burnStr}${ctxStr}${costStr}${upStr}${idleStr}`;
433
530
  });
434
531
  }
435
532
  /** Format session stats entries as a JSON object for export. */
@@ -463,7 +560,8 @@ export const BUILTIN_COMMANDS = new Set([
463
560
  "/uptime", "/auto-pin", "/note", "/notes", "/clip", "/diff", "/mark",
464
561
  "/jump", "/marks", "/search", "/alias", "/insist", "/task", "/tasks",
465
562
  "/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
466
- "/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color",
563
+ "/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
564
+ "/duplicate", "/color-all", "/quiet-hours", "/history-stats",
467
565
  ]);
468
566
  /** Resolve a slash command through the alias map. Returns the expanded command or the original. */
469
567
  export function resolveAlias(line, aliases) {
@@ -733,6 +831,8 @@ export class TUI {
733
831
  sessionTags = new Map(); // session ID → freeform tag set
734
832
  tagFilter2 = null; // active freeform tag filter on session panel
735
833
  sessionColors = new Map(); // session ID → accent color name
834
+ sessionCosts = new Map(); // session ID → latest cost string ("$N.NN")
835
+ quietHoursRanges = []; // quiet-hour start/end pairs
736
836
  // drill-down mode: show a single session's full output
737
837
  viewMode = "overview";
738
838
  drilldownSessionId = null;
@@ -1168,7 +1268,51 @@ export class TUI {
1168
1268
  for (const [id, c] of Object.entries(colors))
1169
1269
  this.sessionColors.set(id, c);
1170
1270
  }
1271
+ /** Set the same accent color on all currently visible sessions (or clear all). */
1272
+ setColorAll(colorName) {
1273
+ let count = 0;
1274
+ for (const s of this.sessions) {
1275
+ if (!colorName) {
1276
+ this.sessionColors.delete(s.id);
1277
+ }
1278
+ else {
1279
+ this.sessionColors.set(s.id, colorName);
1280
+ }
1281
+ count++;
1282
+ }
1283
+ if (count > 0 && this.active)
1284
+ this.paintSessions();
1285
+ return count;
1286
+ }
1287
+ // ── Quiet hours ──────────────────────────────────────────────────────────
1288
+ /** Set quiet-hours ranges (suppresses watchdog/burn-rate alerts). */
1289
+ setQuietHours(ranges) {
1290
+ this.quietHoursRanges = ranges;
1291
+ }
1292
+ /** Get current quiet-hours ranges. */
1293
+ getQuietHours() {
1294
+ return this.quietHoursRanges;
1295
+ }
1296
+ /** Check if current time is in a quiet-hours window. */
1297
+ isCurrentlyQuiet(now) {
1298
+ if (this.quietHoursRanges.length === 0)
1299
+ return false;
1300
+ const hour = (now ?? new Date()).getHours();
1301
+ return isQuietHour(hour, this.quietHoursRanges);
1302
+ }
1303
+ /** Return the duplicate args for a session (for /duplicate wiring). */
1304
+ getDuplicateArgs(sessionIdOrIndex, newTitle) {
1305
+ return buildDuplicateArgs(this.sessions, sessionIdOrIndex, newTitle);
1306
+ }
1171
1307
  // ── Session timeline ─────────────────────────────────────────────────────
1308
+ /** Get the latest cost string for a session (or undefined). */
1309
+ getSessionCost(id) {
1310
+ return this.sessionCosts.get(id);
1311
+ }
1312
+ /** Return all session costs (for /stats). */
1313
+ getAllSessionCosts() {
1314
+ return this.sessionCosts;
1315
+ }
1172
1316
  getSessionTimeline(sessionIdOrIndex, count = TIMELINE_DEFAULT_COUNT) {
1173
1317
  let sessionId;
1174
1318
  if (typeof sessionIdOrIndex === "number") {
@@ -1475,6 +1619,7 @@ export class TUI {
1475
1619
  // track activity changes for sort-by-activity + first-seen for uptime
1476
1620
  const now = Date.now();
1477
1621
  const BURN_ALERT_COOLDOWN_MS = 5 * 60 * 1000; // max one alert per session per 5 minutes
1622
+ const quietNow = this.isCurrentlyQuiet(new Date(now));
1478
1623
  for (const s of opts.sessions) {
1479
1624
  if (!this.sessionFirstSeen.has(s.id))
1480
1625
  this.sessionFirstSeen.set(s.id, now);
@@ -1484,6 +1629,9 @@ export class TUI {
1484
1629
  }
1485
1630
  if (s.lastActivity !== undefined)
1486
1631
  this.prevLastActivity.set(s.id, s.lastActivity);
1632
+ // track cost string
1633
+ if (s.costStr)
1634
+ this.sessionCosts.set(s.id, s.costStr);
1487
1635
  // track context token history for burn-rate alerts
1488
1636
  const tokens = parseContextTokenNumber(s.contextTokens);
1489
1637
  if (tokens !== null) {
@@ -1494,7 +1642,7 @@ export class TUI {
1494
1642
  this.sessionContextHistory.set(s.id, hist);
1495
1643
  // check burn rate and emit alert if above threshold (with cooldown)
1496
1644
  const burnRate = computeContextBurnRate(hist, now);
1497
- if (burnRate !== null && burnRate > CONTEXT_BURN_THRESHOLD) {
1645
+ if (burnRate !== null && burnRate > CONTEXT_BURN_THRESHOLD && !quietNow) {
1498
1646
  const lastAlert = this.burnRateAlerted.get(s.id) ?? 0;
1499
1647
  if (now - lastAlert >= BURN_ALERT_COOLDOWN_MS) {
1500
1648
  this.burnRateAlerted.set(s.id, now);
@@ -1504,7 +1652,7 @@ export class TUI {
1504
1652
  }
1505
1653
  // check context ceiling and emit alert if above threshold (with cooldown)
1506
1654
  const ceiling = parseContextCeiling(s.contextTokens);
1507
- if (ceiling !== null && ceiling.current / ceiling.max >= CONTEXT_CEILING_THRESHOLD) {
1655
+ if (ceiling !== null && ceiling.current / ceiling.max >= CONTEXT_CEILING_THRESHOLD && !quietNow) {
1508
1656
  const lastCeiling = this.ceilingAlerted.get(s.id) ?? 0;
1509
1657
  if (now - lastCeiling >= BURN_ALERT_COOLDOWN_MS) {
1510
1658
  this.ceilingAlerted.set(s.id, now);
@@ -1512,8 +1660,8 @@ export class TUI {
1512
1660
  }
1513
1661
  }
1514
1662
  }
1515
- // watchdog: alert if session has been idle longer than threshold
1516
- if (this.watchdogThresholdMs !== null) {
1663
+ // watchdog: alert if session has been idle longer than threshold (skip during quiet hours)
1664
+ if (this.watchdogThresholdMs !== null && !quietNow) {
1517
1665
  for (const s of opts.sessions) {
1518
1666
  const lastChange = this.lastChangeAt.get(s.id);
1519
1667
  if (lastChange === undefined)
package/dist/types.d.ts CHANGED
@@ -136,6 +136,8 @@ export interface DaemonSessionState {
136
136
  contextTokens?: string;
137
137
  todoSummary?: string;
138
138
  userActive?: boolean;
139
+ costStr?: string;
140
+ path?: string;
139
141
  }
140
142
  export interface DaemonState {
141
143
  tickStartedAt: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.132.0",
3
+ "version": "0.139.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",