@addai/node 0.4.0 → 0.5.1

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.
@@ -7,20 +7,65 @@
7
7
  // every one of those lines lands in the middle of a frame, so the screen
8
8
  // appears to flicker between the UI and log output.
9
9
  //
10
- // Capturing rather than silencing: the lines are kept in a ring buffer, the
11
- // newest is surfaced in the UI, and the whole buffer is replayed to the real
12
- // stdout on exit, so running the daemon under the dashboard loses nothing
13
- // you would have seen without it.
10
+ // Capturing rather than silencing: the lines are kept in a ring buffer that
11
+ // the Logs screen reads, and appended to the daemon log file as they arrive.
12
+ // Nothing is lost by running the daemon under the console and because the
13
+ // file is written live, nothing has to be dumped into your shell on the way
14
+ // out either.
15
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ var desc = Object.getOwnPropertyDescriptor(m, k);
18
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
+ desc = { enumerable: true, get: function() { return m[k]; } };
20
+ }
21
+ Object.defineProperty(o, k2, desc);
22
+ }) : (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ o[k2] = m[k];
25
+ }));
26
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
27
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
28
+ }) : function(o, v) {
29
+ o["default"] = v;
30
+ });
31
+ var __importStar = (this && this.__importStar) || (function () {
32
+ var ownKeys = function(o) {
33
+ ownKeys = Object.getOwnPropertyNames || function (o) {
34
+ var ar = [];
35
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
36
+ return ar;
37
+ };
38
+ return ownKeys(o);
39
+ };
40
+ return function (mod) {
41
+ if (mod && mod.__esModule) return mod;
42
+ var result = {};
43
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
44
+ __setModuleDefault(result, mod);
45
+ return result;
46
+ };
47
+ })();
14
48
  Object.defineProperty(exports, "__esModule", { value: true });
15
49
  exports.captureConsole = captureConsole;
