bosun 0.31.4 → 0.31.5

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.
@@ -240,6 +240,22 @@ export const PATTERN_SEVERITY = {
240
240
  unknown: "low",
241
241
  };
242
242
 
243
+ // ── Remediation hints ──────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Human-readable remediation hints for each error type.
247
+ * Surfaced in UI and Telegram notifications to guide users.
248
+ */
249
+ const REMEDIATION_HINTS = {
250
+ rate_limit: "Wait a few minutes before retrying. Consider reducing MAX_PARALLEL.",
251
+ oom: "Reduce MAX_PARALLEL or increase available memory. Use --max-old-space-size.",
252
+ oom_kill: "Process was killed by the OS. Reduce memory usage or increase system RAM.",
253
+ git_conflict: "Manual conflict resolution required. Run: git mergetool",
254
+ push_failure: "Rebase failed or push rejected. Run: git rebase --abort then try again.",
255
+ auth_error: "Authentication failed. Check your API tokens and credentials.",
256
+ api_error: "Network connectivity issue. Check your internet connection.",
257
+ };
258
+
243
259
  // ── Helpers ─────────────────────────────────────────────────────────────────
244
260
 
245
261
  /** Safely truncate a string for logging / details. */
@@ -364,6 +380,7 @@ export class ErrorDetector {
364
380
  return {
365
381
  ...result,
366
382
  severity: PATTERN_SEVERITY[result.pattern] ?? "low",
383
+ remediation: REMEDIATION_HINTS[result.pattern] || null,
367
384
  };
368
385
  }
