|
// GNOME Shell Extension: DevClaw Monitor |
|
// Shows active DevClaw workers in the Ubuntu top bar |
|
// For GNOME 46 / Ubuntu 24.04 |
|
// |
|
// Install to: ~/.local/share/gnome-shell/extensions/devclaw-monitor@kadajett/extension.js |
|
|
|
import GLib from 'gi://GLib'; |
|
import Gio from 'gi://Gio'; |
|
import St from 'gi://St'; |
|
import Clutter from 'gi://Clutter'; |
|
import Soup from 'gi://Soup?version=3.0'; |
|
import GdkPixbuf from 'gi://GdkPixbuf'; |
|
import Cogl from 'gi://Cogl'; |
|
|
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js'; |
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; |
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; |
|
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; |
|
|
|
// ── CONFIG: Set this to your DevClaw API base URL ── |
|
// The API exposes /api/workers/active, /api/workers, /api/activity, /api/health |
|
// It must be reachable from your local machine (e.g. via Tailscale) |
|
const API_BASE = 'http://REPLACE_WITH_YOUR_DEVCLAW_API_HOST:3100'; |
|
|
|
const POLL_SECONDS = 10; |
|
|
|
function avatarSeed(sessionKey) { |
|
let hash = 0; |
|
for (let i = 0; i < sessionKey.length; i++) { |
|
hash = ((hash << 5) - hash + sessionKey.charCodeAt(i)) | 0; |
|
} |
|
return Math.abs(hash); |
|
} |
|
|
|
function formatDuration(startTime) { |
|
if (!startTime) return '?'; |
|
const ms = Date.now() - new Date(startTime).getTime(); |
|
if (ms < 0) return '0s'; |
|
const s = Math.floor(ms / 1000); |
|
if (s < 60) return `${s}s`; |
|
const m = Math.floor(s / 60); |
|
if (m < 60) return `${m}m`; |
|
const h = Math.floor(m / 60); |
|
const rm = m % 60; |
|
return `${h}h${rm > 0 ? rm + 'm' : ''}`; |
|
} |
|
|
|
function formatTokens(count) { |
|
if (!count || count === 0) return null; |
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M tok`; |
|
if (count >= 1000) return `${(count / 1000).toFixed(0)}k tok`; |
|
return `${count} tok`; |
|
} |
|
|
|
export default class DevClawMonitor extends Extension { |
|
_indicator = null; |
|
_pollTimer = null; |
|
_avatarCache = new Map(); |
|
_soupSession = null; |
|
_lastWorkers = []; |
|
|
|
enable() { |
|
this._soupSession = new Soup.Session({ timeout: 5 }); |
|
this._indicator = new PanelMenu.Button(0.0, 'DevClaw Monitor', false); |
|
this._indicator.add_style_class_name('devclaw-indicator'); |
|
|
|
const box = new St.BoxLayout({ style_class: 'panel-status-indicators-box' }); |
|
|
|
this._icon = new St.Icon({ |
|
icon_name: 'system-run-symbolic', |
|
style_class: 'system-status-icon', |
|
icon_size: 16, |
|
}); |
|
box.add_child(this._icon); |
|
|
|
this._badge = new St.Label({ |
|
text: '0', |
|
style_class: 'devclaw-badge-zero', |
|
y_align: Clutter.ActorAlign.CENTER, |
|
}); |
|
box.add_child(this._badge); |
|
|
|
this._indicator.add_child(box); |
|
this._indicator.menu.box.add_style_class_name('devclaw-menu'); |
|
|
|
this._indicator.menu.connect('open-state-changed', (_menu, open) => { |
|
if (open) this._rebuildMenu(); |
|
}); |
|
|
|
Main.panel.addToStatusArea('devclaw-monitor', this._indicator); |
|
|
|
this._poll(); |
|
this._pollTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, POLL_SECONDS, () => { |
|
this._poll(); |
|
return GLib.SOURCE_CONTINUE; |
|
}); |
|
} |
|
|
|
disable() { |
|
if (this._pollTimer) { |
|
GLib.source_remove(this._pollTimer); |
|
this._pollTimer = null; |
|
} |
|
this._indicator?.destroy(); |
|
this._indicator = null; |
|
this._soupSession = null; |
|
this._avatarCache.clear(); |
|
} |
|
|
|
_poll() { |
|
const msg = Soup.Message.new('GET', `${API_BASE}/api/workers/active`); |
|
this._soupSession.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (_session, result) => { |
|
try { |
|
const bytes = this._soupSession.send_and_read_finish(result); |
|
if (msg.get_status() !== Soup.Status.OK) return; |
|
const decoder = new TextDecoder('utf-8'); |
|
const data = JSON.parse(decoder.decode(bytes.get_data())); |
|
this._lastWorkers = data.workers || []; |
|
this._updateBadge(this._lastWorkers.length); |
|
} catch { |
|
// API unreachable — keep last state |
|
} |
|
}); |
|
} |
|
|
|
_updateBadge(count) { |
|
this._badge.text = `${count}`; |
|
this._badge.style_class = count > 0 ? 'devclaw-badge' : 'devclaw-badge-zero'; |
|
} |
|
|
|
_rebuildMenu() { |
|
this._indicator.menu.removeAll(); |
|
const workers = this._lastWorkers; |
|
|
|
if (workers.length === 0) { |
|
const empty = new PopupMenu.PopupMenuItem('No active workers', { reactive: false }); |
|
empty.label.add_style_class_name('devclaw-empty'); |
|
this._indicator.menu.addMenuItem(empty); |
|
return; |
|
} |
|
|
|
// Group by project |
|
const grouped = {}; |
|
for (const w of workers) { |
|
if (!grouped[w.project]) grouped[w.project] = []; |
|
grouped[w.project].push(w); |
|
} |
|
|
|
let first = true; |
|
for (const [projectName, projectWorkers] of Object.entries(grouped)) { |
|
if (!first) { |
|
this._indicator.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); |
|
} |
|
first = false; |
|
|
|
const headerItem = new PopupMenu.PopupMenuItem(projectName, { reactive: false }); |
|
headerItem.label.add_style_class_name('devclaw-project-header'); |
|
this._indicator.menu.addMenuItem(headerItem); |
|
|
|
for (const w of projectWorkers) { |
|
this._addWorkerRow(w); |
|
} |
|
} |
|
} |
|
|
|
_addWorkerRow(w) { |
|
const item = new PopupMenu.PopupBaseMenuItem({ |
|
reactive: !!w.issueUrl, |
|
can_focus: !!w.issueUrl, |
|
}); |
|
item.add_style_class_name('devclaw-worker-row'); |
|
|
|
// Avatar |
|
const avatarBin = new St.Bin({ |
|
style_class: 'devclaw-avatar', |
|
x_align: Clutter.ActorAlign.CENTER, |
|
y_align: Clutter.ActorAlign.CENTER, |
|
}); |
|
item.add_child(avatarBin); |
|
if (w.sessionKey) this._loadAvatar(w.sessionKey, avatarBin); |
|
|
|
// Info column |
|
const infoBox = new St.BoxLayout({ vertical: true, style_class: 'devclaw-worker-info', x_expand: true }); |
|
|
|
// Row 1: Name + Role/Level |
|
const topRow = new St.BoxLayout({ x_expand: true }); |
|
topRow.add_child(new St.Label({ text: w.name || '?', style_class: 'devclaw-worker-name', x_expand: true })); |
|
topRow.add_child(new St.Label({ text: `${w.role}/${w.level}`, style_class: `devclaw-worker-detail devclaw-role-${w.role}` })); |
|
infoBox.add_child(topRow); |
|
|
|
// Row 2: Issue + Duration |
|
const midRow = new St.BoxLayout({ x_expand: true }); |
|
midRow.add_child(new St.Label({ |
|
text: w.issueId ? `#${w.issueId}` : 'no issue', |
|
style_class: w.issueId ? 'devclaw-issue-link' : 'devclaw-worker-detail', |
|
x_expand: true, |
|
})); |
|
midRow.add_child(new St.Label({ text: formatDuration(w.startTime), style_class: 'devclaw-worker-time' })); |
|
infoBox.add_child(midRow); |
|
|
|
// Row 3: Tokens + Start time |
|
const bottomRow = new St.BoxLayout({ x_expand: true }); |
|
const tokenStr = formatTokens(w.tokens); |
|
bottomRow.add_child(new St.Label({ text: tokenStr || '0 tok', style_class: tokenStr ? 'devclaw-worker-tokens' : 'devclaw-worker-detail', x_expand: true })); |
|
if (w.startTime) { |
|
const t = new Date(w.startTime); |
|
bottomRow.add_child(new St.Label({ |
|
text: `started ${t.getHours().toString().padStart(2, '0')}:${t.getMinutes().toString().padStart(2, '0')}`, |
|
style_class: 'devclaw-worker-detail', |
|
})); |
|
} |
|
infoBox.add_child(bottomRow); |
|
|
|
item.add_child(infoBox); |
|
|
|
if (w.issueUrl) { |
|
item.connect('activate', () => { |
|
Gio.AppInfo.launch_default_for_uri(w.issueUrl, null); |
|
}); |
|
} |
|
|
|
this._indicator.menu.addMenuItem(item); |
|
} |
|
|
|
_loadAvatar(sessionKey, bin) { |
|
const seed = avatarSeed(sessionKey); |
|
const cacheKey = `${seed}`; |
|
|
|
if (this._avatarCache.has(cacheKey)) { |
|
const cached = this._avatarCache.get(cacheKey); |
|
if (cached) bin.set_child(this._makeAvatarIcon(cached)); |
|
return; |
|
} |
|
|
|
const url = `https://randomuser.me/api/portraits/thumb/${seed % 2 === 0 ? 'women' : 'men'}/${seed % 100}.jpg`; |
|
const message = Soup.Message.new('GET', url); |
|
|
|
this._soupSession.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (_session, result) => { |
|
try { |
|
const bytes = this._soupSession.send_and_read_finish(result); |
|
if (message.get_status() !== Soup.Status.OK) return; |
|
const data = bytes.get_data(); |
|
const stream = Gio.MemoryInputStream.new_from_bytes(data); |
|
const pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale(stream, 28, 28, true, null); |
|
stream.close(null); |
|
this._avatarCache.set(cacheKey, pixbuf); |
|
if (bin.get_parent()) bin.set_child(this._makeAvatarIcon(pixbuf)); |
|
} catch { /* avatar load failed */ } |
|
}); |
|
} |
|
|
|
_makeAvatarIcon(pixbuf) { |
|
const image = new Clutter.Image(); |
|
const format = pixbuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 : Cogl.PixelFormat.RGB_888; |
|
image.set_data(pixbuf.get_pixels(), format, pixbuf.get_width(), pixbuf.get_height(), pixbuf.get_rowstride()); |
|
return new St.Widget({ width: 28, height: 28, content: image, style: 'border-radius: 14px;' }); |
|
} |
|
} |