16
- const MAX_LINES = 500;
17
- function captureConsole() {
50
+ const fs = __importStar(require("fs"));
51
+ const MAX_LINES = 2000;
52
+ function captureConsole(opts = {}) {
18
53
  const ring = [];
54
+ const listeners = [];
19
55
  const original = {
20
56
  log: console.log.bind(console),
21
57
  warn: console.warn.bind(console),
22
58
  error: console.error.bind(console),
23
59
  };
60
+ let sink = null;
61
+ if (opts.logFile) {
62
+ try {
63
+ sink = fs.openSync(opts.logFile, 'a');
64
+ }
65
+ catch {
66
+ sink = null;
67
+ }
68
+ }
24
69
  const format = (args) => args.map(a => {
25
70
  if (typeof a === 'string')
26
71
  return a;
@@ -34,9 +79,22 @@ function captureConsole() {
34
79
  }
35
80
  }).join(' ');
36
81
  const push = (level) => (...args) => {
37
- ring.push({ level, text: format(args), at: Date.now() });
82
+ const line = { level, text: format(args), at: Date.now() };
83
+ ring.push(line);
38
84
  if (ring.length > MAX_LINES)
39
85
  ring.shift();
86
+ if (sink !== null) {
87
+ try {
88
+ fs.writeSync(sink, `${line.text}\n`);
89
+ }
90
+ catch { /* disk full, keep the UI alive */ }
91
+ }
92
+ for (const fn of listeners) {
93
+ try {
94
+ fn(line);
95
+ }
96
+ catch { /* a listener must never break logging */ }
97
+ }
40
98
  };
41
99
  console.log = push('log');
42
100
  console.warn = push('warn');
@@ -45,18 +103,18 @@ function captureConsole() {
45
103
  lines: () => [...ring],
46
104
  last: () => (ring.length ? ring[ring.length - 1] : null),
47
105
  count: () => ring.length,
48
- restoreAndReplay() {
106
+ onLine(fn) { listeners.push(fn); },
107
+ restore() {
49
108
  console.log = original.log;
50
109
  console.warn = original.warn;
51
110
  console.error = original.error;
52
- for (const l of ring) {
53
- if (l.level === 'error')
54
- original.error(l.text);
55
- else if (l.level === 'warn')
56
- original.warn(l.text);
57
- else
58
- original.log(l.text);
111
+ if (sink !== null) {
112
+ try {
113
+ fs.closeSync(sink);
114
+ }
115
+ catch { /* already gone */ }
59
116
  }
117
+ sink = null;
60
118
  },
61
119
  };
62
120
  }
@@ -1,9 +1,11 @@
1
+ import { type AppHost, type Screen } from './app';
1
2
  import type { DataLayer, NodeSelf, NodeStats, RequestRow } from './data';
2
- import type { AppHost, Screen } from './app';
3
3
  export interface DashboardState {
4
4
  self: NodeSelf | null;
5
5
  stats: NodeStats | null;
6
- requests: RequestRow[];
6
+ /** Recent requests, newest first — the NOW band reads the live ones off
7
+ * the top and the idle line reads the most recent finish. */
8
+ recent: RequestRow[];
7
9
  sel: number;
8
10
  spin: number;
9
11
  pid: number | null;
@@ -14,24 +16,32 @@ export interface DashboardState {
14
16
  offline: boolean;
15
17
  now: number;
16
18
  version: string;
17
- /** Newest line the daemon logged while the UI owned the screen. */
18
- lastLog: string | null;
19
+ /** How many daemon log lines have been captured this session. */
19
20
  logCount: number;
20
21
  }
21
- export declare const FEED_ROWS = 12;
22
- /** Trigger sources are DB-shaped ('self_improve_distil'); the column is
23
- * nine columns wide and "self_impr" tells you nothing. */
24
- export declare function shortVia(via: string | null | undefined): string;
25
- export declare function requestLine(r: RequestRow, now: number, selected: boolean, width: number): string;
26
- /** The landing screen's menu. Activity and Harnesses are places you go,
27
- * not things that crowd the overview. */
22
+ /** The landing screen's menu. */
28
23
  export declare const MENU: Array<{
29
24
  key: string;
30
25
  label: string;
31
26
  hint: string;
32
27
  shortcut: string;
33
28
  }>;
34
- export declare function renderDashboard(st: DashboardState, width: number): string[];
29
+ /** Live rows the NOW band shows before it starts counting the rest. */
30
+ export declare const MAX_NOW_ROWS = 6;
31
+ export declare function liveRequests(rows: RequestRow[]): RequestRow[];
32
+ /**
33
+ * The node's own state, as a label.
34
+ *
35
+ * A daemon is running — we either started it or the lockfile proved it — so
36
+ * the node IS online, whatever the server has got round to saying. Waiting
37
+ * for `runtime_self` to come back before admitting that meant the first
38
+ * second of every session claimed the node was in an unknown state.
39
+ */
40
+ export declare function stateLabel(st: DashboardState): string;
41
+ /** 1204 reads as a year; 1,204 reads as a count. */
42
+ export declare function fmtCount(n: number | null | undefined): string;
43
+ export declare function nowLines(st: DashboardState, width: number, rows: number): string[];
44
+ export declare function renderDashboard(st: DashboardState, width: number, height: number): string[];
35
45
  export declare function createDashboardScreen(deps: {
36
46
  data: DataLayer;
37
47
  host: AppHost;
@@ -39,4 +49,5 @@ export declare function createDashboardScreen(deps: {
39
49
  openTranscript(r: RequestRow): void;
40
50
  openRequests(): void;
41
51
  openHarnesses(): void;
52
+ openLogs(): void;
42
53
  }): Screen;
@@ -1,176 +1,209 @@
1
1
  "use strict";
2
- // Home screen: is this node alive, what has it been doing, what is it
3
- // doing right now.
2
+ // The cockpit: is this node alive, what is it doing right now, and what has
3
+ // it done today.
4
4
  //
5
- // Status vocabulary comes from the DB, where terminal success is
6
- // 'completed' and 'canceled' is a deliberate user actionshown apart
7
- // from failures so a healthy node isn't slandered by its own cancels.
5
+ // The landing screen answers "is the box earning its keep" without a
6
+ // keystroke. Everything that needs scrolling history, harnesses, logs is
7
+ // a place you go, not something that crowds the overview.
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.MENU = exports.FEED_ROWS = void 0;
10
- exports.shortVia = shortVia;
11
- exports.requestLine = requestLine;
9
+ exports.MAX_NOW_ROWS = exports.MENU = void 0;
10
+ exports.liveRequests = liveRequests;
11
+ exports.stateLabel = stateLabel;
12
+ exports.fmtCount = fmtCount;
13
+ exports.nowLines = nowLines;
12
14
  exports.renderDashboard = renderDashboard;
13
15
  exports.createDashboardScreen = createDashboardScreen;
14
16
  const render_1 = require("./render");
15
- exports.FEED_ROWS = 12;
16
- const ACTIVE = new Set(['pending', 'starting', 'running']);
17
- const CANCELLED = new Set(['canceled', 'cancelled']);
18
- /** Trigger sources are DB-shaped ('self_improve_distil'); the column is
19
- * nine columns wide and "self_impr" tells you nothing. */
20
- function shortVia(via) {
21
- if (!via)
22
- return '—';
23
- const map = {
24
- self_improve_distil: 'distil',
25
- self_improve: 'improve',
26
- entity_chat: 'chat',
27
- chat: 'chat',
28
- schedule: 'schedule',
29
- flow: 'flow',
30
- api: 'api',
31
- studio: 'studio',
32
- };
33
- return map[via] ?? via;
34
- }
35
- function statusLabel(status) {
36
- if (status === 'completed')
37
- return 'DONE';
38
- if (CANCELLED.has(status))
39
- return 'CANCEL';
40
- return status.toUpperCase();
41
- }
42
- function statusCell(status) {
43
- const label = statusLabel(status).padEnd(8).slice(0, 8);
44
- if (ACTIVE.has(status))
45
- return (0, render_1.cyan)(label);
46
- if (status === 'completed')
47
- return (0, render_1.green)(label);
48
- if (CANCELLED.has(status))
49
- return (0, render_1.grey)(label);
50
- return (0, render_1.red)(label);
51
- }
52
- function dot(status) {
53
- if (ACTIVE.has(status))
54
- return (0, render_1.cyan)('⏺');
55
- if (status === 'completed')
56
- return (0, render_1.green)('⏺');
57
- if (CANCELLED.has(status))
58
- return (0, render_1.grey)('⏺');
59
- return (0, render_1.red)('⏺');
60
- }
61
- function durationOf(r, now) {
62
- const start = r.picked_up_at ?? r.created_at;
63
- if (!start)
64
- return null;
65
- const end = r.finished_at ? new Date(r.finished_at).getTime() : now;
66
- return Math.max(0, end - new Date(start).getTime());
17
+ const request_row_1 = require("./request-row");
18
+ const app_1 = require("./app");
19
+ /** The landing screen's menu. */
20
+ exports.MENU = [
21
+ { key: 'activity', label: 'Activity', hint: 'Every request this node has handled', shortcut: 'a' },
22
+ { key: 'harnesses', label: 'Harnesses', hint: 'Install / log in agent CLIs', shortcut: 'h' },
23
+ { key: 'logs', label: 'Logs', hint: 'What the daemon is saying', shortcut: 'l' },
24
+ ];
25
+ /** Live rows the NOW band shows before it starts counting the rest. */
26
+ exports.MAX_NOW_ROWS = 6;
27
+ function liveRequests(rows) {
28
+ return rows.filter(r => request_row_1.ACTIVE.has(r.status));
67
29
  }
68
- function requestLine(r, now, selected, width) {
69
- const entity = (0, render_1.padEndVisible)((0, render_1.truncate)(r.entity_name ?? '—', 12), 12);
70
- const agent = `${r.agent}/${r.mode}`.padEnd(12).slice(0, 12);
71
- const model = (r.model ?? '').replace(/^claude-/, '').padEnd(7).slice(0, 7);
72
- const via = shortVia(r.issued_via).padEnd(9).slice(0, 9);
73
- const dur = (0, render_1.fmtDuration)(durationOf(r, now)).padStart(7);
74
- // A run that failed once and then succeeded on retry still carries the
75
- // old error_code. Showing it on a DONE row reads as "this broke" when
76
- // the work actually landed — so the code is only for rows that ended badly.
77
- const ended_badly = r.status !== 'completed' && !ACTIVE.has(r.status);
78
- const tail = r.error_code && ended_badly
79
- ? (0, render_1.red)(`[${r.error_code}]`)
80
- : (r.prompt ?? '').replace(/\s+/g, ' ');
81
- const prefix = `${dot(r.status)} ${statusCell(r.status)} ${entity} ${(0, render_1.dim)(agent)} ${(0, render_1.dim)(model)} ${(0, render_1.dim)(via)} ${dur} `;
82
- const room = Math.max(8, width - 2 - (0, render_1.visibleWidth)(prefix));
83
- const line = `${prefix}${(0, render_1.truncate)(tail, room)}`;
84
- return selected ? `${(0, render_1.cyan)('❯')} ${line}` : ` ${line}`;
30
+ /**
31
+ * The node's own state, as a label.
32
+ *
33
+ * A daemon is running we either started it or the lockfile proved it — so
34
+ * the node IS online, whatever the server has got round to saying. Waiting
35
+ * for `runtime_self` to come back before admitting that meant the first
36
+ * second of every session claimed the node was in an unknown state.
37
+ */
38
+ function stateLabel(st) {
39
+ const reported = st.self?.effective_status;
40
+ if (!reported)
41
+ return (0, render_1.green)('Online');
42
+ if (reported === 'online')
43
+ return (0, render_1.green)('Online');
44
+ return (0, render_1.yellow)(reported.charAt(0).toUpperCase() + reported.slice(1));
85
45
  }
86
46
  function headerLines(st) {
87
47
  if (!st.paired) {
88
48
  return [
89
- (0, render_1.yellow)('not paired'),
49
+ (0, render_1.yellow)('Not paired'),
90
50
  (0, render_1.dim)('run `ainode` and follow the prompt to vault.add.ai/entity/connect/<CODE>'),
91
51
  ];
92
52
  }
93
53
  const self = st.self;
94
- const name = self?.name ?? self?.hostname ?? 'this node';
95
- const state = self?.effective_status === 'online'
96
- ? (0, render_1.green)('online')
97
- : (0, render_1.yellow)(self?.effective_status ?? 'unknown');
54
+ const name = self?.name ?? self?.hostname ?? 'This node';
98
55
  const paired = self?.created_at ? `paired ${(0, render_1.fmtRelative)(self.created_at, st.now)}` : '';
99
- const first = `${(0, render_1.bold)(name)} ${(0, render_1.dim)('·')} ${state} ${(0, render_1.dim)('·')} ${(0, render_1.dim)(paired)}`;
56
+ const first = `${(0, render_1.bold)(name)} ${(0, render_1.dim)('·')} ${stateLabel(st)}${paired ? ` ${(0, render_1.dim)('·')} ${(0, render_1.dim)(paired)}` : ''}`;
100
57
  const up = st.startedAt ? (0, render_1.fmtDuration)(st.now - st.startedAt) : '—';
101
58
  const daemon = st.viewerMode
102
- ? `${(0, render_1.cyan)('⏺')} viewer ${(0, render_1.dim)( another ainode owns this node')} ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)}`
103
- : `${(0, render_1.green)('⏺')} running ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)(`up ${up}`)} ${(0, render_1.dim)(`${st.inflight} in flight`)}`;
104
- const chip = st.offline ? ` ${(0, render_1.yellow)('⚠ offline · retrying')}` : '';
59
+ ? `${(0, render_1.cyan)('⏺')} Viewer ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)( another process owns this node')}`
60
+ : `${(0, render_1.green)('⏺')} Running ${(0, render_1.dim)(`pid ${st.pid ?? '?'}`)} ${(0, render_1.dim)(`up ${up}`)} ${(0, render_1.dim)(`${st.inflight} in flight`)}`;
61
+ const chip = st.offline ? ` ${(0, render_1.yellow)('⚠ Offline · retrying')}` : '';
105
62
  return [first, daemon + chip];
106
63
  }
64
+ /** 1204 reads as a year; 1,204 reads as a count. */
65
+ function fmtCount(n) {
66
+ if (n == null)
67
+ return '—';
68
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
69
+ }
70
+ /** A labelled figure, padded so the labels line up down the screen. */
71
+ function stat(label, value, width = 14) {
72
+ return (0, render_1.padEndVisible)(`${(0, render_1.dim)(label)} ${value}`, width);
73
+ }
107
74
  function statsLines(st) {
108
75
  const s = st.stats;
109
- if (!s)
110
- return [(0, render_1.dim)('stats unavailable')];
76
+ // Keep the shape while the first fetch is in flight, so the band doesn't
77
+ // pop into place a second after the header.
78
+ if (!s) {
79
+ return [
80
+ `${stat('Today', (0, render_1.dim)('—'))}${stat('24h', (0, render_1.dim)('—'), 26)}${stat('Avg', (0, render_1.dim)('—'))}`,
81
+ `${stat('Week', (0, render_1.dim)('—'))}${stat('Month', (0, render_1.dim)('—'), 26)}${stat('Total', (0, render_1.dim)('—'))}`,
82
+ ];
83
+ }
111
84
  const agents = Object.entries(s.by_agent)
112
85
  .sort((a, b) => b[1] - a[1])
113
- .map(([k, v]) => `${k} ${v}`)
114
- .join(' · ');
115
- const cancels = s.canceled_24h ? (0, render_1.dim)(` ${s.canceled_24h} cancelled`) : '';
116
- // The ✓/✗/avg figures are a rolling 24h window while "Today" is the
117
- // calendar day, so they are labelled — otherwise ✓3 next to Today 2
118
- // looks like an arithmetic bug.
86
+ .map(([k, v]) => `${k.charAt(0).toUpperCase()}${k.slice(1)} ${fmtCount(v)}`)
87
+ .join((0, render_1.dim)(' · '));
88
+ // The ✓/✗ figures are a rolling 24h window while "Today" is the calendar
89
+ // day, so the window is labelled otherwise ✓3 next to Today 2 looks
90
+ // like an arithmetic bug.
91
+ const outcomes = [
92
+ (0, render_1.green)(`✓ ${fmtCount(s.succeeded_24h)}`),
93
+ s.failed_24h ? (0, render_1.red)(`✗ ${fmtCount(s.failed_24h)}`) : (0, render_1.dim)('✗ 0'),
94
+ s.canceled_24h ? (0, render_1.grey)(`⊘ ${fmtCount(s.canceled_24h)}`) : '',
95
+ ].filter(Boolean).join(' ');
96
+ // Both rows share one three-column grid, so Month sits under 24h and
97
+ // Total under Avg rather than the two rows drifting apart.
119
98
  return [
120
- `${(0, render_1.bold)(`Today ${s.today}`)} ${(0, render_1.dim)('·')} ${(0, render_1.dim)('24h')} ${(0, render_1.green)(`✓ ${s.succeeded_24h}`)} ${s.failed_24h ? (0, render_1.red)(`✗ ${s.failed_24h}`) : (0, render_1.dim)('✗ 0')}${cancels} ${(0, render_1.dim)(`avg ${(0, render_1.fmtDuration)(s.avg_duration_ms)}`)} ${(0, render_1.dim)(agents)}`,
121
- (0, render_1.dim)(`Week ${s.week} Month ${s.month} Total ${s.total}`),
99
+ `${stat('Today', (0, render_1.bold)(fmtCount(s.today)))}${stat('24h', outcomes, 26)}${stat('Avg', (0, render_1.fmtDuration)(s.avg_duration_ms))}`,
100
+ `${stat('Week', fmtCount(s.week))}${stat('Month', fmtCount(s.month), 26)}${stat('Total', fmtCount(s.total))}${(0, render_1.dim)(agents)}`,
122
101
  ];
123
102
  }
124
- /** The landing screen's menu. Activity and Harnesses are places you go,
125
- * not things that crowd the overview. */
126
- exports.MENU = [
127
- { key: 'activity', label: 'Activity', hint: 'requests this node has handled', shortcut: 'a' },
128
- { key: 'harnesses', label: 'Harnesses', hint: 'install / log in agent CLIs', shortcut: 'h' },
129
- ];
130
- function renderDashboard(st, width) {
131
- const out = [];
132
- out.push(...(0, render_1.panel)(`+Ai Node ${(0, render_1.dim)(`ainode v${st.version}`)}`, headerLines(st), width));
133
- out.push('');
134
- if (st.paired) {
135
- out.push(...statsLines(st).map(l => ` ${l}`));
136
- out.push('');
137
- exports.MENU.forEach((m, i) => {
138
- const selected = i === st.sel;
139
- const label = selected ? (0, render_1.bold)(m.label.padEnd(12)) : m.label.padEnd(12);
140
- const marker = selected ? (0, render_1.cyan)('❯') : ' ';
141
- out.push(`${marker} ${label} ${(0, render_1.dim)(m.hint)} ${(0, render_1.dim)(m.shortcut)}`);
142
- });
103
+ /** The most recent request that actually finished, for the idle line. */
104
+ function lastFinished(rows) {
105
+ return rows.find(r => r.finished_at) ?? null;
106
+ }
107
+ function nowLines(st, width, rows) {
108
+ const live = liveRequests(st.recent);
109
+ const out = [` ${(0, render_1.bold)('Now')}`];
110
+ if (live.length === 0) {
111
+ const last = lastFinished(st.recent);
112
+ out.push(last
113
+ ? (0, render_1.dim)(` Idle — last run finished ${(0, render_1.fmtRelative)(last.finished_at, st.now)}`)
114
+ : (0, render_1.dim)(' Idle'));
115
+ return out;
116
+ }
117
+ out.push((0, request_row_1.requestHeader)(width));
118
+ const shown = live.slice(0, Math.max(1, rows));
119
+ shown.forEach(r => out.push((0, request_row_1.requestLine)(r, st.now, false, width, st.spin)));
120
+ if (live.length > shown.length) {
121
+ out.push((0, render_1.dim)(` +${live.length - shown.length} more running`));
143
122
  }
144
- out.push('');
145
- if (st.lastLog) {
146
- out.push((0, render_1.dim)(` log ${st.lastLog}`));
147
- if (st.logCount > 1)
148
- out.push((0, render_1.dim)(` ${st.logCount} lines captured — printed on exit`));
123
+ return out;
124
+ }
125
+ function renderDashboard(st, width, height) {
126
+ const head = (0, render_1.panel)(`+Ai Node ${(0, render_1.dim)(`@addai/node v${st.version}`)}`, headerLines(st), width);
127
+ if (!st.paired) {
128
+ return [...head, '', (0, app_1.footerHint)([{ keys: 'q', label: 'quit' }])].slice(0, height);
149
129
  }
150
- out.push('');
151
- out.push((0, render_1.dim)(' ↑↓ move ⏎ open r refresh q quit'));
152
- return out.map(l => (0, render_1.truncate)(l, width));
130
+ const stats = statsLines(st).map(l => ` ${l}`);
131
+ // The shortcut letters line up in a column of their own, so the eye can
132
+ // find them without reading the hints.
133
+ const hintWidth = Math.max(...exports.MENU.map(m => m.hint.length)) + 2;
134
+ const menu = exports.MENU.map((m, i) => {
135
+ const selected = i === st.sel;
136
+ const label = selected ? (0, render_1.bold)(m.label.padEnd(11)) : m.label.padEnd(11);
137
+ const cursor = selected ? (0, render_1.cyan)('❯') : ' ';
138
+ const badge = m.key === 'logs' && st.logCount ? `${st.logCount} lines` : '';
139
+ return `${cursor} ${label} ${(0, render_1.dim)(m.hint.padEnd(hintWidth))}${(0, render_1.dim)(badge.padEnd(10))}${(0, render_1.dim)(m.shortcut)}`;
140
+ });
141
+ const foot = (0, app_1.footerHint)([
142
+ { keys: '↑↓', label: 'move' },
143
+ { keys: '⏎', label: 'open' },
144
+ { keys: 'r', label: 'refresh' },
145
+ { keys: '?', label: 'keys' },
146
+ { keys: 'q', label: 'quit' },
147
+ ]);
148
+ // Everything but the NOW band is fixed height, so the band takes what is
149
+ // left: heading + header row + rows + the "+N more" line.
150
+ const fixed = head.length + 1 + stats.length + 1 + 3 + 1 + menu.length + 1 + 1;
151
+ const room = Math.min(exports.MAX_NOW_ROWS, Math.max(1, height - fixed));
152
+ return [
153
+ ...head,
154
+ '',
155
+ ...stats,
156
+ '',
157
+ ...nowLines(st, width, room),
158
+ '',
159
+ ...menu,
160
+ '',
161
+ foot,
162
+ ].map(l => (0, render_1.truncate)(l, width)).slice(0, height);
153
163
  }
154
164
  function createDashboardScreen(deps) {
155
165
  const st = deps.state;
156
- // The landing screen shows identity and numbers only, so it fetches two
157
- // rows of counters — not the request feed. Activity pages that in itself.
158
166
  const refresh = async () => {
159
167
  st.now = Date.now();
160
- const [self, stats] = await Promise.all([deps.data.self(), deps.data.stats()]);
168
+ const [self, stats, recent] = await Promise.all([
169
+ deps.data.self(),
170
+ deps.data.stats(),
171
+ // Enough rows to hold every plausible concurrent run plus something
172
+ // finished for the idle line, and small enough to poll every 2s.
173
+ deps.data.requests({ limit: 20 }),
174
+ ]);
161
175
  if (self)
162
176
  st.self = self;
163
177
  if (stats)
164
178
  st.stats = stats;
179
+ if (recent.length)
180
+ st.recent = recent;
165
181
  st.offline = deps.data.offline();
166
182
  deps.host.redraw();
167
183
  };
168
184
  return {
169
185
  id: 'dashboard',
170
186
  title: '+Ai Node',
171
- render: (width) => renderDashboard(st, width),
172
- pollMs: () => (st.stats && st.stats.active > 0 ? 2000 : 5000),
187
+ render: (width, height) => renderDashboard(st, width, height),
188
+ // Poll hard while something is moving; an idle node doesn't need traffic.
189
+ pollMs: () => (liveRequests(st.recent).length > 0 || st.inflight > 0 ? 2000 : 5000),
173
190
  poll: refresh,
191
+ tick(n) {
192
+ st.spin = n;
193
+ // Only the NOW band animates, and only when something is in it.
194
+ if (liveRequests(st.recent).length === 0)
195
+ return false;
196
+ st.now = Date.now();
197
+ return true;
198
+ },
199
+ keys: () => [
200
+ { keys: '↑↓ / jk', label: 'move between destinations' },
201
+ { keys: '⏎', label: 'open the selected destination' },
202
+ { keys: 'a', label: 'activity — full request history' },
203
+ { keys: 'h', label: 'harnesses — install / log in agent CLIs' },
204
+ { keys: 'l', label: 'logs — daemon output' },
205
+ { keys: 'r', label: 'refresh now' },
206
+ ],
174
207
  async onKey(key) {
175
208
  if (key.name === 'up' || key.name === 'k') {
176
209
  st.sel = Math.max(0, st.sel - 1);
@@ -190,13 +223,20 @@ function createDashboardScreen(deps) {
190
223
  deps.openHarnesses();
191
224
  return;
192
225
  }
226
+ if (key.name === 'l') {
227
+ deps.openLogs();
228
+ return;
229
+ }
193
230
  if (key.name === 'r') {
194
231
  await refresh();
195
232
  return;
196
233
  }
197
234
  if (key.name === 'return') {
198
- if (exports.MENU[st.sel]?.key === 'harnesses')
235
+ const target = exports.MENU[st.sel]?.key;
236
+ if (target === 'harnesses')
199
237
  deps.openHarnesses();
238
+ else if (target === 'logs')
239
+ deps.openLogs();
200
240
  else
201
241
  deps.openRequests();
202
242
  }
@@ -1,16 +1,53 @@
1
+ import { type HarnessId } from '../harness-registry';
2
+ import { type AppHost, type Screen } from './app';
3
+ export type Probe = {
4
+ available?: boolean;
5
+ authed?: boolean;
6
+ version?: string;
7
+ account?: string;
8
+ plan?: string;
9
+ accountKind?: string;
10
+ models?: string[];
11
+ };
12
+ export type Caps = Record<string, Probe>;
13
+ export interface HarnessesState {
14
+ caps: Caps | null;
15
+ sel: number;
16
+ spin: number;
17
+ /** Spinner label while an action runs; null when idle. */
18
+ busy: string | null;
19
+ /** Enter on an authed row arms this; y fires it. */
20
+ confirmLogout: HarnessId | null;
21
+ /** Tail of an install's npm output. */
22
+ log: string[];
23
+ note: string | null;
24
+ }
25
+ export declare function actionFor(id: HarnessId, p: Probe | undefined): string;
1
26
  /**
2
- * Run the harness manager.
3
- *
4
- * The returned promise resolves when the USER LEAVES the screen — not when
5
- * the first capability probe finishes. Resolving early is what made the
6
- * embedded case flicker: the dashboard re-attached its own keypress handler
7
- * and repainted while this screen was still live, and the two fought over
8
- * the terminal.
27
+ * Harness CLIs report their version in whatever shape they feel like:
28
+ * `2.1.220 (Claude Code)`, `codex-cli 0.139.0`, `0.52.0`. Prefixing all of
29
+ * those with a `v` produced `vcodex-cli 0.139.0`. Pull out the version
30
+ * itself and leave the branding to the label column.
31
+ */
32
+ export declare function formatVersion(v: string | undefined): string;
33
+ export declare function accountLabel(p: Probe | undefined): string;
34
+ /**
35
+ * Column widths.
9
36
  *
10
- * `embedded` distinguishes `ainode harnesses` (q exits the process) from
11
- * the dashboard's `h` (q hands control back to the caller). Ctrl-C always
12
- * ends the process.
37
+ * The account and the effort ladder vary most, so they take the spare width;
38
+ * version and action are fixed at what their values actually need. The old
39
+ * fixed widths cut `codex-cli` down to `vcodex-cli` and
40
+ * `low/medium/high/xhigh` to `low/medium/high/x`.
13
41
  */
14
- export declare function runHarnessesTui(opts?: {
15
- embedded?: boolean;
16
- }): Promise<void>;
42
+ export declare function harnessColumns(width: number): number[];
43
+ export declare function harnessRow(id: HarnessId, p: Probe | undefined, selected: boolean, width: number): string;
44
+ export declare function renderHarnesses(st: HarnessesState, width: number, height: number): string[];
45
+ export declare function logoutSelected(id: HarnessId): string | null;
46
+ export declare function createHarnessesScreen(deps: {
47
+ host: AppHost;
48
+ /** Hand the raw terminal to a vendor CLI, then take it back. */
49
+ suspend(fn: () => void | Promise<void>): Promise<void>;
50
+ probe?: () => Promise<Caps>;
51
+ }): Screen;
52
+ /** `ainode harnesses` on a pipe: no screen, just the facts. */
53
+ export declare function plainStatus(): Promise<void>;