@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +47 -3
  2. package/README.md +15 -8
  3. package/agents/builder.md +11 -10
  4. package/agents/coordinator.md +36 -27
  5. package/agents/cto.md +9 -8
  6. package/agents/gateway.md +28 -12
  7. package/agents/lead.md +45 -30
  8. package/agents/merger.md +4 -4
  9. package/agents/monitor.md +10 -9
  10. package/agents/reviewer.md +8 -8
  11. package/agents/scout.md +10 -10
  12. package/agents/supervisor.md +60 -45
  13. package/bin/legio.mjs +13 -2
  14. package/package.json +3 -3
  15. package/src/agents/hooks-deployer.test.ts +46 -41
  16. package/src/agents/hooks-deployer.ts +10 -9
  17. package/src/agents/manifest.test.ts +6 -2
  18. package/src/agents/overlay.test.ts +9 -7
  19. package/src/agents/overlay.ts +29 -7
  20. package/src/commands/agents.test.ts +1 -5
  21. package/src/commands/clean.test.ts +2 -5
  22. package/src/commands/clean.ts +25 -1
  23. package/src/commands/completions.test.ts +1 -1
  24. package/src/commands/completions.ts +26 -7
  25. package/src/commands/coordinator.test.ts +78 -78
  26. package/src/commands/coordinator.ts +92 -47
  27. package/src/commands/costs.test.ts +2 -6
  28. package/src/commands/dashboard.test.ts +2 -5
  29. package/src/commands/doctor.test.ts +2 -6
  30. package/src/commands/down.ts +3 -3
  31. package/src/commands/errors.test.ts +2 -6
  32. package/src/commands/feed.test.ts +2 -6
  33. package/src/commands/gateway.test.ts +39 -13
  34. package/src/commands/gateway.ts +95 -7
  35. package/src/commands/hooks.test.ts +2 -5
  36. package/src/commands/init.test.ts +4 -13
  37. package/src/commands/inspect.test.ts +2 -6
  38. package/src/commands/log.test.ts +2 -6
  39. package/src/commands/logs.test.ts +2 -9
  40. package/src/commands/mail.test.ts +76 -215
  41. package/src/commands/mail.ts +43 -187
  42. package/src/commands/metrics.test.ts +3 -10
  43. package/src/commands/nudge.ts +15 -0
  44. package/src/commands/prime.test.ts +4 -11
  45. package/src/commands/replay.test.ts +2 -6
  46. package/src/commands/server.test.ts +1 -5
  47. package/src/commands/server.ts +1 -1
  48. package/src/commands/sling.ts +40 -16
  49. package/src/commands/spec.test.ts +2 -5
  50. package/src/commands/status.test.ts +2 -4
  51. package/src/commands/stop.test.ts +2 -5
  52. package/src/commands/supervisor.ts +6 -6
  53. package/src/commands/trace.test.ts +2 -6
  54. package/src/commands/up.test.ts +43 -9
  55. package/src/commands/up.ts +15 -11
  56. package/src/commands/watchman.ts +327 -0
  57. package/src/commands/worktree.test.ts +2 -6
  58. package/src/config.test.ts +34 -104
  59. package/src/config.ts +120 -32
  60. package/src/doctor/agents.test.ts +7 -2
  61. package/src/doctor/config-check.test.ts +7 -2
  62. package/src/doctor/consistency.test.ts +7 -2
  63. package/src/doctor/databases.test.ts +6 -2
  64. package/src/doctor/dependencies.test.ts +35 -10
  65. package/src/doctor/dependencies.ts +16 -92
  66. package/src/doctor/logs.test.ts +7 -2
  67. package/src/doctor/merge-queue.test.ts +6 -2
  68. package/src/doctor/structure.test.ts +7 -2
  69. package/src/doctor/version.test.ts +7 -2
  70. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  71. package/src/index.ts +7 -7
  72. package/src/mail/pending.ts +120 -0
  73. package/src/mail/store.test.ts +89 -0
  74. package/src/mail/store.ts +11 -0
  75. package/src/merge/resolver.test.ts +518 -489
  76. package/src/server/index.ts +33 -2
  77. package/src/server/public/app.js +3 -3
  78. package/src/server/public/components/message-bubble.js +11 -1
  79. package/src/server/public/components/terminal-panel.js +66 -74
  80. package/src/server/public/views/chat.js +18 -2
  81. package/src/server/public/views/costs.js +5 -5
  82. package/src/server/public/views/dashboard.js +80 -51
  83. package/src/server/public/views/gateway-chat.js +37 -131
  84. package/src/server/public/views/inspect.js +16 -4
  85. package/src/server/public/views/issues.js +16 -12
  86. package/src/server/routes.test.ts +55 -39
  87. package/src/server/routes.ts +38 -26
  88. package/src/test-helpers.ts +6 -3
  89. package/src/tracker/beads.ts +159 -0
  90. package/src/tracker/exec.ts +44 -0
  91. package/src/tracker/factory.test.ts +283 -0
  92. package/src/tracker/factory.ts +59 -0
  93. package/src/tracker/seeds.ts +156 -0
  94. package/src/tracker/types.ts +46 -0
  95. package/src/types.ts +11 -2
  96. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  97. package/src/watchman/daemon.ts +940 -0
  98. package/src/worktree/tmux.test.ts +2 -1
  99. package/src/worktree/tmux.ts +4 -4
  100. package/templates/hooks.json.tmpl +17 -17
  101. package/src/beads/client.test.ts +0 -210
  102. package/src/commands/merge.test.ts +0 -676
  103. package/src/commands/watch.test.ts +0 -152
  104. package/src/commands/watch.ts +0 -238
  105. package/src/test-helpers.test.ts +0 -97
  106. package/src/watchdog/daemon.ts +0 -533
  107. package/src/watchdog/health.test.ts +0 -371
  108. package/src/watchdog/triage.test.ts +0 -162
  109. package/src/worktree/manager.test.ts +0 -444
  110. /package/src/{watchdog → watchman}/health.ts +0 -0
  111. /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 [coordinatorMessages, setCoordinatorMessages] = useState([]);
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 thinking so we catch replies quickly
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, thinking ? 2000 : 5000);
69
+ const interval = setInterval(fetchHistory, terminalActivity === "active" ? 2000 : 5000);
74
70
  return () => {
75
71
  cancelled = true;
76
72
  clearInterval(interval);
77
73
  };
