aoaoe 0.46.0 → 0.48.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/colors.d.ts CHANGED
@@ -6,5 +6,35 @@ export declare const GREEN = "\u001B[32m";
6
6
  export declare const YELLOW = "\u001B[33m";
7
7
  export declare const CYAN = "\u001B[36m";
8
8
  export declare const WHITE = "\u001B[37m";
9
+ export declare const INDIGO = "\u001B[38;5;105m";
10
+ export declare const TEAL = "\u001B[38;5;73m";
11
+ export declare const AMBER = "\u001B[38;5;214m";
12
+ export declare const SLATE = "\u001B[38;5;245m";
13
+ export declare const ROSE = "\u001B[38;5;204m";
14
+ export declare const LIME = "\u001B[38;5;114m";
15
+ export declare const SKY = "\u001B[38;5;117m";
9
16
  export declare const BG_DARK = "\u001B[48;5;236m";
17
+ export declare const BOX: {
18
+ readonly tl: "┌";
19
+ readonly tr: "┐";
20
+ readonly bl: "└";
21
+ readonly br: "┘";
22
+ readonly h: "─";
23
+ readonly v: "│";
24
+ readonly ltee: "├";
25
+ readonly rtee: "┤";
26
+ readonly ttee: "┬";
27
+ readonly btee: "┴";
28
+ readonly cross: "┼";
29
+ readonly rtl: "╭";
30
+ readonly rtr: "╮";
31
+ readonly rbl: "╰";
32
+ readonly rbr: "╯";
33
+ };
34
+ export declare const SPINNER: readonly ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
35
+ export declare const DOT: {
36
+ readonly filled: "●";
37
+ readonly hollow: "○";
38
+ readonly half: "◐";
39
+ };
10
40
  //# sourceMappingURL=colors.d.ts.map
package/dist/colors.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // shared ANSI color constants — single source of truth for all CLI output
2
+ // basic 16-color
2
3
  export const RESET = "\x1b[0m";
3
4
  export const BOLD = "\x1b[1m";
4
5
  export const DIM = "\x1b[2m";
@@ -7,5 +8,27 @@ export const GREEN = "\x1b[32m";
7
8
  export const YELLOW = "\x1b[33m";
8
9
  export const CYAN = "\x1b[36m";
9
10
  export const WHITE = "\x1b[37m";
