bosun 0.42.2 → 0.42.4
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/.env.example +9 -0
- package/agent/agent-event-bus.mjs +10 -0
- package/agent/agent-supervisor.mjs +20 -0
- package/bosun-tui.mjs +107 -105
- package/cli.mjs +10 -0
- package/config/config.mjs +25 -0
- package/config/executor-config.mjs +124 -1
- package/infra/container-runner.mjs +565 -1
- package/infra/monitor.mjs +18 -0
- package/infra/tracing.mjs +544 -240
- package/infra/tui-bridge.mjs +13 -1
- package/kanban/kanban-adapter.mjs +128 -4
- package/lib/repo-map.mjs +114 -3
- package/package.json +11 -4
- package/server/ui-server.mjs +3 -0
- package/task/task-archiver.mjs +18 -6
- package/task/task-attachments.mjs +14 -10
- package/task/task-cli.mjs +24 -4
- package/task/task-executor.mjs +19 -0
- package/task/task-store.mjs +194 -37
- package/telegram/telegram-bot.mjs +4 -1
- package/tui/app.mjs +131 -171
- package/tui/components/status-header.mjs +178 -75
- package/tui/lib/header-config.mjs +68 -0
- package/tui/lib/ws-bridge.mjs +61 -9
- package/tui/screens/agents.mjs +127 -0
- package/tui/screens/tasks.mjs +1 -48
- package/ui/app.js +8 -5
- package/ui/components/kanban-board.js +65 -3
- package/ui/components/session-list.js +18 -32
- package/ui/demo-defaults.js +52 -2
- package/ui/modules/session-api.js +100 -0
- package/ui/modules/state.js +71 -15
- package/ui/tabs/workflows.js +25 -1
- package/ui/tui/App.js +298 -0
- package/ui/tui/TasksScreen.js +564 -0
- package/ui/tui/constants.js +55 -0
- package/ui/tui/tasks-screen-helpers.js +301 -0
- package/ui/tui/useTasks.js +61 -0
- package/ui/tui/useWebSocket.js +166 -0
- package/ui/tui/useWorkflows.js +30 -0
- package/workflow/workflow-engine.mjs +412 -7
- package/workflow/workflow-nodes.mjs +616 -75
- package/workflow-templates/agents.mjs +3 -0
- package/workflow-templates/planning.mjs +7 -0
- package/workflow-templates/sub-workflows.mjs +5 -0
- package/workflow-templates/task-execution.mjs +3 -0
- package/workspace/command-diagnostics.mjs +1 -1
- package/workspace/context-cache.mjs +182 -9
package/tui/app.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import htm from "htm";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
|
|
5
5
|
import wsBridge from "./lib/ws-bridge.mjs";
|
|
6
6
|
import StatusHeader from "./components/status-header.mjs";
|
|
7
|
+
import { readTuiHeaderConfig } from "./lib/header-config.mjs";
|
|
7
8
|
import TasksScreen from "./screens/tasks.mjs";
|
|
8
9
|
import AgentsScreen from "./screens/agents.mjs";
|
|
9
10
|
import StatusScreen from "./screens/status.mjs";
|
|
11
|
+
import { listTasksFromApi } from "../ui/tui/tasks-screen-helpers.js";
|
|
10
12
|
|
|
11
13
|
const html = htm.bind(React.createElement);
|
|
12
14
|
|
|
@@ -16,51 +18,112 @@ const SCREENS = {
|
|
|
16
18
|
agents: AgentsScreen,
|
|
17
19
|
};
|
|
18
20
|
|
|
21
|
+
function ScreenTabs({ screen }) {
|
|
22
|
+
const navItems = [
|
|
23
|
+
{ key: "status", num: "1", label: "Status" },
|
|
24
|
+
{ key: "tasks", num: "2", label: "Tasks" },
|
|
25
|
+
{ key: "agents", num: "3", label: "Agents" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
return html`
|
|
29
|
+
<${Box} paddingX=${1} borderStyle="single">
|
|
30
|
+
${navItems.map((item, index) => html`
|
|
31
|
+
<${React.Fragment} key=${item.key}>
|
|
32
|
+
<${Text} inverse=${screen === item.key} color=${screen === item.key ? undefined : "cyan"}>
|
|
33
|
+
[${item.num}] ${item.label}
|
|
34
|
+
<//>
|
|
35
|
+
${index < navItems.length - 1 ? html`<${Text} dimColor> <//>` : null}
|
|
36
|
+
<//>
|
|
37
|
+
`)}
|
|
38
|
+
<${Text} dimColor> [q] Quit<//>
|
|
39
|
+
<//>
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function upsertById(items, nextItem) {
|
|
44
|
+
const index = items.findIndex((item) => item.id === nextItem.id);
|
|
45
|
+
if (index === -1) return [nextItem, ...items];
|
|
46
|
+
const next = [...items];
|
|
47
|
+
next[index] = { ...next[index], ...nextItem };
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export default function App({ host, port, connectOnly, initialScreen, refreshMs }) {
|
|
20
52
|
const [screen, setScreen] = useState(initialScreen || "status");
|
|
21
53
|
const [connected, setConnected] = useState(false);
|
|
54
|
+
const [connectionState, setConnectionState] = useState("offline");
|
|
22
55
|
const [stats, setStats] = useState(null);
|
|
23
56
|
const [sessions, setSessions] = useState([]);
|
|
24
57
|
const [tasks, setTasks] = useState([]);
|
|
25
58
|
const [error, setError] = useState(null);
|
|
59
|
+
const [refreshCountdownSec, setRefreshCountdownSec] = useState(
|
|
60
|
+
Math.max(0, Math.ceil(Number(refreshMs || 2000) / 1000)),
|
|
61
|
+
);
|
|
62
|
+
const [screenInputLocked, setScreenInputLocked] = useState(false);
|
|
63
|
+
const [headerConfig, setHeaderConfig] = useState(() => readTuiHeaderConfig());
|
|
64
|
+
|
|
65
|
+
const bridge = useMemo(
|
|
66
|
+
() => (typeof wsBridge === "function" ? wsBridge({ host, port, refreshMs }) : wsBridge),
|
|
67
|
+
[host, port, refreshMs],
|
|
68
|
+
);
|
|
69
|
+
const bridgeRef = useRef(bridge);
|
|
26
70
|
|
|
27
71
|
useEffect(() => {
|
|
28
|
-
|
|
72
|
+
let active = true;
|
|
73
|
+
bridgeRef.current = bridge;
|
|
74
|
+
setHeaderConfig(readTuiHeaderConfig(bridge?.configDir));
|
|
75
|
+
|
|
76
|
+
const resetRefreshCountdown = () => {
|
|
77
|
+
setRefreshCountdownSec(Math.max(0, Math.ceil(Number(refreshMs || 2000) / 1000)));
|
|
78
|
+
};
|
|
79
|
+
const refreshTasks = async () => {
|
|
80
|
+
try {
|
|
81
|
+
const nextTasks = await listTasksFromApi();
|
|
82
|
+
if (active) setTasks(nextTasks);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (active) setError(String(err?.message || err || "Failed to load tasks"));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const unsubscribes = [];
|
|
89
|
+
const on = (eventName, handler) => {
|
|
90
|
+
const unsubscribe = bridge.on(eventName, handler);
|
|
91
|
+
unsubscribes.push(unsubscribe);
|
|
92
|
+
};
|
|
29
93
|
|
|
30
|
-
|
|
94
|
+
on("connect", () => {
|
|
31
95
|
setConnected(true);
|
|
96
|
+
setConnectionState("connected");
|
|
32
97
|
setError(null);
|
|
98
|
+
resetRefreshCountdown();
|
|
99
|
+
void refreshTasks();
|
|
33
100
|
});
|
|
34
|
-
|
|
35
|
-
bridge.on("disconnect", () => {
|
|
101
|
+
on("disconnect", () => {
|
|
36
102
|
setConnected(false);
|
|
103
|
+
setConnectionState("reconnecting");
|
|
37
104
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
105
|
+
on("reconnecting", () => {
|
|
106
|
+
setConnected(false);
|
|
107
|
+
setConnectionState("reconnecting");
|
|
41
108
|
});
|
|
42
|
-
|
|
43
|
-
|
|
109
|
+
on("error", (err) => {
|
|
110
|
+
const message = err?.message || String(err || "Connection failed");
|
|
111
|
+
setError(message);
|
|
112
|
+
if (String(message).includes("Max reconnection attempts")) {
|
|
113
|
+
setConnectionState("offline");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
on("stats", (data) => {
|
|
44
117
|
setStats(data);
|
|
118
|
+
resetRefreshCountdown();
|
|
45
119
|
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
setSessions((prev) => [...prev, session]);
|
|
120
|
+
on("session:start", (session) => {
|
|
121
|
+
setSessions((prev) => upsertById(prev, session));
|
|
49
122
|
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
setSessions((prev) => {
|
|
53
|
-
const existingIndex = prev.findIndex((candidate) => candidate.id === session.id);
|
|
54
|
-
if (existingIndex >= 0) {
|
|
55
|
-
const updated = [...prev];
|
|
56
|
-
updated[existingIndex] = session;
|
|
57
|
-
return updated;
|
|
58
|
-
}
|
|
59
|
-
return [session, ...prev];
|
|
60
|
-
});
|
|
123
|
+
on("session:update", (session) => {
|
|
124
|
+
setSessions((prev) => upsertById(prev, session));
|
|
61
125
|
});
|
|
62
|
-
|
|
63
|
-
bridge.on("sessions:update", (payload) => {
|
|
126
|
+
on("sessions:update", (payload) => {
|
|
64
127
|
const nextSessions = Array.isArray(payload?.sessions)
|
|
65
128
|
? payload.sessions
|
|
66
129
|
: Array.isArray(payload)
|
|
@@ -68,175 +131,73 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs
|
|
|
68
131
|
: [];
|
|
69
132
|
setSessions(nextSessions);
|
|
70
133
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
setSessions((prev) => prev.filter((candidate) => candidate.id !== session.id));
|
|
134
|
+
on("session:end", (session) => {
|
|
135
|
+
setSessions((prev) => prev.filter((item) => item.id !== session.id));
|
|
74
136
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
setTasks((prev) => {
|
|
78
|
-
const idx = prev.findIndex((candidate) => candidate.id === task.id);
|
|
79
|
-
if (idx >= 0) {
|
|
80
|
-
const updated = [...prev];
|
|
81
|
-
updated[idx] = task;
|
|
82
|
-
return updated;
|
|
83
|
-
}
|
|
84
|
-
return [...prev, task];
|
|
85
|
-
});
|
|
137
|
+
on("tasks:update", () => {
|
|
138
|
+
void refreshTasks();
|
|
86
139
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
setTasks((prev) => [...prev, task]);
|
|
140
|
+
on("task:update", (task) => {
|
|
141
|
+
setTasks((prev) => upsertById(prev, task));
|
|
90
142
|
});
|
|
91
|
-
|
|
92
|
-
|
|
143
|
+
on("task:create", (task) => {
|
|
144
|
+
setTasks((prev) => upsertById(prev, task));
|
|
145
|
+
});
|
|
146
|
+
on("task:delete", (taskId) => {
|
|
93
147
|
setTasks((prev) => prev.filter((task) => task.id !== taskId));
|
|
94
148
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const retryUnsubscribes = [];
|
|
104
|
-
|
|
105
|
-
retryUnsubscribes.push(bridge.on("retry:update", applyRetryQueue));
|
|
106
|
-
retryUnsubscribes.push(bridge.on("retry-queue-updated", applyRetryQueue));
|
|
149
|
+
on("retry:update", (retryQueue) => {
|
|
150
|
+
setStats((prev) => ({ ...(prev || {}), retryQueue }));
|
|
151
|
+
});
|
|
152
|
+
on("retry-queue-updated", (retryQueue) => {
|
|
153
|
+
setStats((prev) => ({ ...(prev || {}), retryQueue }));
|
|
154
|
+
});
|
|
107
155
|
|
|
108
156
|
bridge.connect();
|
|
157
|
+
void refreshTasks();
|
|
109
158
|
|
|
110
159
|
return () => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
return () => {
|
|
118
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
119
|
-
if (typeof unsubscribe === "function") {
|
|
120
|
-
unsubscribe();
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
return () => {
|
|
125
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
126
|
-
if (typeof unsubscribe === "function") {
|
|
127
|
-
unsubscribe();
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
return () => {
|
|
132
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
133
|
-
if (typeof unsubscribe === "function") {
|
|
134
|
-
unsubscribe();
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
return () => {
|
|
139
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
140
|
-
if (typeof unsubscribe === "function") {
|
|
141
|
-
unsubscribe();
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
return () => {
|
|
146
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
147
|
-
if (typeof unsubscribe === "function") {
|
|
148
|
-
unsubscribe();
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
return () => {
|
|
153
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
154
|
-
if (typeof unsubscribe === "function") {
|
|
155
|
-
unsubscribe();
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
return () => {
|
|
160
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
161
|
-
if (typeof unsubscribe === "function") {
|
|
162
|
-
unsubscribe();
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
return () => {
|
|
167
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
168
|
-
if (typeof unsubscribe === "function") {
|
|
169
|
-
unsubscribe();
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
return () => {
|
|
174
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
175
|
-
if (typeof unsubscribe === "function") {
|
|
176
|
-
unsubscribe();
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
return () => {
|
|
181
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
182
|
-
if (typeof unsubscribe === "function") {
|
|
183
|
-
unsubscribe();
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
return () => {
|
|
188
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
189
|
-
if (typeof unsubscribe === "function") {
|
|
190
|
-
unsubscribe();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
return () => {
|
|
195
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
196
|
-
if (typeof unsubscribe === "function") {
|
|
197
|
-
unsubscribe();
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
return () => {
|
|
202
|
-
retryUnsubscribes.forEach((unsubscribe) => {
|
|
203
|
-
if (typeof unsubscribe === "function") {
|
|
204
|
-
unsubscribe();
|
|
205
|
-
}
|
|
160
|
+
active = false;
|
|
161
|
+
unsubscribes.forEach((unsubscribe) => {
|
|
162
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
206
163
|
});
|
|
207
164
|
bridge.disconnect();
|
|
165
|
+
bridgeRef.current = null;
|
|
208
166
|
};
|
|
209
|
-
}, [
|
|
167
|
+
}, [bridge, refreshMs]);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const intervalId = setInterval(() => {
|
|
171
|
+
setRefreshCountdownSec((prev) => Math.max(0, prev - 1));
|
|
172
|
+
}, 1000);
|
|
173
|
+
return () => clearInterval(intervalId);
|
|
174
|
+
}, []);
|
|
210
175
|
|
|
211
176
|
const handleKeyPress = useCallback((key) => {
|
|
212
|
-
if (key === "q")
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (key === "
|
|
216
|
-
setScreen("status");
|
|
217
|
-
}
|
|
218
|
-
if (key === "2") {
|
|
219
|
-
setScreen("tasks");
|
|
220
|
-
}
|
|
221
|
-
if (key === "3") {
|
|
222
|
-
setScreen("agents");
|
|
223
|
-
}
|
|
177
|
+
if (key === "q") process.exit(0);
|
|
178
|
+
if (key === "1") setScreen("status");
|
|
179
|
+
if (key === "2") setScreen("tasks");
|
|
180
|
+
if (key === "3") setScreen("agents");
|
|
224
181
|
}, []);
|
|
225
182
|
|
|
226
183
|
useInput((input) => {
|
|
184
|
+
if (screenInputLocked) return;
|
|
227
185
|
handleKeyPress(input);
|
|
228
186
|
});
|
|
229
187
|
|
|
230
188
|
const ScreenComponent = SCREENS[screen] || StatusScreen;
|
|
231
|
-
const wsBridgeInstance = typeof wsBridge === "function" ? wsBridge({ host, port }) : wsBridge;
|
|
232
189
|
|
|
233
190
|
return html`
|
|
234
191
|
<${Box} flexDirection="column" minHeight=${0}>
|
|
235
192
|
<${StatusHeader}
|
|
236
193
|
stats=${stats}
|
|
237
194
|
connected=${connected}
|
|
238
|
-
|
|
195
|
+
connectionState=${connectionState}
|
|
196
|
+
projectLabel=${headerConfig.projectLabel}
|
|
197
|
+
configuredProviders=${headerConfig.configuredProviders}
|
|
198
|
+
refreshCountdownSec=${refreshCountdownSec}
|
|
239
199
|
/>
|
|
200
|
+
<${ScreenTabs} screen=${screen} />
|
|
240
201
|
<${Box} flexDirection="column" flexGrow=${1}>
|
|
241
202
|
${error
|
|
242
203
|
? html`
|
|
@@ -249,16 +210,15 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs
|
|
|
249
210
|
stats=${stats}
|
|
250
211
|
sessions=${sessions}
|
|
251
212
|
tasks=${tasks}
|
|
252
|
-
wsBridge=${
|
|
213
|
+
wsBridge=${bridgeRef.current}
|
|
253
214
|
host=${host}
|
|
254
215
|
port=${port}
|
|
255
216
|
connectOnly=${connectOnly}
|
|
256
217
|
refreshMs=${refreshMs}
|
|
218
|
+
onTasksChange=${setTasks}
|
|
219
|
+
onInputCaptureChange=${setScreenInputLocked}
|
|
257
220
|
/>
|
|
258
221
|
<//>
|
|
259
|
-
<${Box} paddingX=${1} borderStyle="single">
|
|
260
|
-
<${Text} dimColor>[1] Status [2] Tasks [3] Agents [q] Quit<//>
|
|
261
|
-
<//>
|
|
262
222
|
<//>
|
|
263
223
|
`;
|
|
264
224
|
}
|
|
@@ -4,99 +4,202 @@ import { Box, Text } from "ink";
|
|
|
4
4
|
|
|
5
5
|
const html = htm.bind(React.createElement);
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const PROVIDER_ORDER = ["claude", "codex", "gemini", "copilot"];
|
|
8
|
+
const PROVIDER_ALIASES = {
|
|
9
|
+
anthropic: "claude",
|
|
10
|
+
claude: "claude",
|
|
11
|
+
openai: "codex",
|
|
12
|
+
azure: "codex",
|
|
13
|
+
codex: "codex",
|
|
14
|
+
google: "gemini",
|
|
15
|
+
gemini: "gemini",
|
|
16
|
+
copilot: "copilot",
|
|
17
|
+
github: "copilot",
|
|
11
18
|
};
|
|
19
|
+
const TONE_COLORS = {
|
|
20
|
+
normal: undefined,
|
|
21
|
+
dim: undefined,
|
|
22
|
+
warning: "yellow",
|
|
23
|
+
danger: "red",
|
|
24
|
+
};
|
|
25
|
+
const CONNECTION_STATES = {
|
|
26
|
+
connected: { color: "green", label: "Connected" },
|
|
27
|
+
reconnecting: { color: "yellow", label: "Reconnecting" },
|
|
28
|
+
offline: { color: "red", label: "Offline" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function toNumber(value, fallback = 0) {
|
|
32
|
+
const numeric = Number(value);
|
|
33
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function padMetric(value, width = 0) {
|
|
37
|
+
return String(value ?? "").padStart(width, " ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatCompactMetric(value) {
|
|
41
|
+
const numeric = Math.max(0, toNumber(value, 0));
|
|
42
|
+
if (numeric >= 1_000_000) return `${(numeric / 1_000_000).toFixed(1)}m`;
|
|
43
|
+
if (numeric >= 1_000) return `${(numeric / 1_000).toFixed(1)}k`;
|
|
44
|
+
if (numeric >= 100) return String(Math.round(numeric));
|
|
45
|
+
if (numeric >= 10) return Number(numeric.toFixed(1)).toString();
|
|
46
|
+
return Number(numeric.toFixed(2)).toString();
|
|
47
|
+
}
|
|
12
48
|
|
|
13
49
|
function formatDuration(ms) {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const hours = Math.floor(
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
if (
|
|
50
|
+
const safeMs = Math.max(0, toNumber(ms, 0));
|
|
51
|
+
const totalSeconds = Math.floor(safeMs / 1000);
|
|
52
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
53
|
+
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
|
54
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
55
|
+
const seconds = totalSeconds % 60;
|
|
56
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
57
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
58
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
22
59
|
return `${seconds}s`;
|
|
23
60
|
}
|
|
24
61
|
|
|
25
|
-
function
|
|
26
|
-
const
|
|
27
|
-
return
|
|
62
|
+
function normalizeProviderKey(provider) {
|
|
63
|
+
const normalized = String(provider || "").trim().toLowerCase();
|
|
64
|
+
return PROVIDER_ALIASES[normalized] || normalized;
|
|
28
65
|
}
|
|
29
66
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
totalCostUsd: 0,
|
|
35
|
-
totalSessions: 0,
|
|
36
|
-
activeSessions: 0,
|
|
37
|
-
totalTasks: 0,
|
|
38
|
-
activeTasks: 0,
|
|
39
|
-
completedTasks: 0,
|
|
40
|
-
failedTasks: 0,
|
|
41
|
-
retryQueue: { count: 0 },
|
|
42
|
-
workflows: { active: 0, total: 0 },
|
|
43
|
-
agents: { online: 0, total: 0 },
|
|
44
|
-
...(stats || {}),
|
|
45
|
-
};
|
|
67
|
+
function formatRateValue(value, unit = "min") {
|
|
68
|
+
if (value == null) return "n/a";
|
|
69
|
+
return `${toNumber(value, 0)}/${unit}`;
|
|
70
|
+
}
|
|
46
71
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
function resolveProviderTone(bucket = {}) {
|
|
73
|
+
const values = [bucket.primary, bucket.secondary, bucket.credits]
|
|
74
|
+
.map((value) => (value == null ? null : toNumber(value, NaN)))
|
|
75
|
+
.filter((value) => Number.isFinite(value));
|
|
76
|
+
if (values.some((value) => value <= 0)) return "danger";
|
|
77
|
+
|
|
78
|
+
const limits = [
|
|
79
|
+
[bucket.primary, bucket.primaryLimit],
|
|
80
|
+
[bucket.secondary, bucket.secondaryLimit],
|
|
81
|
+
[bucket.credits, bucket.creditsLimit],
|
|
51
82
|
];
|
|
83
|
+
if (limits.some(([remaining, limit]) => Number.isFinite(Number(remaining)) && Number.isFinite(Number(limit)) && Number(limit) > 0 && (Number(remaining) / Number(limit)) <= 0.2)) {
|
|
84
|
+
return "warning";
|
|
85
|
+
}
|
|
86
|
+
if (values.some((value) => value <= 20)) return "warning";
|
|
87
|
+
return "normal";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeHeaderRateLimits(rateLimits = {}, configuredProviders = {}) {
|
|
91
|
+
const normalized = {};
|
|
92
|
+
for (const [provider, bucket] of Object.entries(rateLimits || {})) {
|
|
93
|
+
const key = normalizeProviderKey(provider);
|
|
94
|
+
if (!PROVIDER_ORDER.includes(key) || !bucket || typeof bucket !== "object") continue;
|
|
95
|
+
normalized[key] = {
|
|
96
|
+
primary: bucket.primary == null ? null : toNumber(bucket.primary, 0),
|
|
97
|
+
secondary: bucket.secondary == null ? null : toNumber(bucket.secondary, 0),
|
|
98
|
+
credits: bucket.credits == null ? null : toNumber(bucket.credits, 0),
|
|
99
|
+
primaryLimit: bucket.primaryLimit == null ? null : toNumber(bucket.primaryLimit, 0),
|
|
100
|
+
secondaryLimit: bucket.secondaryLimit == null ? null : toNumber(bucket.secondaryLimit, 0),
|
|
101
|
+
creditsLimit: bucket.creditsLimit == null ? null : toNumber(bucket.creditsLimit, 0),
|
|
102
|
+
unit: String(bucket.unit || "min").trim() || "min",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return PROVIDER_ORDER.reduce((acc, provider) => {
|
|
107
|
+
const configured = configuredProviders[provider] === true || Boolean(normalized[provider]);
|
|
108
|
+
const bucket = normalized[provider] || null;
|
|
109
|
+
if (!configured || !bucket) {
|
|
110
|
+
acc[provider] = {
|
|
111
|
+
provider,
|
|
112
|
+
configured,
|
|
113
|
+
tone: "dim",
|
|
114
|
+
label: `${provider} n/a`,
|
|
115
|
+
};
|
|
116
|
+
return acc;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
acc[provider] = {
|
|
120
|
+
provider,
|
|
121
|
+
configured,
|
|
122
|
+
tone: resolveProviderTone(bucket),
|
|
123
|
+
label: `${provider} primary ${formatRateValue(bucket.primary, bucket.unit)} | secondary ${formatRateValue(bucket.secondary, bucket.unit)} | credits ${bucket.credits == null ? "n/a" : toNumber(bucket.credits, 0)}`,
|
|
124
|
+
};
|
|
125
|
+
return acc;
|
|
126
|
+
}, {});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildStatusHeaderModel({
|
|
130
|
+
stats = {},
|
|
131
|
+
configuredProviders = {},
|
|
132
|
+
connectionState = "offline",
|
|
133
|
+
projectLabel = "No project",
|
|
134
|
+
refreshCountdownSec = 0,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const activeAgents = Math.max(0, toNumber(stats?.activeAgents, 0));
|
|
137
|
+
const maxAgents = Math.max(0, toNumber(stats?.maxAgents, 0));
|
|
138
|
+
const throughputTps = Math.max(0, toNumber(stats?.throughputTps, 0));
|
|
139
|
+
const uptimeMs = Math.max(0, toNumber(stats?.uptimeMs, 0));
|
|
140
|
+
const tokensIn = Math.max(0, toNumber(stats?.tokensIn, 0));
|
|
141
|
+
const tokensOut = Math.max(0, toNumber(stats?.tokensOut, 0));
|
|
142
|
+
const tokensTotal = Math.max(0, toNumber(stats?.tokensTotal ?? stats?.totalTokens, tokensIn + tokensOut));
|
|
143
|
+
const providers = normalizeHeaderRateLimits(stats?.rateLimits || {}, configuredProviders);
|
|
144
|
+
const connection = CONNECTION_STATES[connectionState] || CONNECTION_STATES.offline;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
row1: `Agents: ${padMetric(activeAgents, 2)}/${padMetric(maxAgents, 2)} | Throughput: ${throughputTps} tps | Runtime: ${formatDuration(uptimeMs)} | Tokens: in ${formatCompactMetric(tokensIn)} | out ${formatCompactMetric(tokensOut)} | total ${formatCompactMetric(tokensTotal)}`,
|
|
148
|
+
row2: PROVIDER_ORDER.map((provider) => providers[provider]),
|
|
149
|
+
row3: {
|
|
150
|
+
connection,
|
|
151
|
+
projectLabel: String(projectLabel || "").trim() || "No project",
|
|
152
|
+
refreshLabel: `Next refresh: ${Math.max(0, Math.trunc(toNumber(refreshCountdownSec, 0)))}s`,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default function StatusHeader({
|
|
158
|
+
stats,
|
|
159
|
+
connected,
|
|
160
|
+
connectionState,
|
|
161
|
+
projectLabel,
|
|
162
|
+
configuredProviders,
|
|
163
|
+
refreshCountdownSec,
|
|
164
|
+
}) {
|
|
165
|
+
const resolvedConnectionState = connectionState || (connected ? "connected" : "offline");
|
|
166
|
+
const model = buildStatusHeaderModel({
|
|
167
|
+
stats,
|
|
168
|
+
configuredProviders,
|
|
169
|
+
connectionState: resolvedConnectionState,
|
|
170
|
+
projectLabel,
|
|
171
|
+
refreshCountdownSec,
|
|
172
|
+
});
|
|
173
|
+
const connectionDot = resolvedConnectionState === "reconnecting" && refreshCountdownSec % 2 === 0
|
|
174
|
+
? "◌"
|
|
175
|
+
: "●";
|
|
52
176
|
|
|
53
177
|
return html`
|
|
54
|
-
<${Box} flexDirection="column"
|
|
55
|
-
<${Box} justifyContent="space-between">
|
|
56
|
-
<${Box}>
|
|
57
|
-
<${Text} bold>Bosun TUI<//>
|
|
58
|
-
<${Text} dimColor> | <//>
|
|
59
|
-
<${Text}
|
|
60
|
-
color=${connected ? STATUS_COLORS.connected : STATUS_COLORS.disconnected}
|
|
61
|
-
bold=${!connected}
|
|
62
|
-
>
|
|
63
|
-
${connected ? "Connected" : "Disconnected"}
|
|
64
|
-
<//>
|
|
65
|
-
<//>
|
|
66
|
-
<${Box}>
|
|
67
|
-
<${Text} dimColor>Uptime: <//>
|
|
68
|
-
<${Text}>${formatDuration(s.uptimeMs)}<//>
|
|
69
|
-
<${Text} dimColor> | Runtime: <//>
|
|
70
|
-
<${Text}>${formatDuration(s.runtimeMs)}<//>
|
|
71
|
-
<${Text} dimColor> | Cost: <//>
|
|
72
|
-
<${Text}>${formatCost(s.totalCostUsd)}<//>
|
|
73
|
-
<//>
|
|
74
|
-
<//>
|
|
178
|
+
<${Box} flexDirection="column" paddingX=${1} paddingTop=${1}>
|
|
75
179
|
<${Box}>
|
|
76
|
-
<${Text}
|
|
77
|
-
<${Text} color=${s.activeSessions > 0 ? STATUS_COLORS.active : undefined}>
|
|
78
|
-
${s.activeSessions}
|
|
79
|
-
<//>
|
|
80
|
-
<${Text} dimColor>/${s.totalSessions} Tasks: <//>
|
|
81
|
-
<${Text} color=${s.activeTasks > 0 ? STATUS_COLORS.active : undefined}>
|
|
82
|
-
${s.activeTasks}
|
|
83
|
-
<//>
|
|
84
|
-
<${Text} dimColor>
|
|
85
|
-
/${s.totalTasks} Done:${s.completedTasks} Fail:${s.failedTasks}
|
|
86
|
-
| Retry:${s.retryQueue?.count || 0}
|
|
87
|
-
| WF:${s.workflows?.active || 0}/${s.workflows?.total || 0}
|
|
88
|
-
| Agents:${s.agents?.online || 0}/${s.agents?.total || 0}
|
|
89
|
-
<//>
|
|
180
|
+
<${Text}>${model.row1}<//>
|
|
90
181
|
<//>
|
|
91
|
-
<${Box}>
|
|
92
|
-
${
|
|
93
|
-
<${
|
|
94
|
-
<${Text}
|
|
95
|
-
[
|
|
182
|
+
<${Box} marginTop=${1}>
|
|
183
|
+
${model.row2.map((provider, index) => html`
|
|
184
|
+
<${React.Fragment} key=${provider.provider}>
|
|
185
|
+
<${Text}
|
|
186
|
+
color=${TONE_COLORS[provider.tone]}
|
|
187
|
+
dimColor=${provider.tone === "dim"}
|
|
188
|
+
>
|
|
189
|
+
${provider.label}
|
|
96
190
|
<//>
|
|
191
|
+
${index < model.row2.length - 1 ? html`<${Text} dimColor> | <//>` : null}
|
|
97
192
|
<//>
|
|
98
193
|
`)}
|
|
99
194
|
<//>
|
|
195
|
+
<${Box} marginTop=${1}>
|
|
196
|
+
<${Text} color=${model.row3.connection.color}>${connectionDot}<//>
|
|
197
|
+
<${Text}> ${model.row3.connection.label}<//>
|
|
198
|
+
<${Text} dimColor> | Project: <//>
|
|
199
|
+
<${Text}>${model.row3.projectLabel}<//>
|
|
200
|
+
<${Text} dimColor> | <//>
|
|
201
|
+
<${Text}>${model.row3.refreshLabel}<//>
|
|
202
|
+
<//>
|
|
100
203
|
<//>
|
|
101
204
|
`;
|
|
102
205
|
}
|