bosun 0.31.2 → 0.31.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.
@@ -132,6 +132,7 @@ const GIT_CONFLICT_PATTERNS = [
132
132
  /rebase.*conflict/i,
133
133
  /cannot.*merge|unable to merge/i,
134
134
  /both modified/i,
135
+ /cannot rebase|rebase failed/i,
135
136
  ];
136
137
 
137
138
  export const PUSH_FAILURE_PATTERNS = [
@@ -173,6 +174,20 @@ export const LINT_FAILURE_PATTERNS = [
173
174
  /gofmt.*differ|goimports.*differ/i,
174
175
  ];
175
176
 
177
+ // ── OOM / SIGKILL patterns ──────────────────────────────────────────────────────
178
+
179
+ export const OOM_KILL_PATTERNS = [
180
+ /SIGKILL/,
181
+ /killed.*out.?of.?memory|oom.?kill/i,
182
+ /out of memory: kill process/i,
183
+ ];
184
+
185
+ export const OOM_PATTERNS = [
186
+ /heap out of memory|javascript heap/i,
187
+ /fatal error.*allocation failed|allocation failure/i,
188
+ /process out of memory/i,
189
+ ];
190
+
176
191
  /**
177
192
  * Ordered list of pattern groups to check. Earlier entries win on ties.
178
193
  * Each entry: [patternName, regexArray, baseConfidence]
@@ -187,6 +202,8 @@ const PATTERN_GROUPS = [
187
202
  ["request_error", REQUEST_ERROR_PATTERNS, 0.91], // Client errors (400/404/422) — needs prompt fix
188
203
  ["api_error", API_ERROR_PATTERNS, 0.9],
189
204
  ["session_expired", SESSION_EXPIRED_PATTERNS, 0.9],
205
+ ["oom_kill", OOM_KILL_PATTERNS, 0.97], // SIGKILL / OS-level OOM kill — critical
206
+ ["oom", OOM_PATTERNS, 0.95], // JavaScript heap OOM — critical
190
207
  ["codex_sandbox", CODEX_SANDBOX_PATTERNS, 0.88], // Codex sandbox failures
191
208
  ["push_failure", PUSH_FAILURE_PATTERNS, 0.85],
192
209
  ["test_failure", TEST_FAILURE_PATTERNS, 0.83],
@@ -195,6 +212,34 @@ const PATTERN_GROUPS = [
195
212
  ["git_conflict", GIT_CONFLICT_PATTERNS, 0.85],
196
213
  ];
197
214
 
215
+ /**
216
+ * Severity level for each error pattern type.
217
+ * 'low' | 'medium' | 'high' | 'critical'
218
+ * @type {Record<string, 'low'|'medium'|'high'|'critical'>}
219
+ */
220
+ export const PATTERN_SEVERITY = {
221
+ auth_error: "high",
222
+ content_policy: "high",
223
+ plan_stuck: "low",
224
+ rate_limit: "medium",
225
+ token_overflow: "medium",
226
+ model_error: "high",
227
+ request_error: "medium",
228
+ api_error: "medium",
229
+ session_expired: "medium",
230
+ oom_kill: "critical",
231
+ oom: "critical",
232
+ codex_sandbox: "high",
233
+ push_failure: "medium",
234
+ test_failure: "medium",
235
+ lint_failure: "low",
236
+ build_failure: "medium",
237
+ git_conflict: "medium",
238
+ permission_wait: "low",
239
+ empty_response: "low",
240
+ unknown: "low",
241
+ };
242
+
198
243
  // ── Helpers ─────────────────────────────────────────────────────────────────
199
244
 
200
245
  /** Safely truncate a string for logging / details. */
@@ -224,12 +269,14 @@ const PATTERN_DESCRIPTIONS = {
224
269
  test_failure: "Unit or integration test failure",
225
270
  lint_failure: "Lint or code formatting failure",
226
271
  push_failure: "Git push or pre-push hook failure",
227
- git_conflict: "Git merge conflict detected",
272
+ git_conflict: "Git merge or rebase conflict detected",
228
273
  auth_error: "API key invalid, expired, or missing — NOT retryable",
229
274
  model_error: "Model not found, deprecated, or unavailable",
230
275
  request_error: "Bad request (400/404/422) — invalid payload or endpoint",
231
276
  content_policy: "Content policy / safety filter violation — NOT retryable",
232
277
  codex_sandbox: "Codex CLI sandbox or permission error",
278
+ oom_kill: "Process killed by OS due to out-of-memory (SIGKILL)",
279
+ oom: "JavaScript heap out-of-memory error",
233
280
  permission_wait: "Agent waiting for human input/permission",
234
281
  empty_response: "Agent produced no meaningful output",
235
282
  unknown: "Unclassified error",
@@ -271,7 +318,7 @@ export class ErrorDetector {
271
318
  *
272
319
  * @param {string} output Agent stdout / response text
273
320
  * @param {string} [error] Agent stderr or error message
274
- * @returns {{ pattern: string, confidence: number, details: string, rawMatch: string|null }}
321
+ * @returns {{ pattern: string, confidence: number, details: string, rawMatch: string|null, severity: 'low'|'medium'|'high'|'critical' }}
275
322
  */
276
323
  classify(output, error) {
277
324
  const combined = [output, error].filter(Boolean).join("\n");
@@ -281,6 +328,7 @@ export class ErrorDetector {
281
328
  confidence: 0,
282
329
  details: "No output to analyse",
283
330
  rawMatch: null,
331
+ severity: PATTERN_SEVERITY.unknown ?? "low",
284
332
  };
285
333
  }
286
334
 
@@ -307,14 +355,16 @@ export class ErrorDetector {
307
355
  }
308
356
  }
309
357
 
310
- return (
311
- best || {
312
- pattern: "unknown",
313
- confidence: 0.3,
314
- details: PATTERN_DESCRIPTIONS.unknown,
315
- rawMatch: null,
316
- }
317
- );
358
+ const result = best || {
359
+ pattern: "unknown",
360
+ confidence: 0.3,
361
+ details: PATTERN_DESCRIPTIONS.unknown,
362
+ rawMatch: null,
363
+ };
364
+ return {
365
+ ...result,
366
+ severity: PATTERN_SEVERITY[result.pattern] ?? "low",
367
+ };
318
368
  }
319
369
 
320
370
  // ── recordError ─────────────────────────────────────────────────────────
package/monitor.mjs CHANGED
@@ -9372,6 +9372,7 @@ function formatCodexResult(result) {
9372
9372
  }
9373
9373
  if (typeof result === "object") {
9374
9374
  const candidates = [
9375
+ result.finalResponse,
9375
9376
  result.output,
9376
9377
  result.text,
9377
9378
  result.message,
@@ -12748,6 +12749,8 @@ export {
12748
12749
  appendKnowledgeEntry,
12749
12750
  buildKnowledgeEntry,
12750
12751
  formatKnowledgeSummary,
12752
+ extractPlannerTasksFromOutput,
12753
+ formatCodexResult,
12751
12754
  // Container runner re-exports
12752
12755
  getContainerStatus,
12753
12756
  isContainerEnabled,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.31.2",
3
+ "version": "0.31.4",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
package/task-executor.mjs CHANGED
@@ -68,7 +68,7 @@ import {
68
68
  collectDiffStats,
69
69
  } from "./diff-stats.mjs";
70
70
  import { createAnomalyDetector } from "./anomaly-detector.mjs";
71
- import { normalizeDedupKey } from "./utils.mjs";
71
+ import { normalizeDedupKey, yieldToEventLoop } from "./utils.mjs";
72
72
  import {
73
73
  resolveExecutorForTask,
74
74
  executorToSdk,
@@ -114,6 +114,23 @@ const CODEX_TASK_LABELS = (() => {
114
114
  /** Watchdog interval: how often to check for stalled agent slots */
115
115
  const WATCHDOG_INTERVAL_MS = 60_000; // 1 minute
116
116
 
117
+ // ── Error categorization ───────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Categorize an error for smarter retry/backoff decisions.
121
+ * @param {unknown} err
122
+ * @returns {'transient'|'auth'|'network'|'conflict'|'permanent'|'unknown'}
123
+ */
124
+ function categorizeError(err) {
125
+ const msg = (err?.message || err?.stderr || String(err ?? "")).toLowerCase();
126
+ if (/rate.?limit|too many requests|429/i.test(msg)) return "transient";
127
+ if (/auth|token|unauthorized|forbidden|403|401/i.test(msg)) return "auth";
128
+ if (/network|econnrefused|enotfound|etimedout|econnreset|socket/i.test(msg)) return "network";
129
+ if (/conflict|merge conflict|rebase conflict|CONFLICT/i.test(msg)) return "conflict";
130
+ if (/fatal|crash|exit code 1|exit code 127|SIGKILL/i.test(msg)) return "permanent";
131
+ return "unknown";
132
+ }
133
+
117
134
  /**
118
135
  * Returns the Co-authored-by trailer for bosun-botswain[bot], or empty string
119
136
  * if the GitHub App ID is not configured. Used to attribute agent commits to
@@ -3241,6 +3258,8 @@ class TaskExecutor {
3241
3258
  `${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
3242
3259
  );
3243
3260
  });
3261
+ // Yield between slot dispatches so WebSocket/HTTP work can proceed
3262
+ await yieldToEventLoop();
3244
3263
  }
3245
3264
  } catch (err) {
3246
3265
  console.error(`${TAG} poll loop error: ${err.message}`);
@@ -4795,6 +4814,15 @@ class TaskExecutor {
4795
4814
  /* best-effort */
4796
4815
  }
4797
4816
 
4817
+ // Category-specific backoff to avoid hammering services under stress
4818
+ const errCategory = categorizeError(result.error || "");
4819
+ console.log(`${TAG} error category for "${task.title}": ${errCategory}`);
4820
+ if (errCategory === "transient") {
4821
+ await new Promise((r) => setTimeout(r, 5_000));
4822
+ } else if (errCategory === "network") {
4823
+ await new Promise((r) => setTimeout(r, 15_000));
4824
+ }
4825
+
4798
4826
  // If plan-stuck, use recovery prompt instead of generic retry
4799
4827
  if (
4800
4828
  classification.pattern === "plan_stuck" &&
package/ui/app.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  connectWebSocket,
44
44
  disconnectWebSocket,
45
45
  wsConnected,
46
+ loadingCount,
46
47
  } from "./modules/api.js";
47
48
  import {
48
49
  connected,
@@ -124,17 +125,48 @@ if (typeof document !== "undefined" && !document.getElementById("offline-banner-
124
125
  gap: 12px;
125
126
  padding: 12px 16px;
126
127
  margin: 8px 16px;
127
- background: rgba(239, 68, 68, 0.08);
128
128
  border: 1px solid rgba(239, 68, 68, 0.2);
129
129
  border-radius: 14px;
130
130
  box-shadow: var(--shadow-sm);
131
131
  backdrop-filter: blur(6px);
132
132
  animation: slideDown 0.3s ease-out;
133
+ transition: background 0.4s ease, border-color 0.4s ease;
134
+ }
135
+ .offline-banner.tone-orange {
136
+ background: rgba(249, 115, 22, 0.08);
137
+ border-color: rgba(249, 115, 22, 0.25);
138
+ }
139
+ .offline-banner.tone-red {
140
+ background: rgba(239, 68, 68, 0.12);
141
+ border-color: rgba(239, 68, 68, 0.3);
133
142
  }
134
143
  .offline-banner-icon { font-size: 20px; }
135
144
  .offline-banner-content { flex: 1; }
136
- .offline-banner-title { font-weight: 600; font-size: 13px; color: #ef4444; }
145
+ .offline-banner-title { font-weight: 600; font-size: 13px; }
146
+ .tone-orange .offline-banner-title { color: #f97316; }
147
+ .tone-red .offline-banner-title { color: #ef4444; }
137
148
  .offline-banner-meta { font-size: 12px; opacity: 0.7; margin-top: 2px; }
149
+ .offline-reconnect-bar {
150
+ height: 2px; border-radius: 2px; margin-top: 6px;
151
+ background: rgba(249,115,22,0.18);
152
+ overflow: hidden;
153
+ }
154
+ .offline-reconnect-fill {
155
+ height: 100%; border-radius: 2px;
156
+ background: #f97316;
157
+ transition: width 1s linear;
158
+ }
159
+ .tone-red .offline-reconnect-fill { background: #ef4444; }
160
+ .offline-dot {
161
+ width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
162
+ animation: offlinePulse 1.6s ease-in-out infinite;
163
+ }
164
+ .offline-dot.orange { background: #f97316; }
165
+ .offline-dot.red { background: #ef4444; }
166
+ @keyframes offlinePulse {
167
+ 0%,100% { opacity:1; transform:scale(1); }
168
+ 50% { opacity:0.5; transform:scale(1.3); }
169
+ }
138
170
  `;
139
171
  document.head.appendChild(style);
140
172
  }
@@ -203,16 +235,38 @@ function OfflineBanner() {
203
235
  }
204
236
  } catch { /* handled by signal */ }
205
237
  }, []);
238
+
239
+ const retryCount = backendRetryCount.value;
240
+ const isPersistent = retryCount > 3;
241
+ const tone = isPersistent ? "red" : "orange";
242
+ const title = isPersistent
243
+ ? "Persistent connection failure"
244
+ : "Backend Unreachable";
245
+
246
+ // Reconnect countdown drives the progress bar
247
+ const countdown = wsReconnectIn.value;
248
+ const maxWait = 15; // max backoff seconds
249
+ const reconnectPct = countdown != null && countdown > 0
250
+ ? Math.round(((maxWait - Math.min(countdown, maxWait)) / maxWait) * 100)
251
+ : 100;
252
+
206
253
  return html`
207
- <div class="offline-banner">
208
- <div class="offline-banner-icon">⚠️</div>
254
+ <div class="offline-banner tone-${tone}">
255
+ <div class="offline-dot ${tone}"></div>
209
256
  <div class="offline-banner-content">
210
- <div class="offline-banner-title">Backend Unreachable</div>
257
+ <div class="offline-banner-title">${title}</div>
211
258
  <div class="offline-banner-meta">${backendError.value || "Connection lost"}</div>
212
259
  ${backendLastSeen.value
213
260
  ? html`<div class="offline-banner-meta">Last connected: ${formatTimeAgo(backendLastSeen.value)}</div>`
214
261
  : null}
215
- <div class="offline-banner-meta">Retry attempt #${backendRetryCount.value}</div>
262
+ <div class="offline-banner-meta">
263
+ ${countdown != null && countdown > 0
264
+ ? `Reconnecting in ${countdown}s…`
265
+ : `Retry attempt #${retryCount}`}
266
+ </div>
267
+ <div class="offline-reconnect-bar">
268
+ <div class="offline-reconnect-fill" style="width:${reconnectPct}%"></div>
269
+ </div>
216
270
  </div>
217
271
  <button class="btn btn-ghost btn-sm" onClick=${manualRetry}>Retry</button>
218
272
  </div>
@@ -224,7 +278,7 @@ import { Component } from "preact";
224
278
  class TabErrorBoundary extends Component {
225
279
  constructor(props) {
226
280
  super(props);
227
- this.state = { error: null };
281
+ this.state = { error: null, showStack: false };
228
282
  }
229
283
  static getDerivedStateFromError(error) {
230
284
  return { error };
@@ -234,23 +288,43 @@ class TabErrorBoundary extends Component {
234
288
  }
235
289
  render() {
236
290
  if (this.state.error) {
237
- const retry = () => this.setState({ error: null });
291
+ const retry = () => this.setState({ error: null, showStack: false });
292
+ const err = this.state.error;
293
+ const tabName = this.props.tabName || "";
294
+ const errorMsg = err?.message || "An unexpected error occurred while rendering this tab.";
295
+ const stack = err?.stack || "";
296
+ const copyError = () => {
297
+ const text = `${errorMsg}\n\n${stack}`;
298
+ navigator?.clipboard?.writeText(text).catch(() => {});
299
+ };
300
+ const toggleStack = () => this.setState((s) => ({ showStack: !s.showStack }));
238
301
  return html`
239
- <div style="padding:24px;text-align:center;color:var(--text-secondary);">
240
- <div style="font-size:32px;margin-bottom:12px;">⚠️</div>
241
- <div style="font-size:14px;font-weight:600;margin-bottom:8px;">
242
- Something went wrong
302
+ <div class="tab-error-boundary">
303
+ <div class="tab-error-pulse">
304
+ <span style="font-size:20px;color:#ef4444;">⚠</span>
243
305
  </div>
244
- <div style="font-size:12px;opacity:0.7;margin-bottom:16px;max-width:400px;margin-left:auto;margin-right:auto;">
245
- ${this.state.error?.message || "An unexpected error occurred while rendering this tab."}
306
+ <div>
307
+ <div style="font-size:14px;font-weight:600;margin-bottom:4px;color:var(--text-primary);">
308
+ Something went wrong${tabName ? ` in ${tabName}` : ""}
309
+ </div>
310
+ <div style="font-size:12px;color:var(--text-secondary);max-width:400px;">
311
+ ${errorMsg}
312
+ </div>
246
313
  </div>
247
- <button class="btn btn-primary btn-sm" onClick=${retry}>
248
- Retry
249
- </button>
314
+ <div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;">
315
+ <button class="btn btn-primary btn-sm" onClick=${retry}>Retry</button>
316
+ <button class="btn btn-ghost btn-sm" onClick=${copyError}>Copy Error</button>
317
+ ${stack ? html`<button class="btn btn-ghost btn-sm" onClick=${toggleStack}>
318
+ ${this.state.showStack ? "Hide Stack" : "Stack Trace"}
319
+ </button>` : null}
320
+ </div>
321
+ ${this.state.showStack && stack ? html`
322
+ <div class="tab-error-stack">${stack}</div>
323
+ ` : null}
250
324
  </div>
251
325
  `;
252
326
  }
253
- return this.props.children;
327
+ return html`<div class="tab-content-enter">${this.props.children}</div>`;
254
328
  }
255
329
  }
256
330
 
@@ -712,6 +786,26 @@ function App() {
712
786
  const mainRef = useRef(null);
713
787
  const [showScrollTop, setShowScrollTop] = useState(false);
714
788
  const scrollVisibilityRef = useRef(false);
789
+
790
+ // ── Top loading bar state ──
791
+ const [loadingPct, setLoadingPct] = useState(0);
792
+ const [loadingVisible, setLoadingVisible] = useState(false);
793
+ const loadingTimerRef = useRef(null);
794
+ const isLoading = loadingCount.value > 0;
795
+ useEffect(() => {
796
+ if (isLoading) {
797
+ if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
798
+ setLoadingVisible(true);
799
+ setLoadingPct(70);
800
+ } else {
801
+ setLoadingPct(100);
802
+ loadingTimerRef.current = setTimeout(() => {
803
+ setLoadingVisible(false);
804
+ setLoadingPct(0);
805
+ }, 500);
806
+ }
807
+ return () => {};
808
+ }, [isLoading]);
715
809
  const [isMoreOpen, setIsMoreOpen] = useState(false);
716
810
  const resizeRef = useRef(null);
717
811
  const [isCompactNav, setIsCompactNav] = useState(() => {
@@ -1055,6 +1149,7 @@ function App() {
1055
1149
  }, []);
1056
1150
 
1057
1151
  return html`
1152
+ <div class="top-loading-bar" style="width: ${loadingPct}%; opacity: ${loadingVisible ? 1 : 0}"></div>
1058
1153
  <div
1059
1154
  class="app-shell"
1060
1155
  style=${shellStyle}
@@ -1130,7 +1225,7 @@ function App() {
1130
1225
  <${CommandPalette} open=${paletteOpen} onClose=${paletteClose} />
1131
1226
  <${PullToRefresh} onRefresh=${() => refreshTab(activeTab.value)}>
1132
1227
  <main class="main-content" ref=${mainRef}>
1133
- <${TabErrorBoundary} key=${activeTab.value}>
1228
+ <${TabErrorBoundary} key=${activeTab.value} tabName=${activeTab.value}>
1134
1229
  <${CurrentTab} />
1135
1230
  <//>
1136
1231
  </main>
package/ui/modules/api.js CHANGED
@@ -14,6 +14,8 @@ export const wsLatency = signal(null);
14
14
  export const wsReconnectIn = signal(null);
15
15
  /** Reactive signal: number of reconnections since last user-initiated action */
16
16
  export const wsReconnectCount = signal(0);
17
+ /** Reactive signal: count of in-flight apiFetch calls (drives top loading bar) */
18
+ export const loadingCount = signal(0);
17
19
 
18
20
  /* ─── REST API Client ─── */
19
21
 
@@ -37,6 +39,7 @@ export async function apiFetch(path, options = {}) {
37
39
  const silent = options._silent;
38
40
  delete options._silent;
39
41
 
42
+ loadingCount.value += 1;
40
43
  try {
41
44
  const res = await fetch(path, { ...options, headers });
42
45
  if (!res.ok) {
@@ -57,6 +60,8 @@ export async function apiFetch(path, options = {}) {
57
60
  }
58
61
  }
59
62
  throw err;
63
+ } finally {
64
+ loadingCount.value = Math.max(0, loadingCount.value - 1);
60
65
  }
61
66
  }
62
67
 
@@ -3876,3 +3876,206 @@ select.input {
3876
3876
  vertical-align: baseline;
3877
3877
  font-size: inherit;
3878
3878
  }
3879
+
3880
+ /* ═══════════════════════════════════════════════
3881
+ * Tab transition animations
3882
+ * ═══════════════════════════════════════════════ */
3883
+
3884
+ @keyframes tabFadeSlideIn {
3885
+ from { opacity: 0; transform: translateY(8px); }
3886
+ to { opacity: 1; transform: translateY(0); }
3887
+ }
3888
+
3889
+ .tab-content-enter {
3890
+ animation: tabFadeSlideIn 0.22s cubic-bezier(0.4, 0, 0.2, 1);
3891
+ }
3892
+
3893
+ /* ═══════════════════════════════════════════════
3894
+ * Top loading bar
3895
+ * ═══════════════════════════════════════════════ */
3896
+
3897
+ .top-loading-bar {
3898
+ position: fixed;
3899
+ top: 0;
3900
+ left: 0;
3901
+ height: 3px;
3902
+ z-index: 99999;
3903
+ background: linear-gradient(90deg, var(--accent), #8b5cf6);
3904
+ transition: width 0.3s ease, opacity 0.4s ease;
3905
+ pointer-events: none;
3906
+ border-radius: 0 2px 2px 0;
3907
+ }
3908
+
3909
+ /* ═══════════════════════════════════════════════
3910
+ * Dashboard headline variants
3911
+ * ═══════════════════════════════════════════════ */
3912
+
3913
+ .dashboard-headline-ok { color: var(--color-done); }
3914
+ .dashboard-headline-warn { color: var(--color-inreview); }
3915
+ .dashboard-headline-error { color: var(--color-error); }
3916
+ .dashboard-headline-idle { color: var(--text-secondary); }
3917
+
3918
+ /* ═══════════════════════════════════════════════
3919
+ * Fleet ticker
3920
+ * ═══════════════════════════════════════════════ */
3921
+
3922
+ .fleet-ticker-wrap {
3923
+ overflow: hidden;
3924
+ position: relative;
3925
+ height: 20px;
3926
+ }
3927
+
3928
+ @keyframes tickerScroll {
3929
+ 0% { transform: translateY(0); }
3930
+ 33% { transform: translateY(-20px); }
3931
+ 66% { transform: translateY(-40px); }
3932
+ 100% { transform: translateY(0); }
3933
+ }
3934
+
3935
+ .fleet-ticker-inner {
3936
+ display: flex;
3937
+ flex-direction: column;
3938
+ animation: tickerScroll 6s steps(1) infinite;
3939
+ }
3940
+
3941
+ .fleet-ticker-item {
3942
+ height: 20px;
3943
+ display: flex;
3944
+ align-items: center;
3945
+ gap: 6px;
3946
+ font-size: 12px;
3947
+ color: var(--text-secondary);
3948
+ white-space: nowrap;
3949
+ overflow: hidden;
3950
+ text-overflow: ellipsis;
3951
+ }
3952
+
3953
+ /* ═══════════════════════════════════════════════
3954
+ * Tab Error Boundary improvements
3955
+ * ═══════════════════════════════════════════════ */
3956
+
3957
+ .tab-error-boundary {
3958
+ padding: 32px 24px;
3959
+ display: flex;
3960
+ flex-direction: column;
3961
+ align-items: center;
3962
+ gap: 16px;
3963
+ text-align: center;
3964
+ }
3965
+
3966
+ .tab-error-pulse {
3967
+ width: 48px;
3968
+ height: 48px;
3969
+ border-radius: 50%;
3970
+ background: rgba(239, 68, 68, 0.15);
3971
+ display: flex;
3972
+ align-items: center;
3973
+ justify-content: center;
3974
+ animation: errorPulse 2s ease-in-out infinite;
3975
+ }
3976
+
3977
+ @keyframes errorPulse {
3978
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
3979
+ 50% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
3980
+ }
3981
+
3982
+ .tab-error-stack {
3983
+ max-height: 120px;
3984
+ overflow-y: auto;
3985
+ background: rgba(0, 0, 0, 0.3);
3986
+ border-radius: 8px;
3987
+ padding: 8px 12px;
3988
+ font-family: monospace;
3989
+ font-size: 11px;
3990
+ color: var(--text-secondary);
3991
+ text-align: left;
3992
+ width: 100%;
3993
+ box-sizing: border-box;
3994
+ }
3995
+
3996
+ /* ═══════════════════════════════════════════════
3997
+ * Offline banner — pulsing reconnect dot
3998
+ * ═══════════════════════════════════════════════ */
3999
+
4000
+ .offline-dot {
4001
+ width: 10px;
4002
+ height: 10px;
4003
+ border-radius: 50%;
4004
+ flex-shrink: 0;
4005
+ animation: offlinePulse 1.6s ease-in-out infinite;
4006
+ }
4007
+
4008
+ .offline-dot.orange { background: #f97316; }
4009
+ .offline-dot.red { background: #ef4444; }
4010
+
4011
+ @keyframes offlinePulse {
4012
+ 0%, 100% { opacity: 1; transform: scale(1); }
4013
+ 50% { opacity: 0.5; transform: scale(1.3); }
4014
+ }
4015
+
4016
+ /* ═══════════════════════════════════════════════
4017
+ * Dashboard — hero "Fleet at rest" badge
4018
+ * ═══════════════════════════════════════════════ */
4019
+
4020
+ .fleet-rest-badge {
4021
+ display: flex;
4022
+ flex-direction: column;
4023
+ align-items: center;
4024
+ gap: 8px;
4025
+ padding: 20px 0 8px;
4026
+ text-align: center;
4027
+ }
4028
+
4029
+ .fleet-rest-icon {
4030
+ width: 44px;
4031
+ height: 44px;
4032
+ border-radius: 50%;
4033
+ background: rgba(34, 197, 94, 0.15);
4034
+ display: flex;
4035
+ align-items: center;
4036
+ justify-content: center;
4037
+ font-size: 22px;
4038
+ color: var(--color-done);
4039
+ }
4040
+
4041
+ .fleet-rest-label {
4042
+ font-size: 15px;
4043
+ font-weight: 700;
4044
+ color: var(--color-done);
4045
+ }
4046
+
4047
+ .fleet-rest-sub {
4048
+ font-size: 12px;
4049
+ color: var(--text-secondary);
4050
+ }
4051
+
4052
+ /* ═══════════════════════════════════════════════
4053
+ * Dashboard welcome card
4054
+ * ═══════════════════════════════════════════════ */
4055
+
4056
+ .dashboard-welcome-card {
4057
+ text-align: center;
4058
+ padding: 40px 24px;
4059
+ display: flex;
4060
+ flex-direction: column;
4061
+ align-items: center;
4062
+ gap: 12px;
4063
+ }
4064
+
4065
+ .dashboard-welcome-icon {
4066
+ font-size: 52px;
4067
+ margin-bottom: 8px;
4068
+ }
4069
+
4070
+ .dashboard-welcome-title {
4071
+ font-size: 22px;
4072
+ font-weight: 700;
4073
+ color: var(--text-primary);
4074
+ }
4075
+
4076
+ .dashboard-welcome-desc {
4077
+ font-size: 14px;
4078
+ color: var(--text-secondary);
4079
+ max-width: 340px;
4080
+ line-height: 1.6;
4081
+ }
@@ -189,6 +189,7 @@ export function CreateTaskModal({ onClose }) {
189
189
  export function DashboardTab() {
190
190
  const [showCreate, setShowCreate] = useState(false);
191
191
  const [showStartModal, setShowStartModal] = useState(false);
192
+ const [uptime, setUptime] = useState(null);
192
193
  const status = statusData.value;
193
194
  const executor = executorData.value;
194
195
  const project = projectSummary.value;
@@ -219,6 +220,44 @@ export function DashboardTab() {
219
220
  blocked ? ` · ${blocked} blocked` : ""
220
221
  }`;
221
222
 
223
+ // ── Dynamic headline ──
224
+ const headline =
225
+ totalActive === 0
226
+ ? "Fleet idle"
227
+ : blocked > 0
228
+ ? "Needs attention"
229
+ : "All systems running";
230
+ const headlineClass =
231
+ totalActive === 0
232
+ ? "dashboard-headline-idle"
233
+ : blocked > 0
234
+ ? "dashboard-headline-error"
235
+ : "dashboard-headline-ok";
236
+
237
+ // ── Hero badge: all tasks done and nothing pending ──
238
+ const fleetAtRest = totalTasks > 0 && done > 0 && backlog === 0 && totalActive === 0;
239
+
240
+ // ── Uptime fetch on mount ──
241
+ useEffect(() => {
242
+ let active = true;
243
+ apiFetch("/api/health", { _silent: true })
244
+ .then((res) => {
245
+ if (!active) return;
246
+ const secs = Number(res?.uptime || 0);
247
+ if (!secs) return;
248
+ const d = Math.floor(secs / 86400);
249
+ const h = Math.floor((secs % 86400) / 3600);
250
+ const m = Math.floor((secs % 3600) / 60);
251
+ const parts = [];
252
+ if (d > 0) parts.push(`${d}d`);
253
+ if (h > 0) parts.push(`${h}h`);
254
+ if (m > 0 && d === 0) parts.push(`${m}m`);
255
+ setUptime(parts.length ? `up ${parts.join(" ")}` : "up < 1m");
256
+ })
257
+ .catch(() => {});
258
+ return () => { active = false; };
259
+ }, []);
260
+
222
261
  const overviewMetrics = [
223
262
  {
224
263
  label: "Total tasks",
@@ -226,6 +265,7 @@ export function DashboardTab() {
226
265
  color: "var(--text-primary)",
227
266
  trend: getTrend("total"),
228
267
  spark: "total",
268
+ tab: "tasks",
229
269
  },
230
270
  {
231
271
  label: "In progress",
@@ -233,6 +273,7 @@ export function DashboardTab() {
233
273
  color: "var(--color-inprogress)",
234
274
  trend: getTrend("running"),
235
275
  spark: "running",
276
+ tab: "tasks",
236
277
  },
237
278
  {
238
279
  label: "Done",
@@ -240,6 +281,7 @@ export function DashboardTab() {
240
281
  color: "var(--color-done)",
241
282
  trend: getTrend("done"),
242
283
  spark: "done",
284
+ tab: "tasks",
243
285
  },
244
286
  {
245
287
  label: "Error rate",
@@ -247,9 +289,13 @@ export function DashboardTab() {
247
289
  color: "var(--color-error)",
248
290
  trend: -getTrend("errors"),
249
291
  spark: "errors",
292
+ tab: "tasks",
250
293
  },
251
294
  ];
252
295
 
296
+ // ── Live fleet ticker data (3 most recent tasks) ──
297
+ const tickerTasks = (tasksData.value || []).slice(0, 3);
298
+
253
299
  const workItems = [
254
300
  { label: "Running", value: running, color: "var(--color-inprogress)" },
255
301
  { label: "Review", value: review, color: "var(--color-inreview)" },
@@ -416,17 +462,40 @@ export function DashboardTab() {
416
462
  if (!status && !executor)
417
463
  return html`<${Card} title="Loading…"><${SkeletonCard} count=${4} /><//>`;
418
464
 
465
+ /* ── Welcome empty state ── */
466
+ if (totalTasks === 0 && !executor) {
467
+ return html`
468
+ <div class="dashboard-shell">
469
+ <${Card} className="dashboard-card">
470
+ <div class="dashboard-welcome-card">
471
+ <div class="dashboard-welcome-icon">🎛️</div>
472
+ <div class="dashboard-welcome-title">Welcome to VirtEngine Control Center</div>
473
+ <div class="dashboard-welcome-desc">
474
+ Your AI development fleet is ready. Create your first task to get started.
475
+ </div>
476
+ <button class="btn btn-primary" onClick=${() => setShowCreate(true)}>
477
+ ➕ Create your first task
478
+ </button>
479
+ </div>
480
+ <//>
481
+ ${showCreate &&
482
+ html`<${CreateTaskModal} onClose=${() => setShowCreate(false)} />`}
483
+ </div>
484
+ `;
485
+ }
486
+
419
487
  return html`
420
488
  <div class="dashboard-shell">
421
489
  <div class="dashboard-header">
422
490
  <div class="dashboard-header-text">
423
491
  <div class="dashboard-eyebrow">Pulse</div>
424
- <div class="dashboard-title">Calm system overview</div>
492
+ <div class="dashboard-title ${headlineClass}">${headline}</div>
425
493
  <div class="dashboard-subtitle">${headerLine}</div>
426
494
  </div>
427
495
  <div class="dashboard-header-meta">
428
496
  <span class="dashboard-chip">Mode ${mode}</span>
429
497
  <span class="dashboard-chip">SDK ${defaultSdk}</span>
498
+ ${uptime ? html`<span class="dashboard-chip">${uptime}</span>` : null}
430
499
  ${executor
431
500
  ? executor.paused
432
501
  ? html`<${Badge} status="error" text="Paused" />`
@@ -490,6 +559,24 @@ export function DashboardTab() {
490
559
  Resume
491
560
  </button>
492
561
  </div>
562
+ ${tickerTasks.length > 0 ? html`
563
+ <div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border)">
564
+ <div style="font-size:10px;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-secondary);margin-bottom:6px;display:flex;align-items:center;gap:5px;">
565
+ <span style="width:6px;height:6px;border-radius:50%;background:var(--color-done);animation:errorPulse 2s ease-in-out infinite;display:inline-block;"></span>
566
+ Live
567
+ </div>
568
+ <div class="fleet-ticker-wrap">
569
+ <div class="fleet-ticker-inner">
570
+ ${tickerTasks.map((task) => html`
571
+ <div class="fleet-ticker-item">
572
+ <${Badge} status=${task.status} text=${task.status} />
573
+ <span>${truncate(task.title || "(untitled)", 38)}</span>
574
+ </div>
575
+ `)}
576
+ </div>
577
+ </div>
578
+ </div>
579
+ ` : null}
493
580
  <//>
494
581
 
495
582
  <${Card}
@@ -498,29 +585,46 @@ export function DashboardTab() {
498
585
  >`}
499
586
  className="dashboard-card dashboard-overview"
500
587
  >
501
- <div class="dashboard-metric-grid">
502
- ${overviewMetrics.map(
503
- (metric) => html`
504
- <div class="dashboard-metric">
505
- <div class="dashboard-metric-label">${metric.label}</div>
506
- <div
507
- class="dashboard-metric-value"
508
- style="color: ${metric.color}"
509
- >
510
- ${metric.value} ${trend(metric.trend)}
511
- </div>
512
- <div class="dashboard-metric-spark">
513
- <${MiniSparkline}
514
- data=${sparkData(metric.spark)}
515
- color=${metric.color}
516
- height=${20}
517
- width=${90}
518
- />
519
- </div>
520
- </div>
521
- `,
522
- )}
523
- </div>
588
+ ${fleetAtRest
589
+ ? html`
590
+ <div class="fleet-rest-badge">
591
+ <div class="fleet-rest-icon">✓</div>
592
+ <div class="fleet-rest-label">Fleet at rest</div>
593
+ <div class="fleet-rest-sub">${done} task${done !== 1 ? "s" : ""} completed · zero pending</div>
594
+ </div>
595
+ `
596
+ : html`
597
+ <div class="dashboard-metric-grid">
598
+ ${overviewMetrics.map(
599
+ (metric) => html`
600
+ <div
601
+ class="dashboard-metric"
602
+ style="cursor:pointer;"
603
+ role="button"
604
+ tabindex="0"
605
+ onClick=${() => navigateTo(metric.tab || "tasks")}
606
+ onKeyDown=${(e) => e.key === "Enter" && navigateTo(metric.tab || "tasks")}
607
+ >
608
+ <div class="dashboard-metric-label">${metric.label}</div>
609
+ <div
610
+ class="dashboard-metric-value"
611
+ style="color: ${metric.color}"
612
+ >
613
+ ${metric.value} ${trend(metric.trend)}
614
+ </div>
615
+ <div class="dashboard-metric-spark">
616
+ <${MiniSparkline}
617
+ data=${sparkData(metric.spark)}
618
+ color=${metric.color}
619
+ height=${20}
620
+ width=${90}
621
+ />
622
+ </div>
623
+ </div>
624
+ `,
625
+ )}
626
+ </div>
627
+ `}
524
628
  ${segments.length > 0 && html`
525
629
  <div class="dashboard-work-layout" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
526
630
  <div class="dashboard-work-list">
package/utils.mjs CHANGED
@@ -170,3 +170,50 @@ export function parsePrNumberFromUrl(url) {
170
170
  const num = Number(match[1]);
171
171
  return Number.isFinite(num) ? num : null;
172
172
  }
173
+
174
+ // ── Async process helpers ─────────────────────────────────────────────────
175
+
176
+ /**
177
+ * spawnAsync — Promise-based alternative to spawnSync.
178
+ * Doesn't block the event loop. Resolves { stdout, stderr, status }
179
+ * or rejects with an Error enriched with those same properties.
180
+ *
181
+ * @param {string} cmd
182
+ * @param {string[]} args
183
+ * @param {import('node:child_process').SpawnOptions} options
184
+ * @returns {Promise<{ stdout: string, stderr: string, status: number }>}
185
+ */
186
+ export async function spawnAsync(cmd, args = [], options = {}) {
187
+ const { spawn } = await import("node:child_process");
188
+ return new Promise((resolve, reject) => {
189
+ const stdoutChunks = [];
190
+ const stderrChunks = [];
191
+ const proc = spawn(cmd, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
192
+ proc.stdout.on("data", (d) => stdoutChunks.push(d));
193
+ proc.stderr.on("data", (d) => stderrChunks.push(d));
194
+ proc.on("close", (code) => {
195
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
196
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
197
+ if (code !== 0) {
198
+ const err = Object.assign(
199
+ new Error(`spawnAsync: ${cmd} ${args.join(" ")} exited ${code}\n${stderr || stdout}`),
200
+ { stdout, stderr, status: code },
201
+ );
202
+ reject(err);
203
+ } else {
204
+ resolve({ stdout, stderr, status: code });
205
+ }
206
+ });
207
+ proc.on("error", reject);
208
+ });
209
+ }
210
+
211
+ /**
212
+ * yieldToEventLoop — allow other microtasks/macrotasks to proceed.
213
+ * Use between iterations of long synchronous-ish loops to prevent
214
+ * event loop starvation when many agent slots are active.
215
+ * @returns {Promise<void>}
216
+ */
217
+ export function yieldToEventLoop() {
218
+ return new Promise((resolve) => setImmediate(resolve));
219
+ }