369
386
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.31.4",
3
+ "version": "0.31.5",
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, yieldToEventLoop } from "./utils.mjs";
71
+ import { normalizeDedupKey, yieldToEventLoop, withRetry } from "./utils.mjs";
72
72
  import {
73
73
  resolveExecutorForTask,
74
74
  executorToSdk,
@@ -3169,10 +3169,20 @@ class TaskExecutor {
3169
3169
  return;
3170
3170
  }
3171
3171
 
3172
- // Fetch todo tasks
3172
+ // Fetch todo tasks (with transient-error retry)
3173
3173
  let tasks;
3174
3174
  try {
3175
- tasks = await listTasks(projectId, { status: "todo" });
3175
+ tasks = await withRetry(
3176
+ () => listTasks(projectId, { status: "todo" }),
3177
+ {
3178
+ maxAttempts: 3,
3179
+ baseMs: 2000,
3180
+ retryIf: (err) => {
3181
+ const cat = categorizeError(err);
3182
+ return cat === "transient" || cat === "network";
3183
+ },
3184
+ },
3185
+ );
3176
3186
  this._resetListTasksBackoff();
3177
3187
  } catch (err) {
3178
3188
  this._noteListTasksFailure(err);
@@ -3252,14 +3262,19 @@ class TaskExecutor {
3252
3262
  for (const task of toDispatch) {
3253
3263
  // Normalize task id
3254
3264
  task.id = task.id || task.task_id;
3255
- // Fire and forget — executeTask handles its own lifecycle
3256
- this.executeTask(task).catch((err) => {
3257
- console.error(
3258
- `${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
3259
- );
3260
- });
3261
- // Yield between slot dispatches so WebSocket/HTTP work can proceed
3262
- await yieldToEventLoop();
3265
+ try {
3266
+ // Fire and forget — executeTask handles its own lifecycle
3267
+ this.executeTask(task).catch((err) => {
3268
+ console.error(
3269
+ `${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
3270
+ );
3271
+ });
3272
+ } catch (err) {
3273
+ console.warn(`${TAG} slot ${task.id} dispatch error: ${err.message}`);
3274
+ } finally {
3275
+ // ALWAYS yield, even on error, to prevent event loop starvation
3276
+ await yieldToEventLoop();
3277
+ }
3263
3278
  }
3264
3279
  } catch (err) {
3265
3280
  console.error(`${TAG} poll loop error: ${err.message}`);
package/ui/app.js CHANGED
@@ -3,12 +3,28 @@
3
3
  * Modular SPA for Telegram Mini App (no build step)
4
4
  * ────────────────────────────────────────────────────────────── */
5
5
 
6
+ // ── Error telemetry ring buffer (max 50 entries, persisted to sessionStorage) ──
7
+ const MAX_ERROR_LOG = 50;
8
+ function getErrorLog() {
9
+ try { return JSON.parse(sessionStorage.getItem("ve_error_log") || "[]"); } catch { return []; }
10
+ }
11
+ function appendErrorLog(entry) {
12
+ try {
13
+ const log = getErrorLog();
14
+ log.unshift({ ...entry, ts: Date.now() });
15
+ if (log.length > MAX_ERROR_LOG) log.length = MAX_ERROR_LOG;
16
+ sessionStorage.setItem("ve_error_log", JSON.stringify(log));
17
+ } catch { /* quota exceeded */ }
18
+ }
19
+
6
20
  /* ── Global error handlers — catch unhandled errors before they freeze the UI ── */
7
21
  globalThis.addEventListener?.("error", (e) => {
8
22
  console.error("[ve:global-error]", e.error || e.message);
23
+ appendErrorLog({ type: "global", message: e.message, stack: e.error?.stack });
9
24
  });
10
25
  globalThis.addEventListener?.("unhandledrejection", (e) => {
11
26
  console.error("[ve:unhandled-rejection]", e.reason);
27
+ appendErrorLog({ type: "rejection", message: String(e.reason?.message || e.reason) });
12
28
  });
13
29
 
14
30
  import { h, render as preactRender } from "preact";
@@ -47,6 +63,8 @@ import {
47
63
  } from "./modules/api.js";
48
64
  import {
49
65
  connected,
66
+ statusData,
67
+ executorData,
50
68
  refreshTab,
51
69
  toasts,
52
70
  initWsInvalidationListener,
@@ -108,6 +126,60 @@ try {
108
126
  if (stateMod.dataFreshness) dataFreshness = stateMod.dataFreshness;
109
127
  } catch { /* use placeholder signals */ }
110
128
 
129
+ /* ── Shared components ── */
130
+
131
+ /**
132
+ * AnimatedNumber — smoothly counts from previous to new value using rAF.
133
+ */
134
+ function AnimatedNumber({ value, duration = 600, className = "" }) {
135
+ const displayRef = useRef(value);
136
+ const rafRef = useRef(null);
137
+ const [display, setDisplay] = useState(value);
138
+
139
+ useEffect(() => {
140
+ const from = displayRef.current;
141
+ const to = value;
142
+ if (from === to) return;
143
+ const start = performance.now();
144
+ const animate = (now) => {
145
+ const t = Math.min((now - start) / duration, 1);
146
+ const eased = 1 - Math.pow(1 - t, 3); // ease out cubic
147
+ const current = Math.round(from + (to - from) * eased);
148
+ displayRef.current = current;
149
+ setDisplay(current);
150
+ if (t < 1) rafRef.current = requestAnimationFrame(animate);
151
+ };
152
+ rafRef.current = requestAnimationFrame(animate);
153
+ return () => rafRef.current && cancelAnimationFrame(rafRef.current);
154
+ }, [value, duration]);
155
+
156
+ return html`<span class="${className}">${display}</span>`;
157
+ }
158
+
159
+ /**
160
+ * KeyboardShortcutsModal — shows available keyboard shortcuts.
161
+ */
162
+ function KeyboardShortcutsModal({ onClose }) {
163
+ const shortcuts = [
164
+ { key: "1–8", desc: "Switch tabs" },
165
+ { key: "c", desc: "Create task (on Dashboard)" },
166
+ { key: "?", desc: "Show keyboard shortcuts" },
167
+ { key: "Esc", desc: "Close modal / palette" },
168
+ ];
169
+ return html`
170
+ <${Modal} title="Keyboard Shortcuts" onClose=${onClose}>
171
+ <div class="shortcuts-list">
172
+ ${shortcuts.map((s) => html`
173
+ <div class="shortcut-item" key=${s.key}>
174
+ <kbd class="shortcut-key">${s.key}</kbd>
175
+ <span class="shortcut-desc">${s.desc}</span>
176
+ </div>
177
+ `)}
178
+ </div>
179
+ <//>
180
+ `;
181
+ }
182
+
111
183
  /* ── Backend health helpers ── */
112
184
 
113
185
  function formatTimeAgo(ts) {
@@ -285,6 +357,7 @@ class TabErrorBoundary extends Component {
285
357
  }
286
358
  componentDidCatch(error, info) {
287
359
  console.error("[TabErrorBoundary] Caught error:", error, info);
360
+ appendErrorLog({ type: "render", tab: this.props.tabName, message: error?.message, stack: error?.stack });
288
361
  }
289
362
  render() {
290
363
  if (this.state.error) {
@@ -317,6 +390,11 @@ class TabErrorBoundary extends Component {
317
390
  ${stack ? html`<button class="btn btn-ghost btn-sm" onClick=${toggleStack}>
318
391
  ${this.state.showStack ? "Hide Stack" : "Stack Trace"}
319
392
  </button>` : null}
393
+ <button class="btn btn-ghost btn-sm" onClick=${() => {
394
+ console.group("[ve:error-log]");
395
+ getErrorLog().forEach((e, i) => console.log(i, e));
396
+ console.groupEnd();
397
+ }}>Error Log</button>
320
398
  </div>
321
399
  ${this.state.showStack && stack ? html`
322
400
  <div class="tab-error-stack">${stack}</div>
@@ -438,10 +516,18 @@ function SidebarNav() {
438
516
  ${TAB_CONFIG.map((tab) => {
439
517
  const isActive = activeTab.value === tab.id;
440
518
  const isHome = tab.id === "dashboard";
519
+ const sCounts = statusData.value?.counts || {};
520
+ const badge =
521
+ tab.id === "tasks"
522
+ ? Number(sCounts.running || sCounts.inprogress || 0) + Number(sCounts.inreview || sCounts.review || 0)
523
+ : tab.id === "agents"
524
+ ? Number(executorData.value?.data?.activeSlots || 0)
525
+ : 0;
441
526
  return html`
442
527
  <button
443
528
  key=${tab.id}
444
529
  class="sidebar-nav-item ${isActive ? "active" : ""}"
530
+ style="position:relative"
445
531
  aria-label=${tab.label}
446
532
  aria-current=${isActive ? "page" : null}
447
533
  onClick=${() =>
@@ -452,6 +538,7 @@ function SidebarNav() {
452
538
  >
453
539
  ${ICONS[tab.icon]}
454
540
  <span>${tab.label}</span>
541
+ ${badge > 0 ? html`<span class="nav-badge">${badge}</span>` : null}
455
542
  </button>
456
543
  `;
457
544
  })}
@@ -683,15 +770,20 @@ function getTabsById(ids) {
683
770
 
684
771
  function BottomNav({ compact, moreOpen, onToggleMore, onNavigate }) {
685
772
  const primaryTabs = getTabsById(PRIMARY_NAV_TABS);
773
+ const sCounts = statusData.value?.counts || {};
774
+ const tasksBadge = Number(sCounts.running || sCounts.inprogress || 0) + Number(sCounts.inreview || sCounts.review || 0);
775
+ const agentsBadge = Number(executorData.value?.data?.activeSlots || 0);
686
776
  return html`
687
777
  <nav class=${`bottom-nav ${compact ? "compact" : ""}`}>
688
778
  ${primaryTabs.map((tab) => {
689
779
  const isHome = tab.id === "dashboard";
690
780
  const isActive = activeTab.value === tab.id;
781
+ const badge = tab.id === "tasks" ? tasksBadge : tab.id === "agents" ? agentsBadge : 0;
691
782
  return html`
692
783
  <button
693
784
  key=${tab.id}
694
785
  class="nav-item ${isActive ? "active" : ""}"
786
+ style="position:relative"
695
787
  aria-label=${`Go to ${tab.label}`}
696
788
  type="button"
697
789
  onClick=${() =>
@@ -702,6 +794,7 @@ function BottomNav({ compact, moreOpen, onToggleMore, onNavigate }) {
702
794
  >
703
795
  ${ICONS[tab.icon]}
704
796
  <span class="nav-label">${tab.label}</span>
797
+ ${badge > 0 ? html`<span class="nav-badge">${badge}</span>` : null}
705
798
  </button>
706
799
  `;
707
800
  })}
@@ -783,6 +876,7 @@ function MoreSheet({ open, onClose, onNavigate }) {
783
876
  function App() {
784
877
  useBackendHealth();
785
878
  const { open: paletteOpen, onClose: paletteClose } = useCommandPalette();
879
+ const [showShortcuts, setShowShortcuts] = useState(false);
786
880
  const mainRef = useRef(null);
787
881
  const [showScrollTop, setShowScrollTop] = useState(false);
788
882
  const scrollVisibilityRef = useRef(false);
@@ -965,7 +1059,10 @@ function App() {
965
1059
 
966
1060
  useEffect(() => {
967
1061
  if (typeof globalThis === "undefined") return;
968
- const handler = () => setIsMoreOpen(false);
1062
+ const handler = () => {
1063
+ setIsMoreOpen(false);
1064
+ setShowShortcuts(false);
1065
+ };
969
1066
  globalThis.addEventListener("ve:close-modals", handler);
970
1067
  return () => globalThis.removeEventListener("ve:close-modals", handler);
971
1068
  }, []);
@@ -1016,9 +1113,24 @@ function App() {
1016
1113
  return;
1017
1114
  }
1018
1115
 
1116
+ // "c" to create task (when not in a form element)
1117
+ if (e.key === "c") {
1118
+ e.preventDefault();
1119
+ globalThis.dispatchEvent(new CustomEvent("ve:create-task"));
1120
+ return;
1121
+ }
1122
+
1123
+ // "?" to toggle keyboard shortcuts help
1124
+ if (e.key === "?") {
1125
+ e.preventDefault();
1126
+ setShowShortcuts((v) => !v);
1127
+ return;
1128
+ }
1129
+
1019
1130
  // Escape to close modals/palette
1020
1131
  if (e.key === "Escape") {
1021
1132
  globalThis.dispatchEvent(new CustomEvent("ve:close-modals"));
1133
+ setShowShortcuts(false);
1022
1134
  }
1023
1135
  }
1024
1136
  document.addEventListener("keydown", handleGlobalKeys);
@@ -1223,6 +1335,7 @@ function App() {
1223
1335
  ${backendDown.value ? html`<${OfflineBanner} />` : null}
1224
1336
  <${ToastContainer} />
1225
1337
  <${CommandPalette} open=${paletteOpen} onClose=${paletteClose} />
1338
+ ${showShortcuts ? html`<${KeyboardShortcutsModal} onClose=${() => setShowShortcuts(false)} />` : null}
1226
1339
  <${PullToRefresh} onRefresh=${() => refreshTab(activeTab.value)}>
1227
1340
  <main class="main-content" ref=${mainRef}>
1228
1341
  <${TabErrorBoundary} key=${activeTab.value} tabName=${activeTab.value}>
package/ui/modules/api.js CHANGED
@@ -6,6 +6,9 @@
6
6
  import { signal } from "@preact/signals";
7
7
  import { getInitData } from "./telegram.js";
8
8
 
9
+ /** Map of in-flight GET request promises, keyed by path */
10
+ const _inflight = new Map();
11
+
9
12
  /** Reactive signal: whether the WebSocket is currently connected */
10
13
  export const wsConnected = signal(false);
11
14
  /** Reactive signal: WebSocket round-trip latency in ms (null if unknown) */
@@ -27,7 +30,7 @@ export const loadingCount = signal(0);
27
30
  * @param {RequestInit & {_silent?: boolean}} options
28
31
  * @returns {Promise<any>} parsed JSON body
29
32
  */
30
- export async function apiFetch(path, options = {}) {
33
+ export function apiFetch(path, options = {}) {
31
34
  const headers = { ...options.headers };
32
35
  headers["Content-Type"] = headers["Content-Type"] || "application/json";
33
36
 
@@ -39,30 +42,59 @@ export async function apiFetch(path, options = {}) {
39
42
  const silent = options._silent;
40
43
  delete options._silent;
41
44
 
42
- loadingCount.value += 1;
43
- try {
44
- const res = await fetch(path, { ...options, headers });
45
- if (!res.ok) {
46
- const text = await res.text().catch(() => "");
47
- throw new Error(text || `Request failed (${res.status})`);
45
+ // Deduplicate concurrent identical GETs
46
+ const isGet = !options.method || options.method === "GET";
47
+ if (isGet && !options.body) {
48
+ if (_inflight.has(path)) {
49
+ return _inflight.get(path);
48
50
  }
49
- return await res.json();
50
- } catch (err) {
51
- // Re-throw so callers can catch, but don't toast on silent requests
52
- if (!silent) {
53
- // Dispatch a custom event so the state layer can show a toast
54
- try {
55
- globalThis.dispatchEvent(
56
- new CustomEvent("ve:api-error", { detail: { message: err.message } }),
57
- );
58
- } catch {
59
- /* noop */
51
+ }
52
+
53
+ // Retry config for network-level failures only (not 4xx/5xx HTTP errors)
54
+ const MAX_FETCH_RETRIES = 2;
55
+ const FETCH_RETRY_BASE_MS = 800;
56
+
57
+ const promise = (async () => {
58
+ loadingCount.value += 1;
59
+ let res;
60
+ let fetchAttempt = 0;
61
+ try {
62
+ while (fetchAttempt <= MAX_FETCH_RETRIES) {
63
+ try {
64
+ res = await fetch(path, { ...options, headers });
65
+ break; // success — exit retry loop
66
+ } catch (networkErr) {
67
+ fetchAttempt++;
68
+ if (fetchAttempt > MAX_FETCH_RETRIES || silent) throw networkErr;
69
+ await new Promise((r) => setTimeout(r, FETCH_RETRY_BASE_MS * fetchAttempt));
70
+ }
71
+ }
72
+ if (!res.ok) {
73
+ const text = await res.text().catch(() => "");
74
+ throw new Error(text || `Request failed (${res.status})`);
60
75
  }
76
+ return await res.json();
77
+ } catch (err) {
78
+ // Re-throw so callers can catch, but don't toast on silent requests
79
+ if (!silent) {
80
+ // Dispatch a custom event so the state layer can show a toast
81
+ try {
82
+ globalThis.dispatchEvent(
83
+ new CustomEvent("ve:api-error", { detail: { message: err.message } }),
84
+ );
85
+ } catch {
86
+ /* noop */
87
+ }
88
+ }
89
+ throw err;
90
+ } finally {
91
+ loadingCount.value = Math.max(0, loadingCount.value - 1);
92
+ if (isGet && !options.body) _inflight.delete(path);
61
93
  }
62
- throw err;
63
- } finally {
64
- loadingCount.value = Math.max(0, loadingCount.value - 1);
65
- }
94
+ })();
95
+
96
+ if (isGet && !options.body) _inflight.set(path, promise);
97
+ return promise;
66
98
  }
67
99
 
68
100
  /* ─── Command Sending ─── */
@@ -4079,3 +4079,167 @@ select.input {
4079
4079
  max-width: 340px;
4080
4080
  line-height: 1.6;
4081
4081
  }
4082
+
4083
+ /* ═══════════════════════════════════════════════
4084
+ * Nav Badges
4085
+ * ═══════════════════════════════════════════════ */
4086
+
4087
+ .nav-badge {
4088
+ display: inline-flex;
4089
+ align-items: center;
4090
+ justify-content: center;
4091
+ min-width: 18px;
4092
+ height: 18px;
4093
+ padding: 0 5px;
4094
+ border-radius: 99px;
4095
+ background: var(--accent);
4096
+ color: var(--accent-text, #fff);
4097
+ font-size: 10px;
4098
+ font-weight: 700;
4099
+ line-height: 1;
4100
+ position: absolute;
4101
+ top: 2px;
4102
+ right: 2px;
4103
+ }
4104
+
4105
+ /* ═══════════════════════════════════════════════
4106
+ * Dashboard Clock Chip
4107
+ * ═══════════════════════════════════════════════ */
4108
+
4109
+ .dashboard-chip-clock {
4110
+ font-variant-numeric: tabular-nums;
4111
+ font-family: var(--font-mono, monospace);
4112
+ }
4113
+
4114
+ .dashboard-chip-tz {
4115
+ font-size: 10px;
4116
+ opacity: 0.7;
4117
+ margin-left: 4px;
4118
+ }
4119
+
4120
+ /* ═══════════════════════════════════════════════
4121
+ * Dashboard Health Score
4122
+ * ═══════════════════════════════════════════════ */
4123
+
4124
+ .dashboard-health-score {
4125
+ text-align: center;
4126
+ padding: 12px 0;
4127
+ }
4128
+
4129
+ .health-score-value {
4130
+ font-size: 48px;
4131
+ font-weight: 800;
4132
+ line-height: 1;
4133
+ letter-spacing: -0.05em;
4134
+ font-family: var(--font-mono, monospace);
4135
+ }
4136
+
4137
+ .health-score-label {
4138
+ font-size: 11px;
4139
+ text-transform: uppercase;
4140
+ letter-spacing: 0.1em;
4141
+ color: var(--text-secondary);
4142
+ margin-top: 4px;
4143
+ }
4144
+
4145
+ /* ═══════════════════════════════════════════════
4146
+ * Dashboard Recent Commits
4147
+ * ═══════════════════════════════════════════════ */
4148
+
4149
+ .dashboard-commits {
4150
+ display: flex;
4151
+ flex-direction: column;
4152
+ gap: 8px;
4153
+ }
4154
+
4155
+ .dashboard-commit-item {
4156
+ display: grid;
4157
+ grid-template-columns: 56px 1fr;
4158
+ grid-template-rows: auto auto;
4159
+ column-gap: 10px;
4160
+ padding: 8px 10px;
4161
+ border-radius: 8px;
4162
+ background: var(--surface-secondary, rgba(255,255,255,0.04));
4163
+ border: 1px solid var(--border);
4164
+ }
4165
+
4166
+ .dashboard-commit-hash {
4167
+ grid-row: 1 / 3;
4168
+ font-family: var(--font-mono, monospace);
4169
+ font-size: 11px;
4170
+ color: var(--accent);
4171
+ font-weight: 600;
4172
+ align-self: center;
4173
+ letter-spacing: 0.03em;
4174
+ }
4175
+
4176
+ .dashboard-commit-msg {
4177
+ font-size: 13px;
4178
+ color: var(--text-primary);
4179
+ white-space: nowrap;
4180
+ overflow: hidden;
4181
+ text-overflow: ellipsis;
4182
+ }
4183
+
4184
+ .dashboard-commit-meta {
4185
+ font-size: 11px;
4186
+ color: var(--text-secondary);
4187
+ margin-top: 2px;
4188
+ }
4189
+
4190
+ /* ═══════════════════════════════════════════════
4191
+ * Stat Flash Animation
4192
+ * ═══════════════════════════════════════════════ */
4193
+
4194
+ @keyframes statFlash {
4195
+ 0% { background: rgba(59, 130, 246, 0.15); }
4196
+ 100% { background: transparent; }
4197
+ }
4198
+
4199
+ .stat-flash {
4200
+ animation: statFlash 0.4s ease;
4201
+ }
4202
+
4203
+ /* ═══════════════════════════════════════════════
4204
+ * Keyboard Shortcuts Modal
4205
+ * ═══════════════════════════════════════════════ */
4206
+
4207
+ .shortcuts-list {
4208
+ display: flex;
4209
+ flex-direction: column;
4210
+ gap: 6px;
4211
+ padding: 4px 0;
4212
+ }
4213
+
4214
+ .shortcut-item {
4215
+ display: flex;
4216
+ align-items: center;
4217
+ gap: 12px;
4218
+ padding: 6px 0;
4219
+ border-bottom: 1px solid var(--border);
4220
+ }
4221
+
4222
+ .shortcut-item:last-child {
4223
+ border-bottom: none;
4224
+ }
4225
+
4226
+ .shortcut-key {
4227
+ display: inline-flex;
4228
+ align-items: center;
4229
+ justify-content: center;
4230
+ min-width: 40px;
4231
+ padding: 3px 8px;
4232
+ border-radius: 6px;
4233
+ background: var(--surface-secondary, rgba(255,255,255,0.06));
4234
+ border: 1px solid var(--border);
4235
+ font-family: var(--font-mono, monospace);
4236
+ font-size: 12px;
4237
+ font-weight: 600;
4238
+ color: var(--text-primary);
4239
+ white-space: nowrap;
4240
+ }
4241
+
4242
+ .shortcut-desc {
4243
+ font-size: 13px;
4244
+ color: var(--text-secondary);
4245
+ }
@@ -6,6 +6,7 @@ import {
6
6
  useState,
7
7
  useEffect,
8
8
  useCallback,
9
+ useRef,
9
10
  } from "preact/hooks";
10
11
  import htm from "htm";
11
12
 
@@ -96,6 +97,32 @@ const QUICK_ACTIONS = [
96
97
  },
97
98
  ];
98
99
 
100
+ /* ─── AnimatedNumber ─── */
101
+ function AnimatedNumber({ value, duration = 600, className = "" }) {
102
+ const displayRef = useRef(value);
103
+ const rafRef = useRef(null);
104
+ const [display, setDisplay] = useState(value);
105
+
106
+ useEffect(() => {
107
+ const from = displayRef.current;
108
+ const to = value;
109
+ if (from === to) return;
110
+ const start = performance.now();
111
+ const animate = (now) => {
112
+ const t = Math.min((now - start) / duration, 1);
113
+ const eased = 1 - Math.pow(1 - t, 3);
114
+ const current = Math.round(from + (to - from) * eased);
115
+ displayRef.current = current;
116
+ setDisplay(current);
117
+ if (t < 1) rafRef.current = requestAnimationFrame(animate);
118
+ };
119
+ rafRef.current = requestAnimationFrame(animate);
120
+ return () => rafRef.current && cancelAnimationFrame(rafRef.current);
121
+ }, [value, duration]);
122
+
123
+ return html`<span class="${className}">${display}</span>`;
124
+ }
125
+
99
126
  /* ─── CreateTaskModal ─── */
100
127
  export function CreateTaskModal({ onClose }) {
101
128
  const [title, setTitle] = useState("");
@@ -190,6 +217,11 @@ export function DashboardTab() {
190
217
  const [showCreate, setShowCreate] = useState(false);
191
218
  const [showStartModal, setShowStartModal] = useState(false);
192
219
  const [uptime, setUptime] = useState(null);
220
+ // New state
221
+ const [now, setNow] = useState(() => new Date());
222
+ const [recentCommits, setRecentCommits] = useState([]);
223
+ const [flashKey, setFlashKey] = useState(0);
224
+ const prevCounts = useRef(null);
193
225
  const status = statusData.value;
194
226
  const executor = executorData.value;
195
227
  const project = projectSummary.value;
@@ -216,6 +248,19 @@ export function DashboardTab() {
216
248
  const slotPct = execData?.maxParallel
217
249
  ? ((execData.activeSlots || 0) / execData.maxParallel) * 100
218
250
  : 0;
251
+
252
+ // ── Health score (0–100) ──
253
+ let healthScore = 100;
254
+ if (executor?.paused) healthScore -= 20;
255
+ healthScore -= Math.min(40, errorRateValue * 2);
256
+ if ((execData?.activeSlots ?? 0) === 0 && backlog > 0) healthScore -= 10;
257
+ if (slotPct > 50 && blocked === 0) healthScore += 10;
258
+ healthScore = Math.min(100, Math.max(0, Math.round(healthScore)));
259
+
260
+ // ── Clock ──
261
+ const timeStr = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
262
+ const tzStr = Intl.DateTimeFormat().resolvedOptions().timeZone;
263
+
219
264
  const headerLine = `${totalActive} active · ${backlog} backlog · ${done} done${
220
265
  blocked ? ` · ${blocked} blocked` : ""
221
266
  }`;
@@ -258,6 +303,36 @@ export function DashboardTab() {
258
303
  return () => { active = false; };
259
304
  }, []);
260
305
 
306
+ // ── Listen for ve:create-task keyboard shortcut ──
307
+ useEffect(() => {
308
+ const handler = () => setShowCreate(true);
309
+ window.addEventListener("ve:create-task", handler);
310
+ return () => window.removeEventListener("ve:create-task", handler);
311
+ }, []);
312
+
313
+ // ── Real-time clock ──
314
+ useEffect(() => {
315
+ const t = setInterval(() => setNow(new Date()), 1000);
316
+ return () => clearInterval(t);
317
+ }, []);
318
+
319
+ // ── Recent commits (graceful 404) ──
320
+ useEffect(() => {
321
+ apiFetch("/api/recent-commits", { _silent: true })
322
+ .then((data) => { if (Array.isArray(data)) setRecentCommits(data.slice(0, 3)); })
323
+ .catch(() => {});
324
+ }, []);
325
+
326
+ // ── Flash metrics on counts change ──
327
+ useEffect(() => {
328
+ const current = JSON.stringify(counts);
329
+ const previous = JSON.stringify(prevCounts.current);
330
+ if (previous !== current) {
331
+ prevCounts.current = counts;
332
+ setFlashKey((k) => k + 1);
333
+ }
334
+ });
335
+
261
336
  const overviewMetrics = [
262
337
  {
263
338
  label: "Total tasks",
@@ -496,6 +571,9 @@ export function DashboardTab() {
496
571
  <span class="dashboard-chip">Mode ${mode}</span>
497
572
  <span class="dashboard-chip">SDK ${defaultSdk}</span>
498
573
  ${uptime ? html`<span class="dashboard-chip">${uptime}</span>` : null}
574
+ <span class="dashboard-chip dashboard-chip-clock">
575
+ ${timeStr} <span class="dashboard-chip-tz">${tzStr}</span>
576
+ </span>
499
577
  ${executor
500
578
  ? executor.paused
501
579
  ? html`<${Badge} status="error" text="Paused" />`
@@ -518,6 +596,10 @@ export function DashboardTab() {
518
596
  ${executor?.paused ? "Paused" : "Running"}
519
597
  </div>
520
598
  </div>
599
+ <div class="dashboard-health-score">
600
+ <div class="health-score-value" style="color: ${healthScore >= 80 ? 'var(--color-done)' : healthScore >= 50 ? 'var(--color-inreview)' : 'var(--color-error)'}">${healthScore}</div>
601
+ <div class="health-score-label">Health Score</div>
602
+ </div>
521
603
  <div class="dashboard-health-grid">
522
604
  <div class="dashboard-health-item">
523
605
  <div class="dashboard-health-label">Slots</div>
@@ -594,7 +676,7 @@ export function DashboardTab() {
594
676
  </div>
595
677
  `
596
678
  : html`
597
- <div class="dashboard-metric-grid">
679
+ <div class="dashboard-metric-grid stat-flash" key=${flashKey}>
598
680
  ${overviewMetrics.map(
599
681
  (metric) => html`
600
682
  <div
@@ -610,7 +692,9 @@ export function DashboardTab() {
610
692
  class="dashboard-metric-value"
611
693
  style="color: ${metric.color}"
612
694
  >
613
- ${metric.value} ${trend(metric.trend)}
695
+ ${typeof metric.value === "number"
696
+ ? html`<${AnimatedNumber} value=${metric.value} />`
697
+ : metric.value} ${trend(metric.trend)}
614
698
  </div>
615
699
  <div class="dashboard-metric-spark">
616
700
  <${MiniSparkline}
@@ -798,6 +882,23 @@ export function DashboardTab() {
798
882
  ${showCreate &&
799
883
  html`<${CreateTaskModal} onClose=${() => setShowCreate(false)} />`}
800
884
 
885
+ ${recentCommits.length > 0 && html`
886
+ <${Card}
887
+ title=${html`<span class="dashboard-card-title"><span class="dashboard-title-icon">${ICONS.git || '🔀'}</span>Recent Commits</span>`}
888
+ className="dashboard-card dashboard-commits-card"
889
+ >
890
+ <div class="dashboard-commits">
891
+ ${recentCommits.map((c) => html`
892
+ <div class="dashboard-commit-item" key=${c.hash || c.message}>
893
+ <div class="dashboard-commit-hash">${(c.hash || '').slice(0, 7)}</div>
894
+ <div class="dashboard-commit-msg">${truncate(c.message || c.msg || '', 60)}</div>
895
+ <div class="dashboard-commit-meta">${c.author || ''} · ${formatRelative(c.date || c.timestamp)}</div>
896
+ </div>
897
+ `)}
898
+ </div>
899
+ <//>
900
+ `}
901
+
801
902
  ${showStartModal &&
802
903
  html`
803
904
  <${StartTaskModal}
package/utils.mjs CHANGED
@@ -217,3 +217,63 @@ export async function spawnAsync(cmd, args = [], options = {}) {
217
217
  export function yieldToEventLoop() {
218
218
  return new Promise((resolve) => setImmediate(resolve));
219
219
  }
220
+
221
+ /**
222
+ * withRetry — run an async fn with exponential backoff on failure.
223
+ *
224
+ * @template T
225
+ * @param {() => Promise<T>} fn — async operation to retry
226
+ * @param {object} [opts]
227
+ * @param {number} [opts.maxAttempts=3] — total attempts
228
+ * @param {number} [opts.baseMs=1000] — initial delay
229
+ * @param {number} [opts.maxMs=30000] — cap delay
230
+ * @param {(err: Error, attempt: number) => boolean} [opts.retryIf] — optional guard
231
+ * @returns {Promise<T>}
232
+ */
233
+ export async function withRetry(fn, opts = {}) {
234
+ const { maxAttempts = 3, baseMs = 1000, maxMs = 30000, retryIf } = opts;
235
+ let lastErr;
236
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
237
+ try {
238
+ return await fn();
239
+ } catch (err) {
240
+ lastErr = err;
241
+ if (attempt >= maxAttempts) break;
242
+ if (retryIf && !retryIf(err, attempt)) break;
243
+ const jitter = Math.random() * 0.3 + 0.85; // 0.85–1.15x
244
+ const delay = Math.min(baseMs * Math.pow(2, attempt - 1) * jitter, maxMs);
245
+ await new Promise((r) => setTimeout(r, delay));
246
+ }
247
+ }
248
+ throw lastErr;
249
+ }
250
+
251
+ /**
252
+ * memoizeWithTtl — cache the result of a zero-args async factory for `ttlMs`.
253
+ * Returns a getter function that resolves the cached value or refreshes it.
254
+ *
255
+ * @template T
256
+ * @param {() => Promise<T>} factory
257
+ * @param {number} ttlMs
258
+ * @returns {() => Promise<T>}
259
+ */
260
+ export function memoizeWithTtl(factory, ttlMs) {
261
+ let cache = null;
262
+ let expiry = 0;
263
+ let pending = null;
264
+ return async function get() {
265
+ const now = Date.now();
266
+ if (cache !== null && now < expiry) return cache;
267
+ if (pending) return pending;
268
+ pending = factory().then((v) => {
269
+ cache = v;
270
+ expiry = Date.now() + ttlMs;
271
+ pending = null;
272
+ return v;
273
+ }).catch((e) => {
274
+ pending = null;
275
+ throw e;
276
+ });
277
+ return pending;
278
+ };
279
+ }