10
- export const BG_DARK = "\x1b[48;5;236m";
11
+ // 256-color palette tasteful accents for the TUI
12
+ // use sparingly: these are highlights, not defaults
13
+ export const INDIGO = "\x1b[38;5;105m"; // muted purple-blue for branding
14
+ export const TEAL = "\x1b[38;5;73m"; // cool blue-green for info
15
+ export const AMBER = "\x1b[38;5;214m"; // warm orange for warnings/active
16
+ export const SLATE = "\x1b[38;5;245m"; // neutral gray for secondary text
17
+ export const ROSE = "\x1b[38;5;204m"; // soft red for errors
18
+ export const LIME = "\x1b[38;5;114m"; // fresh green for success/working
19
+ export const SKY = "\x1b[38;5;117m"; // light blue for reasoning
20
+ // background variants (256-color)
21
+ export const BG_DARK = "\x1b[48;5;236m"; // dark gray for header bar
22
+ // box-drawing characters — Unicode block elements
23
+ export const BOX = {
24
+ tl: "┌", tr: "┐", bl: "└", br: "┘",
25
+ h: "─", v: "│",
26
+ ltee: "├", rtee: "┤", ttee: "┬", btee: "┴", cross: "┼",
27
+ // rounded corners (softer look)
28
+ rtl: "╭", rtr: "╮", rbl: "╰", rbr: "╯",
29
+ };
30
+ // braille spinner frames for phase animation
31
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
32
+ // status dots — filled circle variants
33
+ export const DOT = { filled: "●", hollow: "○", half: "◐" };
11
34
  //# sourceMappingURL=colors.js.map
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import { resolve, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { execFile as execFileCb } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
+ import { toReasonerBackend } from "./types.js";
6
7
  const execFileAsync = promisify(execFileCb);
7
8
  const AOAOE_DIR = join(homedir(), ".aoaoe");
8
9
  const CONFIG_NAMES = ["aoaoe.config.json", ".aoaoe.json"];
@@ -250,7 +251,7 @@ export function parseCliArgs(argv) {
250
251
  const arg = argv[i];
251
252
  switch (arg) {
252
253
  case "--reasoner":
253
- overrides.reasoner = nextArg(i, arg);
254
+ overrides.reasoner = toReasonerBackend(nextArg(i, arg));
254
255
  i++;
255
256
  break;
256
257
  case "--poll-interval": {
@@ -4,6 +4,7 @@
4
4
  import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, renameSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ import { toDaemonState } from "./types.js";
7
8
  import { parseTasks, formatTaskList } from "./task-parser.js";
8
9
  const AOAOE_DIR = join(homedir(), ".aoaoe");
9
10
  const STATE_FILE = join(AOAOE_DIR, "daemon-state.json");
@@ -122,7 +123,7 @@ export function readState() {
122
123
  try {
123
124
  if (!existsSync(STATE_FILE))
124
125
  return null;
125
- return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
126
+ return toDaemonState(JSON.parse(readFileSync(STATE_FILE, "utf-8")));
126
127
  }
127
128
  catch {
128
129
  return null;
package/dist/index.js CHANGED
@@ -467,6 +467,8 @@ async function main() {
467
467
  else {
468
468
  // ── normal mode: full tick ─────────────────────────────────────────
469
469
  const activeTaskContext = taskManager ? taskManager.tasks.filter((t) => t.status !== "completed") : undefined;
470
+ if (!reasoner || !executor)
471
+ throw new Error("reasoner/executor unexpectedly null in normal mode");
470
472
  const { interrupted, decisionsThisTick, actionsOk, actionsFail } = await daemonTick(config, poller, reasoner, executor, reasonerConsole, pollCount, policyStates, userMessage, forceDashboard, activeTaskContext, taskManager, tui);
471
473
  totalDecisions += decisionsThisTick;
472
474
  totalActionsExecuted += actionsOk;
package/dist/init.js CHANGED
@@ -36,7 +36,10 @@ async function discoverSessions() {
36
36
  if (result.exitCode !== 0)
37
37
  return [];
38
38
  try {
39
- const raw = JSON.parse(result.stdout);
39
+ const parsed = JSON.parse(result.stdout);
40
+ if (!Array.isArray(parsed))
41
+ return [];
42
+ const raw = parsed;
40
43
  // fetch status for each session in parallel (allSettled so one failure doesn't kill all)
41
44
  const results = await Promise.allSettled(raw.map(async (r) => {
42
45
  const id = String(r.id ?? "");
@@ -78,7 +78,7 @@ function checkAndClear() {
78
78
  .pop() || '';
79
79
  appendFileSync(logFile,
80
80
  new Date().toISOString() + ' CLEARED: ' + line.trim().slice(0, 120) + '\\n');
81
- } catch(e) {}
81
+ } catch {}
82
82
  }
83
83
  }
84
84
  }
@@ -80,6 +80,8 @@ export class OpencodeReasoner {
80
80
  }
81
81
  }
82
82
  async decideViaSDK(prompt, signal) {
83
+ if (!this.client)
84
+ throw new Error("decideViaSDK called without a connected client");
83
85
  const client = this.client;
84
86
  // create session on first call, after rotation, or after error reset
85
87
  if (!this.sessionId) {
@@ -281,7 +283,7 @@ class OpencodeClient {
281
283
  // extract text from assistant response parts
282
284
  const textParts = (data.parts ?? [])
283
285
  .filter((p) => p.type === "text" && p.text)
284
- .map((p) => p.text);
286
+ .map((p) => p.text ?? "");
285
287
  return textParts.join("\n");
286
288
  }
287
289
  }
@@ -160,7 +160,7 @@ export function formatObservation(obs) {
160
160
  parts.push("Project context for sessions:");
161
161
  let contextBudget = 50_000; // max bytes for all project context combined
162
162
  for (const snap of sortedContextSessions) {
163
- const ctx = snap.projectContext;
163
+ const ctx = snap.projectContext ?? "";
164
164
  const ctxBytes = Buffer.byteLength(ctx, "utf-8");
165
165
  if (ctxBytes > contextBudget) {
166
166
  // truncate this context to fit remaining budget
package/dist/task-cli.js CHANGED
@@ -4,6 +4,7 @@ import { exec } from "./shell.js";
4
4
  import { existsSync } from "node:fs";
5
5
  import { resolve, basename } from "node:path";
6
6
  import { loadTaskState, saveTaskState, formatTaskTable } from "./task-manager.js";
7
+ import { toAoeSessionList } from "./types.js";
7
8
  import { BOLD, DIM, GREEN, YELLOW, RED, RESET } from "./colors.js";
8
9
  // resolve a fuzzy reference to a task: match by title, repo basename, or session ID prefix
9
10
  export function resolveTask(ref, tasks) {
@@ -112,7 +113,7 @@ export async function taskNew(title, path, tool = "opencode") {
112
113
  let sessionId;
113
114
  if (listResult.exitCode === 0) {
114
115
  try {
115
- const sessions = JSON.parse(listResult.stdout);
116
+ const sessions = toAoeSessionList(JSON.parse(listResult.stdout));
116
117
  const found = sessions.find((s) => s.title.toLowerCase() === lower);
117
118
  sessionId = found?.id;
118
119
  }
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
5
5
  import { join, resolve, basename } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { exec } from "./shell.js";
8
+ import { toTaskState, toAoeSessionList } from "./types.js";
8
9
  import { RESET, BOLD, DIM, GREEN, YELLOW, RED, CYAN } from "./colors.js";
9
10
  const AOAOE_DIR = join(homedir(), ".aoaoe");
10
11
  const STATE_FILE = join(AOAOE_DIR, "task-state.json");
@@ -84,8 +85,9 @@ export function loadTaskState() {
84
85
  const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
85
86
  if (raw && typeof raw.tasks === "object") {
86
87
  for (const [repo, state] of Object.entries(raw.tasks)) {
87
- if (state && typeof state === "object") {
88
- map.set(repo, state);
88
+ const validated = toTaskState(state);
89
+ if (validated) {
90
+ map.set(repo, validated);
89
91
  }
90
92
  }
91
93
  }
@@ -138,10 +140,12 @@ export class TaskManager {
138
140
  else {
139
141
  // update goal/tool if definition changed (don't reset progress)
140
142
  const existing = this.states.get(def.repo);
141
- if (def.goal)
142
- existing.goal = def.goal;
143
- if (def.tool)
144
- existing.tool = def.tool;
143
+ if (existing) {
144
+ if (def.goal)
145
+ existing.goal = def.goal;
146
+ if (def.tool)
147
+ existing.tool = def.tool;
148
+ }
145
149
  }
146
150
  }
147
151
  this.save();
@@ -201,7 +205,7 @@ export class TaskManager {
201
205
  const refreshResult = await exec("aoe", ["list", "--json"]);
202
206
  if (refreshResult.exitCode === 0) {
203
207
  try {
204
- const refreshed = JSON.parse(refreshResult.stdout);
208
+ const refreshed = toAoeSessionList(JSON.parse(refreshResult.stdout));
205
209
  const newSession = refreshed.find((s) => s.title.toLowerCase() === task.sessionTitle.toLowerCase());
206
210
  if (newSession) {
207
211
  task.sessionId = newSession.id;
package/dist/tui.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DaemonSessionState, DaemonPhase } from "./types.js";
2
+ declare function phaseDisplay(phase: DaemonPhase, paused: boolean, spinnerFrame: number): string;
2
3
  export interface ActivityEntry {
3
4
  time: string;
4
5
  tag: string;
@@ -17,6 +18,7 @@ export declare class TUI {
17
18
  private inputRow;
18
19
  private activityBuffer;
19
20
  private maxActivity;
21
+ private spinnerFrame;
20
22
  private phase;
21
23
  private pollCount;
22
24
  private sessions;
@@ -47,17 +49,17 @@ export declare class TUI {
47
49
  private repaintActivityRegion;
48
50
  private paintInputLine;
49
51
  }
52
+ declare function formatSessionCard(s: DaemonSessionState, maxWidth: number): string;
50
53
  declare function formatActivity(entry: ActivityEntry, maxCols: number): string;
54
+ declare function padBoxLine(line: string, totalWidth: number): string;
55
+ declare function padToWidth(line: string, totalWidth: number): string;
56
+ declare function stripAnsiForLen(str: string): number;
51
57
  declare function truncateAnsi(str: string, maxCols: number): string;
52
58
  declare function truncatePlain(str: string, max: number): string;
53
59
  /**
54
60
  * Format a session state as a plain-English sentence.
55
- * Examples:
56
- * "~ Adventure (opencode) — working on authentication"
57
- * "! Cloud Hypervisor (opencode) — error"
58
- * ". CHV (claude) — idle"
59
- * "~ Adventure (opencode) — you're working here"
61
+ * Kept for backward compatibility — used by non-TUI output paths.
60
62
  */
61
63
  export declare function formatSessionSentence(s: DaemonSessionState, maxCols: number): string;
62
- export { formatActivity, truncateAnsi, truncatePlain };
64
+ export { formatActivity, formatSessionCard, truncateAnsi, truncatePlain, padBoxLine, padToWidth, stripAnsiForLen, phaseDisplay };
63
65
  //# sourceMappingURL=tui.d.ts.map
package/dist/tui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BOLD, DIM, RESET, GREEN, YELLOW, RED, CYAN, WHITE, BG_DARK } from "./colors.js";
1
+ import { BOLD, DIM, RESET, GREEN, CYAN, WHITE, BG_DARK, INDIGO, TEAL, AMBER, SLATE, ROSE, LIME, SKY, BOX, SPINNER, DOT, } from "./colors.js";
2
2
  // ── ANSI helpers ────────────────────────────────────────────────────────────
3
3
  const ESC = "\x1b";
4
4
  const CSI = `${ESC}[`;
@@ -15,15 +15,30 @@ const RESTORE_CURSOR = `${ESC}8`;
15
15
  const moveTo = (row, col) => `${CSI}${row};${col}H`;
16
16
  const setScrollRegion = (top, bottom) => `${CSI}${top};${bottom}r`;
17
17
  const resetScrollRegion = () => `${CSI}r`;
18
- // status icons
19
- const STATUS_ICONS = {
20
- working: `${GREEN}~${RESET}`,
21
- idle: `${DIM}.${RESET}`,
22
- waiting: `${YELLOW}~${RESET}`,
23
- done: `${GREEN}+${RESET}`,
24
- error: `${RED}!${RESET}`,
25
- stopped: `${DIM}x${RESET}`,
18
+ // ── Status rendering ────────────────────────────────────────────────────────
19
+ const STATUS_DOT = {
20
+ working: `${LIME}${DOT.filled}${RESET}`,
21
+ running: `${LIME}${DOT.filled}${RESET}`,
22
+ idle: `${SLATE}${DOT.hollow}${RESET}`,
23
+ waiting: `${AMBER}${DOT.half}${RESET}`,
24
+ done: `${GREEN}${DOT.filled}${RESET}`,
25
+ error: `${ROSE}${DOT.filled}${RESET}`,
26
+ stopped: `${SLATE}${DOT.hollow}${RESET}`,
26
27
  };
28
+ // phase colors and labels
29
+ function phaseDisplay(phase, paused, spinnerFrame) {
30
+ if (paused)
31
+ return `${AMBER}${BOLD}PAUSED${RESET}`;
32
+ const frame = SPINNER[spinnerFrame % SPINNER.length];
33
+ switch (phase) {
34
+ case "reasoning": return `${SKY}${frame} reasoning${RESET}`;
35
+ case "executing": return `${AMBER}${frame} executing${RESET}`;
36
+ case "polling": return `${LIME}${frame} polling${RESET}`;
37
+ case "interrupted": return `${ROSE}${BOLD}interrupted${RESET}`;
38
+ case "sleeping": return `${SLATE}sleeping${RESET}`;
39
+ default: return `${SLATE}${phase}${RESET}`;
40
+ }
41
+ }
27
42
  // ── TUI class ───────────────────────────────────────────────────────────────
28
43
  export class TUI {
29
44
  active = false;
@@ -31,13 +46,14 @@ export class TUI {
31
46
  cols = 80;
32
47
  rows = 24;
33
48
  headerHeight = 1; // top bar
34
- sessionRows = 0; // dynamic based on session count
49
+ sessionRows = 0; // dynamic: 2 (borders) + N sessions
35
50
  separatorRow = 0; // line between sessions and activity
36
51
  scrollTop = 0; // first row of scroll region
37
52
  scrollBottom = 0; // last row of scroll region
38
53
  inputRow = 0; // bottom input line
39
54
  activityBuffer = []; // ring buffer for activity log
40
55
  maxActivity = 500; // max entries to keep
56
+ spinnerFrame = 0; // current spinner animation frame
41
57
  // current state for repaints
42
58
  phase = "sleeping";
43
59
  pollCount = 0;
@@ -56,12 +72,16 @@ export class TUI {
56
72
  process.stderr.write(ALT_SCREEN_ON + CURSOR_HIDE + CLEAR_SCREEN);
57
73
  // handle terminal resize
58
74
  process.stdout.on("resize", () => this.onResize());
59
- // repaint header every second so countdown timer ticks down
75
+ // tick timer: countdown + spinner animation (~4 fps for smooth braille spin)
60
76
  this.countdownTimer = setInterval(() => {
61
- if (this.active && this.phase === "sleeping" && this.nextTickAt > 0) {
77
+ if (!this.active)
78
+ return;
79
+ this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER.length;
80
+ // repaint header for countdown and spinner
81
+ if (this.phase !== "sleeping" || this.nextTickAt > 0) {
62
82
  this.paintHeader();
63
83
  }
64
- }, 1000);
84
+ }, 250);
65
85
  // initial layout
66
86
  this.computeLayout(0);
67
87
  this.paintAll();
@@ -129,8 +149,10 @@ export class TUI {
129
149
  computeLayout(sessionCount) {
130
150
  this.updateDimensions();
131
151
  // header: 1 row
132
- // sessions: 1 header + N sessions + 1 blank = N+2 rows (min 2 if no sessions)
133
- this.sessionRows = Math.max(sessionCount, 0) + 2;
152
+ // sessions: top border (1) + N session rows + bottom border (1) = N+2
153
+ // if no sessions, just show an empty box (2 rows: top + bottom borders)
154
+ const sessBodyRows = Math.max(sessionCount, 1); // at least 1 row for "no agents"
155
+ this.sessionRows = sessBodyRows + 2; // + top/bottom borders
134
156
  this.separatorRow = this.headerHeight + this.sessionRows + 1;
135
157
  // input line is the last row
136
158
  this.inputRow = this.rows;
@@ -138,7 +160,6 @@ export class TUI {
138
160
  this.scrollTop = this.separatorRow + 1;
139
161
  this.scrollBottom = this.rows - 1;
140
162
  if (this.active) {
141
- // set scroll region so activity log scrolls within bounds
142
163
  process.stderr.write(setScrollRegion(this.scrollTop, this.scrollBottom));
143
164
  }
144
165
  }
@@ -159,54 +180,64 @@ export class TUI {
159
180
  this.paintInputLine();
160
181
  }
161
182
  paintHeader() {
162
- const phaseText = this.paused
163
- ? `${YELLOW}PAUSED${RESET}`
164
- : this.phase === "reasoning"
165
- ? `${CYAN}reasoning...${RESET}`
166
- : this.phase === "executing"
167
- ? `${YELLOW}executing...${RESET}`
168
- : this.phase === "polling"
169
- ? `${GREEN}polling${RESET}`
170
- : `${DIM}sleeping${RESET}`;
171
- const sessCount = `${this.sessions.length} session${this.sessions.length !== 1 ? "s" : ""}`;
183
+ const phaseText = phaseDisplay(this.phase, this.paused, this.spinnerFrame);
184
+ const sessCount = `${this.sessions.length} agent${this.sessions.length !== 1 ? "s" : ""}`;
172
185
  const activeCount = this.sessions.filter((s) => s.userActive).length;
173
- const activeTag = activeCount > 0 ? ` ${DIM}|${RESET} ${YELLOW}${activeCount} user active${RESET}` : "";
186
+ const activeTag = activeCount > 0 ? ` ${SLATE}│${RESET} ${AMBER}${activeCount} user${RESET}` : "";
174
187
  // countdown to next tick (only in sleeping phase)
175
188
  let countdownTag = "";
176
189
  if (this.phase === "sleeping" && this.nextTickAt > 0) {
177
190
  const remaining = Math.max(0, Math.ceil((this.nextTickAt - Date.now()) / 1000));
178
- countdownTag = ` ${DIM}|${RESET} ${DIM}next: ${remaining}s${RESET}`;
191
+ countdownTag = ` ${SLATE}│${RESET} ${SLATE}${remaining}s${RESET}`;
179
192
  }
180
- // reasoner name
181
- const reasonerTag = this.reasonerName ? ` ${DIM}|${RESET} ${DIM}${this.reasonerName}${RESET}` : "";
182
- const line = ` ${BOLD}aoaoe${RESET} ${DIM}${this.version}${RESET} ${DIM}|${RESET} poll #${this.pollCount} ${DIM}|${RESET} ${sessCount} ${DIM}|${RESET} ${phaseText}${activeTag}${countdownTag}${reasonerTag}`;
193
+ // reasoner badge
194
+ const reasonerTag = this.reasonerName ? ` ${SLATE}│${RESET} ${TEAL}${this.reasonerName}${RESET}` : "";
195
+ const line = ` ${INDIGO}${BOLD}aoaoe${RESET} ${SLATE}${this.version}${RESET} ${SLATE}│${RESET} #${this.pollCount} ${SLATE}│${RESET} ${sessCount} ${SLATE}│${RESET} ${phaseText}${activeTag}${countdownTag}${reasonerTag}`;
183
196
  process.stderr.write(SAVE_CURSOR +
184
- moveTo(1, 1) + CLEAR_LINE + BG_DARK + WHITE + truncateAnsi(line, this.cols) + RESET +
197
+ moveTo(1, 1) + CLEAR_LINE + BG_DARK + WHITE + truncateAnsi(line, this.cols) + padToWidth(line, this.cols) + RESET +
185
198
  RESTORE_CURSOR);
186
199
  }
187
200
  paintSessions() {
188
201
  const startRow = this.headerHeight + 1;
189
- // session header friendly label instead of column abbreviations
190
- const hdr = ` ${DIM}agents:${RESET}`;
191
- process.stderr.write(SAVE_CURSOR + moveTo(startRow, 1) + CLEAR_LINE + hdr);
192
- for (let i = 0; i < this.sessions.length; i++) {
193
- const s = this.sessions[i];
194
- const line = ` ${formatSessionSentence(s, this.cols - 4)}`;
195
- process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + line);
202
+ const innerWidth = this.cols - 2; // inside the box borders
203
+ // top border with label
204
+ const label = " agents ";
205
+ const borderAfterLabel = Math.max(0, innerWidth - label.length);
206
+ const topBorder = `${SLATE}${BOX.rtl}${BOX.h}${RESET}${SLATE}${label}${RESET}${SLATE}${BOX.h.repeat(borderAfterLabel)}${BOX.rtr}${RESET}`;
207
+ process.stderr.write(SAVE_CURSOR + moveTo(startRow, 1) + CLEAR_LINE + truncateAnsi(topBorder, this.cols));
208
+ if (this.sessions.length === 0) {
209
+ // empty state
210
+ const empty = `${SLATE}${BOX.v}${RESET} ${DIM}no agents connected${RESET}`;
211
+ const padded = padBoxLine(empty, this.cols);
212
+ process.stderr.write(moveTo(startRow + 1, 1) + CLEAR_LINE + padded);
213
+ }
214
+ else {
215
+ for (let i = 0; i < this.sessions.length; i++) {
216
+ const s = this.sessions[i];
217
+ const line = `${SLATE}${BOX.v}${RESET} ${formatSessionCard(s, innerWidth - 1)}`;
218
+ const padded = padBoxLine(line, this.cols);
219
+ process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
220
+ }
196
221
  }
197
- // clear any leftover rows if session count decreased
198
- const totalSessionLines = this.sessions.length + 1; // +1 for header
199
- for (let r = startRow + totalSessionLines; r < this.separatorRow; r++) {
222
+ // bottom border
223
+ const bodyRows = Math.max(this.sessions.length, 1);
224
+ const bottomRow = startRow + 1 + bodyRows;
225
+ const bottomBorder = `${SLATE}${BOX.rbl}${BOX.h.repeat(Math.max(0, this.cols - 2))}${BOX.rbr}${RESET}`;
226
+ process.stderr.write(moveTo(bottomRow, 1) + CLEAR_LINE + truncateAnsi(bottomBorder, this.cols));
227
+ // clear any leftover rows below the box
228
+ for (let r = bottomRow + 1; r < this.separatorRow; r++) {
200
229
  process.stderr.write(moveTo(r, 1) + CLEAR_LINE);
201
230
  }
202
231
  process.stderr.write(RESTORE_CURSOR);
203
232
  }
204
233
  paintSeparator() {
205
- const hints = " ESC ESC: interrupt /help /explain /pause ";
206
- const prefix = "── activity ";
207
- const totalDecor = prefix.length + hints.length + 2; // 2 for surrounding ──
208
- const fill = Math.max(0, this.cols - totalDecor);
209
- const line = `${DIM}${prefix}${"─".repeat(Math.floor(fill / 2))}${hints}${"─".repeat(Math.ceil(fill / 2))}${RESET}`;
234
+ const hints = " esc esc: interrupt /help /explain /pause ";
235
+ const prefix = `${BOX.h}${BOX.h} activity `;
236
+ const totalLen = prefix.length + hints.length;
237
+ const fill = Math.max(0, this.cols - totalLen);
238
+ const left = Math.floor(fill / 2);
239
+ const right = Math.ceil(fill / 2);
240
+ const line = `${SLATE}${prefix}${BOX.h.repeat(left)}${DIM}${hints}${RESET}${SLATE}${BOX.h.repeat(right)}${RESET}`;
210
241
  process.stderr.write(SAVE_CURSOR + moveTo(this.separatorRow, 1) + CLEAR_LINE + truncateAnsi(line, this.cols) + RESTORE_CURSOR);
211
242
  }
212
243
  writeActivityLine(entry) {
@@ -219,7 +250,6 @@ export class TUI {
219
250
  this.paintInputLine();
220
251
  }
221
252
  repaintActivityRegion() {
222
- // repaint visible portion of activity buffer
223
253
  const visibleLines = this.scrollBottom - this.scrollTop + 1;
224
254
  const entries = this.activityBuffer.slice(-visibleLines);
225
255
  for (let i = 0; i < visibleLines; i++) {
@@ -234,25 +264,62 @@ export class TUI {
234
264
  }
235
265
  }
236
266
  paintInputLine() {
267
+ // phase-aware prompt styling
268
+ const prompt = this.paused
269
+ ? `${AMBER}${BOLD}paused >${RESET} `
270
+ : this.phase === "reasoning"
271
+ ? `${SKY}thinking >${RESET} `
272
+ : `${LIME}>${RESET} `;
237
273
  process.stderr.write(SAVE_CURSOR +
238
- moveTo(this.inputRow, 1) + CLEAR_LINE +
239
- `${GREEN}you >${RESET} ` +
274
+ moveTo(this.inputRow, 1) + CLEAR_LINE + prompt +
240
275
  RESTORE_CURSOR);
241
276
  }
242
277
  }
243
278
  // ── Formatting helpers ──────────────────────────────────────────────────────
279
+ // format a session as a card-style line (inside the box)
280
+ function formatSessionCard(s, maxWidth) {
281
+ const dot = STATUS_DOT[s.status] ?? `${AMBER}${DOT.filled}${RESET}`;
282
+ const name = `${BOLD}${s.title}${RESET}`;
283
+ const toolBadge = `${SLATE}${s.tool}${RESET}`;
284
+ // status description
285
+ let desc;
286
+ if (s.userActive) {
287
+ desc = `${AMBER}you're active${RESET}`;
288
+ }
289
+ else if (s.status === "working" || s.status === "running") {
290
+ desc = s.currentTask
291
+ ? truncatePlain(s.currentTask, Math.max(20, maxWidth - s.title.length - s.tool.length - 16))
292
+ : `${LIME}working${RESET}`;
293
+ }
294
+ else if (s.status === "idle" || s.status === "stopped") {
295
+ desc = `${SLATE}idle${RESET}`;
296
+ }
297
+ else if (s.status === "error") {
298
+ desc = `${ROSE}error${RESET}`;
299
+ }
300
+ else if (s.status === "done") {
301
+ desc = `${GREEN}done${RESET}`;
302
+ }
303
+ else if (s.status === "waiting") {
304
+ desc = `${AMBER}waiting${RESET}`;
305
+ }
306
+ else {
307
+ desc = `${SLATE}${s.status}${RESET}`;
308
+ }
309
+ return truncateAnsi(`${dot} ${name} ${toolBadge} ${SLATE}${BOX.h}${RESET} ${desc}`, maxWidth);
310
+ }
244
311
  // colorize an activity entry based on its tag
245
312
  function formatActivity(entry, maxCols) {
246
313
  const { time, tag, text } = entry;
247
- let color = DIM;
314
+ let color = SLATE;
248
315
  let prefix = tag;
249
316
  switch (tag) {
250
317
  case "observation":
251
- color = DIM;
318
+ color = SLATE;
252
319
  prefix = "obs";
253
320
  break;
254
321
  case "reasoner":
255
- color = CYAN;
322
+ color = SKY;
256
323
  break;
257
324
  case "explain":
258
325
  color = `${BOLD}${CYAN}`;
@@ -260,30 +327,46 @@ function formatActivity(entry, maxCols) {
260
327
  break;
261
328
  case "+ action":
262
329
  case "action":
263
- color = YELLOW;
264
- prefix = "+ action";
330
+ color = AMBER;
331
+ prefix = " action";
265
332
  break;
266
333
  case "! action":
267
334
  case "error":
268
- color = RED;
269
- prefix = "! action";
335
+ color = ROSE;
336
+ prefix = " error";
270
337
  break;
271
338
  case "you":
272
- color = GREEN;
339
+ color = LIME;
273
340
  break;
274
341
  case "system":
275
- color = DIM;
342
+ color = SLATE;
276
343
  break;
277
344
  case "status":
278
- color = DIM;
345
+ color = SLATE;
279
346
  break;
280
347
  default:
281
- color = DIM;
348
+ color = SLATE;
282
349
  break;
283
350
  }
284
- const formatted = ` ${DIM}${time}${RESET} ${color}[${prefix}]${RESET} ${text}`;
351
+ const formatted = ` ${SLATE}${time}${RESET} ${color}${prefix}${RESET} ${DIM}│${RESET} ${text}`;
285
352
  return truncateAnsi(formatted, maxCols);
286
353
  }
354
+ // pad a box line to end with the right border character
355
+ function padBoxLine(line, totalWidth) {
356
+ const visible = stripAnsiForLen(line);
357
+ const pad = Math.max(0, totalWidth - visible - 1); // -1 for closing border
358
+ return line + " ".repeat(pad) + `${SLATE}${BOX.v}${RESET}`;
359
+ }
360
+ // pad the header bar to fill the full width with background color
361
+ function padToWidth(line, totalWidth) {
362
+ const visible = stripAnsiForLen(line);
363
+ const pad = Math.max(0, totalWidth - visible);
364
+ return " ".repeat(pad);
365
+ }
366
+ // count visible characters (strip ANSI escapes)
367
+ function stripAnsiForLen(str) {
368
+ return str.replace(/\x1b\[[0-9;]*m/g, "").length;
369
+ }
287
370
  // truncate a string with ANSI codes to fit a column width (approximate)
288
371
  function truncateAnsi(str, maxCols) {
289
372
  // strip ANSI to count visible chars
@@ -299,46 +382,41 @@ function truncatePlain(str, max) {
299
382
  }
300
383
  /**
301
384
  * Format a session state as a plain-English sentence.
302
- * Examples:
303
- * "~ Adventure (opencode) — working on authentication"
304
- * "! Cloud Hypervisor (opencode) — error"
305
- * ". CHV (claude) — idle"
306
- * "~ Adventure (opencode) — you're working here"
385
+ * Kept for backward compatibility — used by non-TUI output paths.
307
386
  */
308
387
  export function formatSessionSentence(s, maxCols) {
309
- const icon = STATUS_ICONS[s.status] ?? `${YELLOW}?${RESET}`;
388
+ const dot = STATUS_DOT[s.status] ?? `${AMBER}${DOT.filled}${RESET}`;
310
389
  const name = s.title;
311
- const tool = `${DIM}(${s.tool})${RESET}`;
312
- // human-readable status descriptions
390
+ const tool = `${SLATE}(${s.tool})${RESET}`;
313
391
  let statusDesc;
314
392
  if (s.userActive) {
315
- statusDesc = `${YELLOW}you're working here${RESET}`;
393
+ statusDesc = `${AMBER}you're working here${RESET}`;
316
394
  }
317
395
  else if (s.status === "working") {
318
396
  if (s.currentTask) {
319
397
  statusDesc = truncatePlain(s.currentTask, Math.max(30, maxCols - name.length - s.tool.length - 20));
320
398
  }
321
399
  else {
322
- statusDesc = `${GREEN}working${RESET}`;
400
+ statusDesc = `${LIME}working${RESET}`;
323
401
  }
324
402
  }
325
403
  else if (s.status === "idle" || s.status === "stopped") {
326
- statusDesc = `${DIM}idle${RESET}`;
404
+ statusDesc = `${SLATE}idle${RESET}`;
327
405
  }
328
406
  else if (s.status === "error") {
329
- statusDesc = `${RED}hit an error${RESET}`;
407
+ statusDesc = `${ROSE}hit an error${RESET}`;
330
408
  }
331
409
  else if (s.status === "done") {
332
410
  statusDesc = `${GREEN}finished${RESET}`;
333
411
  }
334
412
  else if (s.status === "waiting") {
335
- statusDesc = `${YELLOW}waiting for input${RESET}`;
413
+ statusDesc = `${AMBER}waiting for input${RESET}`;
336
414
  }
337
415
  else {
338
416
  statusDesc = s.status;
339
417
  }
340
- return truncateAnsi(`${icon} ${BOLD}${name}${RESET} ${tool} ${DIM}—${RESET} ${statusDesc}`, maxCols);
418
+ return truncateAnsi(`${dot} ${BOLD}${name}${RESET} ${tool} ${SLATE}—${RESET} ${statusDesc}`, maxCols);
341
419
  }
342
420
  // ── Exported pure helpers (for testing) ─────────────────────────────────────
343
- export { formatActivity, truncateAnsi, truncatePlain };
421
+ export { formatActivity, formatSessionCard, truncateAnsi, truncatePlain, padBoxLine, padToWidth, stripAnsiForLen, phaseDisplay };
344
422
  //# sourceMappingURL=tui.js.map
package/dist/types.d.ts CHANGED
@@ -162,4 +162,11 @@ export interface TaskState {
162
162
  completedAt?: number;
163
163
  progress: TaskProgress[];
164
164
  }
165
+ export declare function toTaskState(raw: unknown): TaskState | null;
166
+ export declare function toDaemonState(raw: unknown): DaemonState | null;
167
+ export declare function toAoeSessionList(raw: unknown): Array<{
168
+ id: string;
169
+ title: string;
170
+ }>;
171
+ export declare function toReasonerBackend(raw: string): ReasonerBackend;
165
172
  //# sourceMappingURL=types.d.ts.map
package/dist/types.js CHANGED
@@ -37,4 +37,79 @@ export function actionDetail(action) {
37
37
  return undefined;
38
38
  }
39
39
  }
40
+ const VALID_TASK_STATUSES = new Set(["pending", "active", "completed", "paused", "failed"]);
41
+ // validate an unknown value (e.g. from JSON.parse) as a TaskState, returning null if invalid
42
+ export function toTaskState(raw) {
43
+ if (!raw || typeof raw !== "object")
44
+ return null;
45
+ const r = raw;
46
+ if (typeof r.repo !== "string" || !r.repo)
47
+ return null;
48
+ if (typeof r.sessionTitle !== "string")
49
+ return null;
50
+ if (typeof r.tool !== "string")
51
+ return null;
52
+ if (typeof r.goal !== "string")
53
+ return null;
54
+ if (typeof r.status !== "string" || !VALID_TASK_STATUSES.has(r.status))
55
+ return null;
56
+ if (!Array.isArray(r.progress))
57
+ return null;
58
+ return {
59
+ repo: r.repo,
60
+ sessionTitle: r.sessionTitle,
61
+ tool: r.tool,
62
+ goal: r.goal,
63
+ status: r.status,
64
+ sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
65
+ createdAt: typeof r.createdAt === "number" ? r.createdAt : undefined,
66
+ lastProgressAt: typeof r.lastProgressAt === "number" ? r.lastProgressAt : undefined,
67
+ completedAt: typeof r.completedAt === "number" ? r.completedAt : undefined,
68
+ progress: r.progress.filter((p) => !!p && typeof p === "object" &&
69
+ typeof p.at === "number" &&
70
+ typeof p.summary === "string"),
71
+ };
72
+ }
73
+ // validate an unknown value as a DaemonState, returning null if invalid
74
+ export function toDaemonState(raw) {
75
+ if (!raw || typeof raw !== "object")
76
+ return null;
77
+ const r = raw;
78
+ if (typeof r.tickStartedAt !== "number")
79
+ return null;
80
+ if (typeof r.nextTickAt !== "number")
81
+ return null;
82
+ if (typeof r.pollIntervalMs !== "number")
83
+ return null;
84
+ if (typeof r.phase !== "string")
85
+ return null;
86
+ if (typeof r.phaseStartedAt !== "number")
87
+ return null;
88
+ if (typeof r.pollCount !== "number")
89
+ return null;
90
+ if (typeof r.paused !== "boolean")
91
+ return null;
92
+ if (typeof r.sessionCount !== "number")
93
+ return null;
94
+ if (typeof r.changeCount !== "number")
95
+ return null;
96
+ if (!Array.isArray(r.sessions))
97
+ return null;
98
+ return raw;
99
+ }
100
+ // validate an unknown array as an AoE session list (from `aoe list --json`)
101
+ export function toAoeSessionList(raw) {
102
+ if (!Array.isArray(raw))
103
+ return [];
104
+ return raw.filter((item) => !!item && typeof item === "object" &&
105
+ typeof item.id === "string" &&
106
+ typeof item.title === "string");
107
+ }
108
+ // validate a string as a ReasonerBackend, throwing on invalid input
109
+ const VALID_REASONER_BACKENDS = new Set(["opencode", "claude-code"]);
110
+ export function toReasonerBackend(raw) {
111
+ if (VALID_REASONER_BACKENDS.has(raw))
112
+ return raw;
113
+ throw new Error(`--reasoner must be "opencode" or "claude-code", got "${raw}"`);
114
+ }
40
115
  //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.46.0",
3
+ "version": "0.48.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",