@katyella/legio 0.1.2 → 0.2.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/CHANGELOG.md +47 -3
- package/README.md +15 -8
- package/agents/builder.md +11 -10
- package/agents/coordinator.md +36 -27
- package/agents/cto.md +9 -8
- package/agents/gateway.md +28 -12
- package/agents/lead.md +45 -30
- package/agents/merger.md +4 -4
- package/agents/monitor.md +10 -9
- package/agents/reviewer.md +8 -8
- package/agents/scout.md +10 -10
- package/agents/supervisor.md +60 -45
- package/bin/legio.mjs +13 -2
- package/package.json +3 -3
- package/src/agents/hooks-deployer.test.ts +46 -41
- package/src/agents/hooks-deployer.ts +10 -9
- package/src/agents/manifest.test.ts +6 -2
- package/src/agents/overlay.test.ts +9 -7
- package/src/agents/overlay.ts +29 -7
- package/src/commands/agents.test.ts +1 -5
- package/src/commands/clean.test.ts +2 -5
- package/src/commands/clean.ts +25 -1
- package/src/commands/completions.test.ts +1 -1
- package/src/commands/completions.ts +26 -7
- package/src/commands/coordinator.test.ts +78 -78
- package/src/commands/coordinator.ts +92 -47
- package/src/commands/costs.test.ts +2 -6
- package/src/commands/dashboard.test.ts +2 -5
- package/src/commands/doctor.test.ts +2 -6
- package/src/commands/down.ts +3 -3
- package/src/commands/errors.test.ts +2 -6
- package/src/commands/feed.test.ts +2 -6
- package/src/commands/gateway.test.ts +39 -13
- package/src/commands/gateway.ts +95 -7
- package/src/commands/hooks.test.ts +2 -5
- package/src/commands/init.test.ts +4 -13
- package/src/commands/inspect.test.ts +2 -6
- package/src/commands/log.test.ts +2 -6
- package/src/commands/logs.test.ts +2 -9
- package/src/commands/mail.test.ts +76 -215
- package/src/commands/mail.ts +43 -187
- package/src/commands/metrics.test.ts +3 -10
- package/src/commands/nudge.ts +15 -0
- package/src/commands/prime.test.ts +4 -11
- package/src/commands/replay.test.ts +2 -6
- package/src/commands/server.test.ts +1 -5
- package/src/commands/server.ts +1 -1
- package/src/commands/sling.ts +40 -16
- package/src/commands/spec.test.ts +2 -5
- package/src/commands/status.test.ts +2 -4
- package/src/commands/stop.test.ts +2 -5
- package/src/commands/supervisor.ts +6 -6
- package/src/commands/trace.test.ts +2 -6
- package/src/commands/up.test.ts +43 -9
- package/src/commands/up.ts +15 -11
- package/src/commands/watchman.ts +327 -0
- package/src/commands/worktree.test.ts +2 -6
- package/src/config.test.ts +34 -104
- package/src/config.ts +120 -32
- package/src/doctor/agents.test.ts +7 -2
- package/src/doctor/config-check.test.ts +7 -2
- package/src/doctor/consistency.test.ts +7 -2
- package/src/doctor/databases.test.ts +6 -2
- package/src/doctor/dependencies.test.ts +35 -10
- package/src/doctor/dependencies.ts +16 -92
- package/src/doctor/logs.test.ts +7 -2
- package/src/doctor/merge-queue.test.ts +6 -2
- package/src/doctor/structure.test.ts +7 -2
- package/src/doctor/version.test.ts +7 -2
- package/src/e2e/init-sling-lifecycle.test.ts +2 -5
- package/src/index.ts +7 -7
- package/src/mail/pending.ts +120 -0
- package/src/mail/store.test.ts +89 -0
- package/src/mail/store.ts +11 -0
- package/src/merge/resolver.test.ts +518 -489
- package/src/server/index.ts +33 -2
- package/src/server/public/app.js +3 -3
- package/src/server/public/components/message-bubble.js +11 -1
- package/src/server/public/components/terminal-panel.js +66 -74
- package/src/server/public/views/chat.js +18 -2
- package/src/server/public/views/costs.js +5 -5
- package/src/server/public/views/dashboard.js +80 -51
- package/src/server/public/views/gateway-chat.js +37 -131
- package/src/server/public/views/inspect.js +16 -4
- package/src/server/public/views/issues.js +16 -12
- package/src/server/routes.test.ts +55 -39
- package/src/server/routes.ts +38 -26
- package/src/test-helpers.ts +6 -3
- package/src/tracker/beads.ts +159 -0
- package/src/tracker/exec.ts +44 -0
- package/src/tracker/factory.test.ts +283 -0
- package/src/tracker/factory.ts +59 -0
- package/src/tracker/seeds.ts +156 -0
- package/src/tracker/types.ts +46 -0
- package/src/types.ts +11 -2
- package/src/{watchdog → watchman}/daemon.test.ts +421 -515
- package/src/watchman/daemon.ts +940 -0
- package/src/worktree/tmux.test.ts +2 -1
- package/src/worktree/tmux.ts +4 -4
- package/templates/hooks.json.tmpl +17 -17
- package/src/beads/client.test.ts +0 -210
- package/src/commands/merge.test.ts +0 -676
- package/src/commands/watch.test.ts +0 -152
- package/src/commands/watch.ts +0 -238
- package/src/test-helpers.test.ts +0 -97
- package/src/watchdog/daemon.ts +0 -533
- package/src/watchdog/health.test.ts +0 -371
- package/src/watchdog/triage.test.ts +0 -162
- package/src/worktree/manager.test.ts +0 -444
- /package/src/{watchdog → watchman}/health.ts +0 -0
- /package/src/{watchdog → watchman}/triage.ts +0 -0
|
@@ -28,9 +28,7 @@ export function GatewayChat({ gwRunning }) {
|
|
|
28
28
|
const [sendError, setSendError] = useState("");
|
|
29
29
|
const [pendingMessages, setPendingMessages] = useState([]);
|
|
30
30
|
const [historyMessages, setHistoryMessages] = useState([]);
|
|
31
|
-
const [
|
|
32
|
-
const [showCoordinator, setShowCoordinator] = useState(false);
|
|
33
|
-
const [thinking, setThinking] = useState(false);
|
|
31
|
+
const [terminalActivity, setTerminalActivity] = useState("idle");
|
|
34
32
|
const [dropdown, setDropdown] = useState({
|
|
35
33
|
visible: false,
|
|
36
34
|
items: [],
|
|
@@ -42,15 +40,13 @@ export function GatewayChat({ gwRunning }) {
|
|
|
42
40
|
const prevFromAgentCountRef = useRef(0);
|
|
43
41
|
const inputRef = useRef(null);
|
|
44
42
|
const pendingCursorRef = useRef(null);
|
|
45
|
-
const thinkingTimeoutRef = useRef(null);
|
|
46
|
-
const thinkingCountRef = useRef(0);
|
|
47
43
|
|
|
48
44
|
const inputClass =
|
|
49
45
|
"bg-[#1a1a1a] border border-[#2a2a2a] rounded px-2 py-1 text-sm text-[#e5e5e5]" +
|
|
50
46
|
" placeholder-[#666] outline-none focus:border-[#E64415]";
|
|
51
47
|
|
|
52
48
|
// Load gateway chat history on mount and poll for updates
|
|
53
|
-
// Poll faster (2000ms) when
|
|
49
|
+
// Poll faster (2000ms) when active so we catch replies quickly
|
|
54
50
|
useEffect(() => {
|
|
55
51
|
let cancelled = false;
|
|
56
52
|
|
|
@@ -70,43 +66,12 @@ export function GatewayChat({ gwRunning }) {
|
|
|
70
66
|
}
|
|
71
67
|
|
|
72
68
|
fetchHistory();
|
|
73
|
-
const interval = setInterval(fetchHistory,
|
|
69
|
+
const interval = setInterval(fetchHistory, terminalActivity === "active" ? 2000 : 5000);
|
|
74
70
|
return () => {
|
|
75
71
|
cancelled = true;
|
|
76
72
|
clearInterval(interval);
|
|
77
73
|
};
|
|
78
|
-
}, [
|
|
79
|
-
|
|
80
|
-
// Fetch coordinator messages when toggle is enabled
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!showCoordinator) {
|
|
83
|
-
setCoordinatorMessages([]);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
let cancelled = false;
|
|
87
|
-
|
|
88
|
-
async function fetchCoordinatorHistory() {
|
|
89
|
-
try {
|
|
90
|
-
const data = await fetchJson("/api/coordinator/chat/history?limit=200");
|
|
91
|
-
if (!cancelled) {
|
|
92
|
-
const next = Array.isArray(data) ? data : [];
|
|
93
|
-
setCoordinatorMessages((prev) => {
|
|
94
|
-
if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
|
|
95
|
-
return next;
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
} catch (_err) {
|
|
99
|
-
// non-fatal
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
fetchCoordinatorHistory();
|
|
104
|
-
const interval = setInterval(fetchCoordinatorHistory, thinking ? 2000 : 5000);
|
|
105
|
-
return () => {
|
|
106
|
-
cancelled = true;
|
|
107
|
-
clearInterval(interval);
|
|
108
|
-
};
|
|
109
|
-
}, [showCoordinator, thinking]);
|
|
74
|
+
}, [terminalActivity]);
|
|
110
75
|
|
|
111
76
|
// Subscribe to WebSocket mail_new events via appState.mail signal for instant updates
|
|
112
77
|
useEffect(() => {
|
|
@@ -117,35 +82,16 @@ export function GatewayChat({ gwRunning }) {
|
|
|
117
82
|
(m.from === "gateway" && m.to === "human")) &&
|
|
118
83
|
(m.audience === "human" || m.audience === "both"),
|
|
119
84
|
);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const existingIds = new Set(prev.map((m) => m.id));
|
|
131
|
-
const newMsgs = gatewayMessages.filter((m) => !existingIds.has(m.id));
|
|
132
|
-
if (newMsgs.length === 0) return prev;
|
|
133
|
-
return [...prev, ...newMsgs].sort(
|
|
134
|
-
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
135
|
-
);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
if (coordMessages.length > 0) {
|
|
139
|
-
setCoordinatorMessages((prev) => {
|
|
140
|
-
const existingIds = new Set(prev.map((m) => m.id));
|
|
141
|
-
const newMsgs = coordMessages.filter((m) => !existingIds.has(m.id));
|
|
142
|
-
if (newMsgs.length === 0) return prev;
|
|
143
|
-
return [...prev, ...newMsgs].sort(
|
|
144
|
-
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}, [appState.mail.value, showCoordinator]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
85
|
+
if (gatewayMessages.length === 0) return;
|
|
86
|
+
setHistoryMessages((prev) => {
|
|
87
|
+
const existingIds = new Set(prev.map((m) => m.id));
|
|
88
|
+
const newMsgs = gatewayMessages.filter((m) => !existingIds.has(m.id));
|
|
89
|
+
if (newMsgs.length === 0) return prev;
|
|
90
|
+
return [...prev, ...newMsgs].sort(
|
|
91
|
+
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
}, [appState.mail.value]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
149
95
|
|
|
150
96
|
// Poll POST /api/chat/transcript-sync to sync gateway transcript responses into mail.db
|
|
151
97
|
useEffect(() => {
|
|
@@ -159,12 +105,12 @@ export function GatewayChat({ gwRunning }) {
|
|
|
159
105
|
}
|
|
160
106
|
}
|
|
161
107
|
syncTranscript();
|
|
162
|
-
const interval = setInterval(syncTranscript,
|
|
108
|
+
const interval = setInterval(syncTranscript, terminalActivity === "active" ? 2000 : 10000);
|
|
163
109
|
return () => {
|
|
164
110
|
cancelled = true;
|
|
165
111
|
clearInterval(interval);
|
|
166
112
|
};
|
|
167
|
-
}, [
|
|
113
|
+
}, [terminalActivity]);
|
|
168
114
|
|
|
169
115
|
// Consume pendingChatContext from issue click-through
|
|
170
116
|
useEffect(() => {
|
|
@@ -175,14 +121,13 @@ export function GatewayChat({ gwRunning }) {
|
|
|
175
121
|
inputRef.current?.focus();
|
|
176
122
|
}, [appState.pendingChatContext.value]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
177
123
|
|
|
178
|
-
// Count non-human responses in history — detect new agent replies
|
|
124
|
+
// Count non-human responses in history — detect new agent replies
|
|
179
125
|
const fromAgentCount = historyMessages.filter((m) => m.from !== "human").length;
|
|
180
126
|
|
|
181
127
|
useEffect(() => {
|
|
182
128
|
if (fromAgentCount > prevFromAgentCountRef.current) {
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
if (thinkingCountRef.current === 0) setThinking(false);
|
|
129
|
+
// Agent replied — transition activity to ready (state machine in TerminalPanel takes over)
|
|
130
|
+
setTerminalActivity("ready");
|
|
186
131
|
// Deduplicate pending messages that now appear in history
|
|
187
132
|
setPendingMessages((prev) =>
|
|
188
133
|
prev.filter(
|
|
@@ -216,28 +161,10 @@ export function GatewayChat({ gwRunning }) {
|
|
|
216
161
|
);
|
|
217
162
|
}, [historyMessages]);
|
|
218
163
|
|
|
219
|
-
//
|
|
220
|
-
useEffect(() => {
|
|
221
|
-
if (thinking) {
|
|
222
|
-
thinkingTimeoutRef.current = setTimeout(() => {
|
|
223
|
-
thinkingCountRef.current = 0;
|
|
224
|
-
setThinking(false);
|
|
225
|
-
}, 60000);
|
|
226
|
-
}
|
|
227
|
-
return () => {
|
|
228
|
-
if (thinkingTimeoutRef.current) {
|
|
229
|
-
clearTimeout(thinkingTimeoutRef.current);
|
|
230
|
-
thinkingTimeoutRef.current = null;
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
}, [thinking]);
|
|
234
|
-
|
|
235
|
-
// Merge history + pending (+ coordinator when toggle on), deduplicate by id, sort oldest first
|
|
164
|
+
// Merge history + pending, deduplicate by id, sort oldest first
|
|
236
165
|
const seenIds = new Set();
|
|
237
166
|
const allMessages = [];
|
|
238
|
-
const mergeSource =
|
|
239
|
-
? [...historyMessages, ...coordinatorMessages, ...pendingMessages]
|
|
240
|
-
: [...historyMessages, ...pendingMessages];
|
|
167
|
+
const mergeSource = [...historyMessages, ...pendingMessages];
|
|
241
168
|
for (const msg of mergeSource) {
|
|
242
169
|
if (!seenIds.has(msg.id)) {
|
|
243
170
|
seenIds.add(msg.id);
|
|
@@ -246,6 +173,11 @@ export function GatewayChat({ gwRunning }) {
|
|
|
246
173
|
}
|
|
247
174
|
allMessages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
248
175
|
|
|
176
|
+
// Look up gateway agent session state for TerminalPanel ready/idle determination
|
|
177
|
+
const gwAgents = appState.agents.value ?? [];
|
|
178
|
+
const gwSession = gwAgents.find((a) => (a.agentName ?? a.name) === "gateway");
|
|
179
|
+
const gwState = gwSession?.state ?? null;
|
|
180
|
+
|
|
249
181
|
// Auto-scroll to bottom when near bottom
|
|
250
182
|
useEffect(() => {
|
|
251
183
|
const feed = feedRef.current;
|
|
@@ -254,7 +186,7 @@ export function GatewayChat({ gwRunning }) {
|
|
|
254
186
|
feed.scrollTop = feed.scrollHeight;
|
|
255
187
|
});
|
|
256
188
|
}
|
|
257
|
-
}, [allMessages.length,
|
|
189
|
+
}, [allMessages.length, terminalActivity]);
|
|
258
190
|
|
|
259
191
|
// Restore cursor position after programmatic input update (e.g., @-mention insertion)
|
|
260
192
|
useEffect(() => {
|
|
@@ -295,8 +227,7 @@ export function GatewayChat({ gwRunning }) {
|
|
|
295
227
|
inputRef.current.style.height = "auto";
|
|
296
228
|
}
|
|
297
229
|
inputRef.current?.focus();
|
|
298
|
-
|
|
299
|
-
setThinking(true);
|
|
230
|
+
setTerminalActivity("active");
|
|
300
231
|
await postJson("/api/gateway/chat", { text });
|
|
301
232
|
// Do NOT remove pending here — let the historyMessages useEffect deduplicate
|
|
302
233
|
// once the history poll confirms the message, preventing a visible flash/gap.
|
|
@@ -430,17 +361,6 @@ export function GatewayChat({ gwRunning }) {
|
|
|
430
361
|
<div class="px-3 py-2 border-b border-[#2a2a2a] shrink-0 flex items-center gap-2">
|
|
431
362
|
<span class="text-sm font-medium text-[#e5e5e5]">Chat</span>
|
|
432
363
|
<span class="ml-1 text-xs text-[#555]">All agents</span>
|
|
433
|
-
<button
|
|
434
|
-
onClick=${() => setShowCoordinator((v) => !v)}
|
|
435
|
-
class=${
|
|
436
|
-
"ml-auto text-xs px-2 py-0.5 rounded border cursor-pointer " +
|
|
437
|
-
(showCoordinator
|
|
438
|
-
? "border-purple-600 text-purple-400 bg-purple-900/20"
|
|
439
|
-
: "border-[#2a2a2a] text-[#666] hover:border-[#444] bg-transparent")
|
|
440
|
-
}
|
|
441
|
-
>
|
|
442
|
-
${showCoordinator ? "Hide Coordinator" : "Show Coordinator"}
|
|
443
|
-
</button>
|
|
444
364
|
</div>
|
|
445
365
|
|
|
446
366
|
<!-- Message feed — unified timeline of all human-audience messages -->
|
|
@@ -460,22 +380,13 @@ export function GatewayChat({ gwRunning }) {
|
|
|
460
380
|
const isFromUser = msg.from === "human";
|
|
461
381
|
const isSending = msg.status === "sending";
|
|
462
382
|
const isCommand = isFromUser && (msg.body ?? "").startsWith("/");
|
|
463
|
-
const isCoordinator = msg.from === "coordinator" || msg.to === "coordinator";
|
|
464
383
|
|
|
465
384
|
// Determine bubble styling
|
|
466
385
|
let bubbleClass = "max-w-[85%] rounded px-3 py-2 text-sm ";
|
|
467
|
-
if (isFromUser
|
|
468
|
-
// Human -> coordinator
|
|
469
|
-
bubbleClass +=
|
|
470
|
-
"bg-purple-600/20 text-[#e5e5e5] border border-purple-600/30" +
|
|
471
|
-
(isSending ? " opacity-70" : "");
|
|
472
|
-
} else if (isFromUser) {
|
|
386
|
+
if (isFromUser) {
|
|
473
387
|
bubbleClass +=
|
|
474
388
|
"bg-[#E64415]/20 text-[#e5e5e5] border border-[#E64415]/30" +
|
|
475
389
|
(isSending ? " opacity-70" : "");
|
|
476
|
-
} else if (isCoordinator) {
|
|
477
|
-
// Coordinator -> human
|
|
478
|
-
bubbleClass += "bg-purple-900/20 text-[#e5e5e5] border border-purple-800/40";
|
|
479
390
|
} else {
|
|
480
391
|
bubbleClass += "bg-[#1a1a1a] text-[#e5e5e5] border border-[#2a2a2a]";
|
|
481
392
|
}
|
|
@@ -488,14 +399,9 @@ export function GatewayChat({ gwRunning }) {
|
|
|
488
399
|
>
|
|
489
400
|
<div class=${bubbleClass}>
|
|
490
401
|
<div class="flex items-center gap-1 mb-1">
|
|
491
|
-
<span class
|
|
402
|
+
<span class="text-xs text-[#999]">
|
|
492
403
|
${isFromUser ? "You" : msg.from || "unknown"}
|
|
493
404
|
</span>
|
|
494
|
-
${
|
|
495
|
-
isCoordinator && !isFromUser
|
|
496
|
-
? html`<span class="text-xs px-1 py-0.5 rounded bg-purple-900/40 text-purple-400 font-mono">coordinator</span>`
|
|
497
|
-
: null
|
|
498
|
-
}
|
|
499
405
|
<span class="text-xs text-[#555]">
|
|
500
406
|
${isSending ? "\u00b7 sending\u2026" : `\u00b7 ${timeAgo(msg.createdAt)}`}
|
|
501
407
|
</span>
|
|
@@ -517,7 +423,7 @@ export function GatewayChat({ gwRunning }) {
|
|
|
517
423
|
})
|
|
518
424
|
}
|
|
519
425
|
${
|
|
520
|
-
|
|
426
|
+
terminalActivity === "active"
|
|
521
427
|
? html`
|
|
522
428
|
<div class="flex justify-start">
|
|
523
429
|
<div class="max-w-[85%] rounded px-3 py-2 text-sm bg-[#1a1a1a] text-[#e5e5e5] border border-[#2a2a2a]">
|
|
@@ -536,7 +442,7 @@ export function GatewayChat({ gwRunning }) {
|
|
|
536
442
|
</div>
|
|
537
443
|
|
|
538
444
|
<!-- Terminal panel (collapsible, collapsed by default) -->
|
|
539
|
-
<${TerminalPanel} chatTarget="gateway"
|
|
445
|
+
<${TerminalPanel} chatTarget="gateway" activity=${terminalActivity} agentState=${gwState} />
|
|
540
446
|
|
|
541
447
|
<!-- Input area (always visible) -->
|
|
542
448
|
<div class="border-t border-[#2a2a2a] p-3 shrink-0">
|
|
@@ -593,10 +499,10 @@ export function GatewayChat({ gwRunning }) {
|
|
|
593
499
|
`
|
|
594
500
|
: null
|
|
595
501
|
}
|
|
596
|
-
<div class="flex gap-2">
|
|
597
|
-
<
|
|
502
|
+
<div class="flex gap-2 items-end">
|
|
503
|
+
<textarea
|
|
598
504
|
ref=${inputRef}
|
|
599
|
-
|
|
505
|
+
rows="1"
|
|
600
506
|
placeholder=${
|
|
601
507
|
gwRunning ? "Send command to gateway\u2026" : "Start gateway to chat\u2026"
|
|
602
508
|
}
|
|
@@ -604,8 +510,8 @@ export function GatewayChat({ gwRunning }) {
|
|
|
604
510
|
onInput=${handleInput}
|
|
605
511
|
onKeyDown=${handleKeyDown}
|
|
606
512
|
disabled=${sending || !gwRunning}
|
|
607
|
-
class=${`${inputClass} flex-1 min-w-0`}
|
|
608
|
-
|
|
513
|
+
class=${`${inputClass} flex-1 min-w-0 resize-none overflow-hidden`}
|
|
514
|
+
></textarea>
|
|
609
515
|
<button
|
|
610
516
|
onClick=${handleSend}
|
|
611
517
|
disabled=${sending || !input.trim() || !gwRunning}
|
|
@@ -53,7 +53,19 @@ function formatTimestamp(iso) {
|
|
|
53
53
|
// Strip ANSI escape sequences from terminal output before display
|
|
54
54
|
function stripAnsi(str) {
|
|
55
55
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences requires matching control chars
|
|
56
|
-
|
|
56
|
+
const oscRe = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
57
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences requires matching control chars
|
|
58
|
+
const csiRe = /\x1b\[[?0-9;]*[a-zA-Z]/g;
|
|
59
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences requires matching control chars
|
|
60
|
+
const charsetRe = /\x1b[()][A-Z0-9]/g;
|
|
61
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences requires matching control chars
|
|
62
|
+
const keypadRe = /\x1b[=>]/g;
|
|
63
|
+
return str
|
|
64
|
+
.replace(oscRe, "")
|
|
65
|
+
.replace(csiRe, "")
|
|
66
|
+
.replace(charsetRe, "")
|
|
67
|
+
.replace(keypadRe, "")
|
|
68
|
+
.replace(/\r/g, "");
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
// ── State badge config ─────────────────────────────────────────────────────
|
|
@@ -241,7 +253,7 @@ export function InspectView({ agentName }) {
|
|
|
241
253
|
return html`
|
|
242
254
|
<div class="p-4 text-[#e5e5e5]">
|
|
243
255
|
<!-- Header -->
|
|
244
|
-
<div class="flex items-center gap-3 mb-1">
|
|
256
|
+
<div class="flex flex-wrap items-center gap-3 mb-1">
|
|
245
257
|
<h2 class="text-xl font-semibold">${agent.agentName || agentName}</h2>
|
|
246
258
|
<span class=${`text-sm px-2 py-0.5 rounded-sm ${badgeClass}`}>
|
|
247
259
|
${agent.state || ""}
|
|
@@ -251,14 +263,14 @@ export function InspectView({ agentName }) {
|
|
|
251
263
|
</div>
|
|
252
264
|
|
|
253
265
|
<!-- Subheader -->
|
|
254
|
-
<div class="text-[#999] text-sm mb-4">
|
|
266
|
+
<div class="text-[#999] text-sm mb-4 break-words">
|
|
255
267
|
Branch: ${agent.branchName || "\u2014"} |
|
|
256
268
|
Parent: ${agent.parentAgent || "orchestrator"} |
|
|
257
269
|
Duration: ${formatDuration(dur)}
|
|
258
270
|
</div>
|
|
259
271
|
|
|
260
272
|
<!-- Token stat cards -->
|
|
261
|
-
<div class="grid grid-cols-3 gap-2 mb-6">
|
|
273
|
+
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 mb-6">
|
|
262
274
|
${statCards.map(
|
|
263
275
|
({ label, value }) => html`
|
|
264
276
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-3">
|
|
@@ -74,7 +74,7 @@ function DispatchableCard({ issue }) {
|
|
|
74
74
|
const [closing, setClosing] = useState(false);
|
|
75
75
|
const [closeError, setCloseError] = useState(null);
|
|
76
76
|
|
|
77
|
-
const isDispatchable = issue.status === "open"
|
|
77
|
+
const isDispatchable = issue.status === "open";
|
|
78
78
|
|
|
79
79
|
const handleDispatch = useCallback(
|
|
80
80
|
async (e) => {
|
|
@@ -144,7 +144,7 @@ function DispatchableCard({ issue }) {
|
|
|
144
144
|
class=${
|
|
145
145
|
closing
|
|
146
146
|
? "px-2 py-1 text-xs rounded-sm border border-[#444] text-[#999] cursor-wait"
|
|
147
|
-
: "px-2 py-1 text-xs rounded-sm border border-
|
|
147
|
+
: "px-2 py-1 text-xs rounded-sm border border-[#666] text-[#999] hover:bg-[#666]/10"
|
|
148
148
|
}
|
|
149
149
|
>
|
|
150
150
|
${closing ? "Closing…" : closeConfirm ? "Confirm?" : "Close"}
|
|
@@ -178,7 +178,7 @@ function SkeletonCard() {
|
|
|
178
178
|
|
|
179
179
|
function SkeletonColumn({ title, borderClass, count }) {
|
|
180
180
|
return html`
|
|
181
|
-
<div class="flex-1 min-w-[240px] flex flex-col">
|
|
181
|
+
<div class="flex-1 min-w-full md:min-w-[240px] flex flex-col">
|
|
182
182
|
<div class=${`border-t-2 ${borderClass} bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm px-3 py-2 mb-2 flex items-center gap-2`}>
|
|
183
183
|
<span class="text-[#e5e5e5] text-sm font-medium">${title}</span>
|
|
184
184
|
<span class="bg-[#2a2a2a] text-[#999] text-xs rounded-full px-2">—</span>
|
|
@@ -194,7 +194,7 @@ function SkeletonColumn({ title, borderClass, count }) {
|
|
|
194
194
|
|
|
195
195
|
function Column({ title, issues, borderClass }) {
|
|
196
196
|
return html`
|
|
197
|
-
<div class="flex-1 min-w-[240px] flex flex-col">
|
|
197
|
+
<div class="flex-1 min-w-full md:min-w-[240px] flex flex-col">
|
|
198
198
|
<div class=${`border-t-2 ${borderClass} bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm px-3 py-2 mb-2 flex items-center gap-2`}>
|
|
199
199
|
<span class="text-[#e5e5e5] text-sm font-medium">${title}</span>
|
|
200
200
|
<span class="bg-[#2a2a2a] text-[#999] text-xs rounded-full px-2">${issues.length}</span>
|
|
@@ -273,7 +273,7 @@ export function IssuesView() {
|
|
|
273
273
|
100% { opacity: 0.3; }
|
|
274
274
|
}
|
|
275
275
|
</style>
|
|
276
|
-
<div class="flex gap-4 overflow-x-auto pb-4">
|
|
276
|
+
<div class="flex flex-col md:flex-row gap-4 overflow-x-auto pb-4">
|
|
277
277
|
<${SkeletonColumn} title="Open" borderClass="border-blue-500" count=${3} />
|
|
278
278
|
<${SkeletonColumn} title="In Progress" borderClass="border-yellow-500" count=${2} />
|
|
279
279
|
<${SkeletonColumn} title="Blocked" borderClass="border-red-500" count=${1} />
|
|
@@ -290,7 +290,9 @@ export function IssuesView() {
|
|
|
290
290
|
|
|
291
291
|
const visibleIssues = showClosed ? filtered : filtered.filter((i) => i.status !== "closed");
|
|
292
292
|
const { open, inProgress, blocked, closed } = categorize(visibleIssues);
|
|
293
|
-
closed.sort(
|
|
293
|
+
closed.sort(
|
|
294
|
+
(a, b) => new Date(b.closed_at || b.updated_at) - new Date(a.closed_at || a.updated_at),
|
|
295
|
+
);
|
|
294
296
|
|
|
295
297
|
const filterButtons = [null, 0, 1, 2, 3, 4];
|
|
296
298
|
|
|
@@ -307,7 +309,7 @@ export function IssuesView() {
|
|
|
307
309
|
/>
|
|
308
310
|
</div>
|
|
309
311
|
<!-- Priority filter bar -->
|
|
310
|
-
<div class="flex items-center gap-2 mb-4">
|
|
312
|
+
<div class="flex flex-wrap items-center gap-2 mb-4">
|
|
311
313
|
${filterButtons.map((p) => {
|
|
312
314
|
const active = priorityFilter === p;
|
|
313
315
|
const label = p == null ? "All" : `P${p}`;
|
|
@@ -340,7 +342,7 @@ export function IssuesView() {
|
|
|
340
342
|
</div>
|
|
341
343
|
|
|
342
344
|
<!-- Kanban board -->
|
|
343
|
-
<div class="flex gap-4 overflow-x-auto pb-4">
|
|
345
|
+
<div class="flex flex-col md:flex-row gap-4 overflow-x-auto pb-4">
|
|
344
346
|
<${Column} title="Open" issues=${open} borderClass="border-blue-500" />
|
|
345
347
|
<${Column} title="In Progress" issues=${inProgress} borderClass="border-yellow-500" />
|
|
346
348
|
<${Column} title="Blocked" issues=${blocked} borderClass="border-red-500" />
|
|
@@ -393,7 +395,7 @@ function renderColumnHtml(title, issues, borderClass) {
|
|
|
393
395
|
? `<div class="text-[#999] text-sm text-center py-4">No issues</div>`
|
|
394
396
|
: issues.map(renderIssueCardHtml).join("");
|
|
395
397
|
return `
|
|
396
|
-
<div class="flex-1 min-w-[240px]">
|
|
398
|
+
<div class="flex-1 min-w-full md:min-w-[240px]">
|
|
397
399
|
<div class="border-t-2 ${borderClass} bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm px-3 py-2 mb-2 flex items-center gap-2">
|
|
398
400
|
<span class="text-[#e5e5e5] text-sm font-medium">${escapeHtml(title)}</span>
|
|
399
401
|
<span class="bg-[#2a2a2a] text-[#999] text-xs rounded-full px-2">${issues.length}</span>
|
|
@@ -412,7 +414,9 @@ window.renderIssues = (appState, el) => {
|
|
|
412
414
|
|
|
413
415
|
const visibleIssues = showClosed ? filtered : filtered.filter((i) => i.status !== "closed");
|
|
414
416
|
const { open, inProgress, blocked, closed } = categorize(visibleIssues);
|
|
415
|
-
closed.sort(
|
|
417
|
+
closed.sort(
|
|
418
|
+
(a, b) => new Date(b.closed_at || b.updated_at) - new Date(a.closed_at || a.updated_at),
|
|
419
|
+
);
|
|
416
420
|
|
|
417
421
|
const filterButtons = [
|
|
418
422
|
{ key: "all", label: "All" },
|
|
@@ -448,8 +452,8 @@ window.renderIssues = (appState, el) => {
|
|
|
448
452
|
|
|
449
453
|
el.innerHTML = `
|
|
450
454
|
<div class="p-4">
|
|
451
|
-
<div class="flex items-center gap-2 mb-4">${filterBtnsHtml}${closedToggleHtml}</div>
|
|
452
|
-
<div class="flex gap-4 overflow-x-auto pb-4">${columnsHtml}</div>
|
|
455
|
+
<div class="flex flex-wrap items-center gap-2 mb-4">${filterBtnsHtml}${closedToggleHtml}</div>
|
|
456
|
+
<div class="flex flex-col md:flex-row gap-4 overflow-x-auto pb-4">${columnsHtml}</div>
|
|
453
457
|
</div>`;
|
|
454
458
|
|
|
455
459
|
// Wire up filter button click handlers
|
|
@@ -34,47 +34,51 @@ vi.stubGlobal("Bun", {
|
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
// Mock the
|
|
37
|
+
// Mock the tracker factory so tests can run without `bd`/`sd` on PATH.
|
|
38
38
|
// list/ready return [] (keeps existing /api/issues tests passing).
|
|
39
39
|
// show throws (existing /api/issues/:id test expects 404 on error).
|
|
40
40
|
// create returns a predictable issue ID for strategy approve tests.
|
|
41
41
|
// list returns a closed and a blocked issue fixture when all=true to verify all-statuses behavior.
|
|
42
|
-
vi.mock("../
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
42
|
+
vi.mock("../tracker/factory.ts", async (importOriginal) => {
|
|
43
|
+
const original = await importOriginal<typeof import("../tracker/factory.ts")>();
|
|
44
|
+
return {
|
|
45
|
+
...original,
|
|
46
|
+
createTrackerClient: () => ({
|
|
47
|
+
ready: async () => [],
|
|
48
|
+
list: async (options?: { status?: string; limit?: number; all?: boolean }) => {
|
|
49
|
+
// Return closed and blocked issue fixtures when all is true
|
|
50
|
+
if (options?.all) {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
id: "bead-closed-001",
|
|
54
|
+
title: "Closed issue",
|
|
55
|
+
status: "closed",
|
|
56
|
+
priority: 3,
|
|
57
|
+
type: "task",
|
|
58
|
+
closedAt: "2026-01-01T00:00:00.000Z",
|
|
59
|
+
closeReason: "Done",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "bead-blocked-001",
|
|
63
|
+
title: "Blocked issue",
|
|
64
|
+
status: "blocked",
|
|
65
|
+
priority: 2,
|
|
66
|
+
type: "task",
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
return [];
|
|
71
|
+
},
|
|
72
|
+
show: async (id: string) => {
|
|
73
|
+
throw new Error(`tracker not available: ${id}`);
|
|
74
|
+
},
|
|
75
|
+
create: async () => "bead-test-001",
|
|
76
|
+
claim: async () => {},
|
|
77
|
+
close: async () => {},
|
|
78
|
+
sync: async () => {},
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
78
82
|
|
|
79
83
|
// Mock tmux so tests don't interfere with real developer tmux sessions.
|
|
80
84
|
// isSessionAlive defaults to true so that tests which seed an active session
|
|
@@ -406,11 +410,23 @@ describe("GET /api/agents", () => {
|
|
|
406
410
|
expect(body).toEqual([]);
|
|
407
411
|
});
|
|
408
412
|
|
|
409
|
-
it("returns
|
|
413
|
+
it("returns only active agents when no current run", async () => {
|
|
414
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
415
|
+
const res = await dispatch("/api/agents");
|
|
416
|
+
expect(res.status).toBe(200);
|
|
417
|
+
const body = (await json(res)) as Array<{ state: string }>;
|
|
418
|
+
// No current-run.txt → falls back to active agents only
|
|
419
|
+
expect(body.length).toBe(1);
|
|
420
|
+
expect(body[0]?.state).toBe("working");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns all agents in current run when current-run.txt exists", async () => {
|
|
410
424
|
seedSessionDb(join(legioDir, "sessions.db"));
|
|
425
|
+
await writeFile(join(legioDir, "current-run.txt"), "run-001\n");
|
|
411
426
|
const res = await dispatch("/api/agents");
|
|
412
427
|
expect(res.status).toBe(200);
|
|
413
428
|
const body = (await json(res)) as unknown[];
|
|
429
|
+
// current-run.txt has run-001, both sessions belong to run-001
|
|
414
430
|
expect(body.length).toBe(2);
|
|
415
431
|
});
|
|
416
432
|
});
|
|
@@ -1977,7 +1993,7 @@ describe("POST /api/coordinator/start", () => {
|
|
|
1977
1993
|
expect(body).toBeDefined();
|
|
1978
1994
|
});
|
|
1979
1995
|
|
|
1980
|
-
it("passes --
|
|
1996
|
+
it("passes --watchman flag when body.watchdog is true", async () => {
|
|
1981
1997
|
const res = await dispatchPost("/api/coordinator/start", { watchdog: true });
|
|
1982
1998
|
expect([200, 500]).toContain(res.status);
|
|
1983
1999
|
const body = (await json(res)) as Record<string, unknown>;
|