78
- }, [thinking]);
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
- const coordMessages = showCoordinator
121
- ? mailMessages.filter(
122
- (m) =>
123
- (m.from === "coordinator" && m.to === "human") ||
124
- (m.from === "human" && m.to === "coordinator"),
125
- )
126
- : [];
127
- if (gatewayMessages.length === 0 && coordMessages.length === 0) return;
128
- if (gatewayMessages.length > 0) {
129
- setHistoryMessages((prev) => {
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, thinking ? 2000 : 10000);
108
+ const interval = setInterval(syncTranscript, terminalActivity === "active" ? 2000 : 10000);
163
109
  return () => {
164
110
  cancelled = true;
165
111
  clearInterval(interval);
166
112
  };
167
- }, [thinking]);
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 to clear thinking
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
- // Decrement thinking counter; clear indicator only when all sends have been replied to
184
- thinkingCountRef.current = Math.max(0, thinkingCountRef.current - 1);
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
- // Auto-clear thinking after 60 seconds to prevent it from getting stuck forever
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 = showCoordinator
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, thinking]);
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
- thinkingCountRef.current += 1;
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 && isCoordinator) {
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=${`text-xs ${isCoordinator ? "text-purple-400" : "text-[#999]"}`}>
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
- thinking
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" thinking=${thinking} />
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
- <input
502
+ <div class="flex gap-2 items-end">
503
+ <textarea
598
504
  ref=${inputRef}
599
- type="text"
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
- return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
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" || issue.status === "in_progress";
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-red-700 text-red-400 hover:bg-red-900/20"
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((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
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((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
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 beads client so strategy tests can run without `bd` on PATH.
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("../beads/client.ts", () => ({
43
- createBeadsClient: () => ({
44
- ready: async () => [],
45
- list: async (options?: { status?: string; limit?: number; all?: boolean }) => {
46
- // Return closed and blocked issue fixtures when all is true
47
- if (options?.all) {
48
- return [
49
- {
50
- id: "bead-closed-001",
51
- title: "Closed issue",
52
- status: "closed",
53
- priority: 3,
54
- type: "task",
55
- closedAt: "2026-01-01T00:00:00.000Z",
56
- closeReason: "Done",
57
- },
58
- {
59
- id: "bead-blocked-001",
60
- title: "Blocked issue",
61
- status: "blocked",
62
- priority: 2,
63
- type: "task",
64
- dependency_count: 1,
65
- },
66
- ];
67
- }
68
- return [];
69
- },
70
- show: async (id: string) => {
71
- throw new Error(`bd not available: ${id}`);
72
- },
73
- create: async () => "bead-test-001",
74
- claim: async () => {},
75
- close: async () => {},
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 seeded agent sessions", async () => {
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 --watchdog flag when body.watchdog is true", async () => {
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>;