Skip to content

Instantly share code, notes, and snippets.

@Kadajett
Created March 7, 2026 09:51
Show Gist options
  • Select an option

  • Save Kadajett/c22dae887687f74274a5c4593e4eed93 to your computer and use it in GitHub Desktop.

Select an option

Save Kadajett/c22dae887687f74274a5c4593e4eed93 to your computer and use it in GitHub Desktop.
DevClaw Monitor - GNOME Shell Extension for tracking active AI workers
// 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;' });
}
}
{
"name": "DevClaw Monitor",
"description": "Shows active DevClaw workers in the GNOME top bar",
"uuid": "devclaw-monitor@kadajett",
"shell-version": ["46"],
"version": 1,
"url": ""
}

DevClaw Monitor - GNOME Shell Extension Install Prompt

What this is

A GNOME 46 Shell extension that shows active DevClaw AI workers in the Ubuntu top bar. It polls a REST API and displays a red badge with the count of active workers. Clicking it opens a compact dark-mode dropdown showing each worker's name, role/level, issue ID (clickable link to GitHub), runtime duration, token usage, and a randomuser.me avatar.

Setup instructions

1. Install the extension files

Create the extension directory and copy the three files from this gist into it:

mkdir -p ~/.local/share/gnome-shell/extensions/devclaw-monitor@kadajett/
  • extension.js -> ~/.local/share/gnome-shell/extensions/devclaw-monitor@kadajett/extension.js
  • stylesheet.css -> ~/.local/share/gnome-shell/extensions/devclaw-monitor@kadajett/stylesheet.css
  • metadata.json -> ~/.local/share/gnome-shell/extensions/devclaw-monitor@kadajett/metadata.json

2. Set the API URL

In extension.js, find the line:

const API_BASE = 'http://REPLACE_WITH_YOUR_DEVCLAW_API_HOST:3100';

Replace REPLACE_WITH_YOUR_DEVCLAW_API_HOST with the Tailscale hostname or IP of the DevClaw API server. The API must be reachable from this machine. You can verify with:

curl http://YOUR_HOST:3100/api/health

3. Enable the extension

From a desktop terminal (not SSH):

gnome-extensions enable devclaw-monitor@kadajett

Or use the GNOME Extensions app / Extension Manager to toggle it on.

If the extension doesn't appear, restart GNOME Shell:

  • X11: Alt+F2, type r, Enter
  • Wayland: Log out and log back in

API Endpoints

The extension uses /api/workers/active which returns:

{
  "count": 2,
  "workers": [
    {
      "project": "my-project",
      "slug": "my-project",
      "role": "developer",
      "level": "medior",
      "slotIndex": 0,
      "name": "Carmelle",
      "issueId": "1333",
      "sessionKey": "agent:...:subagent:...",
      "startTime": "2026-03-07T08:21:07.177Z",
      "active": true,
      "tokens": 257416,
      "staleCount": 0,
      "ghUrl": "https://github.com/User/Repo",
      "issueUrl": "https://github.com/User/Repo/issues/1333"
    }
  ]
}

Other available endpoints:

  • GET /api/workers - All workers (active + idle) with project summaries
  • GET /api/activity?limit=50 - Recent worker activity log events
  • GET /api/health - Health check

Behavior

  • Polls every 10 seconds
  • Red badge = active workers, grey badge = zero
  • Workers grouped by project in the dropdown
  • Clicking a worker row opens the GitHub issue in the browser
  • Avatars are deterministic per session key (same worker = same face)
  • All dark mode, compact layout designed for the top bar
/* DevClaw Monitor - Dark compact theme for GNOME 46 top bar */
.devclaw-indicator { padding: 0 4px; }
.devclaw-badge {
background-color: #e53935; color: #fff; border-radius: 7px;
font-size: 9px; font-weight: bold; min-width: 14px; min-height: 14px;
text-align: center; padding: 0 3px; margin-left: 2px;
}
.devclaw-badge-zero {
background-color: #555; color: #999; border-radius: 7px;
font-size: 9px; min-width: 14px; min-height: 14px;
text-align: center; padding: 0 3px; margin-left: 2px;
}
.devclaw-menu { min-width: 380px; max-width: 420px; }
.devclaw-project-header {
font-size: 10px; font-weight: bold; color: #777;
padding: 4px 12px 2px 12px; text-transform: uppercase;
}
.devclaw-worker-row { padding: 6px 10px; spacing: 8px; }
.devclaw-avatar {
width: 28px; height: 28px; border-radius: 14px; background-color: #333;
}
.devclaw-worker-info { spacing: 1px; }
.devclaw-worker-name { font-size: 12px; font-weight: bold; color: #e0e0e0; }
.devclaw-worker-detail { font-size: 10px; color: #999; }
.devclaw-worker-tokens { font-size: 10px; color: #7986cb; }
.devclaw-worker-time { font-size: 10px; color: #66bb6a; }
.devclaw-issue-link { font-size: 10px; color: #42a5f5; }
.devclaw-issue-link:hover { text-decoration: underline; }
.devclaw-role-developer { color: #66bb6a; }
.devclaw-role-reviewer { color: #ef5350; }
.devclaw-role-architect { color: #42a5f5; }
.devclaw-role-tester { color: #ab47bc; }
.devclaw-empty { font-size: 11px; color: #666; padding: 12px; text-align: center; }
.devclaw-separator { background-color: #333; height: 1px; margin: 2px 8px; }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment