26 KiB
Werkregie-Dashboard
A web app showing what the coding agents and the build pipeline are doing right now and what happened recently — read-only, links back to the tools.
| Source | Data | Shown |
|---|---|---|
| Claude Code (Desktop and terminal) | ~/.config/Claude/claude-code-sessions/**.json, ~/.claude/sessions/<pid>.json, the end of ~/.claude/projects/*/<session>.jsonl |
busy / waiting / closed sessions, resume command, sessions waiting for the user |
| Codex (ChatGPT Desktop, IDE, CLI) | ~/.codex/state_5.sqlite (read-only), rollout files |
threads, running turns, resume command |
| Werkator, any number of instances | /api/repos, /api/repos/{repo}/builds/history, /api/system, /api/system/history |
running and finished builds, without failures a newer build of the branch superseded; @ai-review builds as werkrevisor; the host's load |
| werkrevisor's statistics service | /api/stats |
review costs of 24 hours and 7 days |
| Gitea, any number of instances | /api/v1 |
open PRs with commit statuses (Werkator, werkrevisor run and verdict), own activity, merges of the configured authors |
| Matrix, with the user's own account | client-server API through Trixnity (de.connect2x.trixnity) |
direct messages, mentions in every room, every message of configured rooms |
The page follows Werkator's design (werkregie.css mirrors werkator.css: IBM Plex, petrol accent, light/dark with a toggle).
It opens on the overview, which answers two questions first: is every source connected, and where must the user act?
- The connection bar shows each tool once, as its logo with the worst status of its sources; a logo of several sources shows a second dot behind it, with the best status. A tap or click on the logo opens a menu of its instances and relays, each with its state and link.
- The page asks the server every 5 s while it is visible, at once when it becomes visible, returns from the back/forward cache
or the network comes back, and after a failure again after 1 s, at most 3 times; a request that hangs fails after 10 s.
On the desktop the Claude logo opens Claude Desktop's sessions waiting for the user (
claude://code/needs-input), and a session of Claude Desktop opens there (claude://code/continue?session=…). On a phone the logo openshttps://claude.ai/code, and a session with Remote Control opens its address there, which the Claude app takes over; a session without Remote Control has no link there. Codex has no such link yet: the ChatGPT app on Linux takescodex://threads/…but does not act on it. - Du bist dran groups what asks something of the user: Absichern, Festgefahren, Nacharbeiten, Freigeben, Mergen, Antworten.
Absichern holds a vulnerable dependency on main: a failed run of Werkator's nightly build
vulnerability-data, with the vulnerabilities of its OWASP Dependency-Check report. Antworten holds the Matrix messages to the user, the Claude Code sessions that wait for the user, and the user's own PRs that another human commented. - Beside it: the load of each Werkator host, Werkator's builds of 24 hours, werkrevisor's costs,
and per Gitea the merges into the default branch of 30 days, 12 weeks or 12 months, stacked by author,
as pull requests or as changed lines; the legend names the mean and the median of the lines per pull request.
A Werkator panel charts 24 hours of its 3 most critical metrics,
out of CPU, RAM, each disk, load, swap, CPU wait, memory wait and disk wait.
Before its verdict it shows the running builds of its slots, e.g.
1/2, coloured as on Werkator's tile "Builds". - Below it: what runs and which sessions wait, as cards; the history of 24 hours as a lane per tool.
The tabs Pull Requests, Sessions, Aufgaben, Nachrichten, Verlauf and Quellen hold the full lists, with the filters;
the address keeps the tab, e.g. #prs.
On a phone the tabs are a bar at the bottom.
The page speaks German or English (DE/EN in the header); the browser keeps the choice.
Texts that the sources compose, e.g. Gitea activity titles, stay as the server writes them.
The logos in static/logos/ are the tools' own; the black ones turn white in the dark mode.
The page installs as an app (PWA): manifest.webmanifest, icons and the service worker sw.js in static/.
The service worker keeps the page, styles and fonts for a start without network, fetching them anew first whenever it can;
it never touches /api/.
The page itself keeps the last state of /api/dashboard in the browser's localStorage (werkregie-last):
when the server does not answer, it shows that state under an amber banner with its age.
Demo data is never kept, and forgetting the access token deletes the state.
The PNG icons are rendered from the SVGs, e.g. inkscape icon-maskable.svg -w 512 -h 512 -o icon-maskable-512.png.
Modes
One program, three ways to run it:
┌──────────── Linux machine with coding agents ─────────────┐
│ Claude Code, Codex ──► werkregie-dashboard relay ──┐ │
└─────────────────────────────────────────────────────┼─────┘
HTTPS POST every 5 s, │ outgoing only:
bearer token ▼ nothing reaches in
┌──────────── host, e.g. Hostsharing Managed Webspace ──────┐
browser ─►│ Apache (.htaccess) ─► werkregie-dashboard server ◄── Werkator, Gitea
(cookie) └───────────────────────────────────────────────────────────┘
| Mode | Reads | Serves |
|---|---|---|
local (default) |
Claude Code, Codex, Werkator, Gitea | 127.0.0.1 only, no token |
server |
Werkator, Gitea, and whatever the relays report | real data only with the access token, invented demo data without |
relay |
Claude Code, Codex | nothing: posts its state to relay.serverUrl |
watch-pr URL |
one pull request of a Gitea in giteas |
nothing: prints one line when its coding agent must act, then ends |
- Access token: the server creates it on its first start in
<stateDir>/access-token(mode 0600);werkregie-dashboard tokenprints it, deleting the file rotates it. The browser enters it with the key button and keeps it as thewerkregie_tokencookie; relays send it asAuthorization: Bearer. The relay endpoint does not accept the cookie, so no other site can make a logged-in browser post to it. - Silent relays: after
server.relayStaleSeconds(30) the relay's chip turns yellow with "zuletzt vor …", and the indicator in the header shows e.g. "think still" in amber instead of "live", also on a phone; afterserver.relayGoneSeconds(120) its running and open sessions move to the history. A server without any relay shows a failing source "Relays" and "kein Relay" in the header. - Retired relays: a relay that reports no more stays as stale until it is forgotten:
werkregie-dashboard forget-relay NAMEon the server host asks the running server (with the token from its state), or editsrelays.jsonwhen no server runs. A relay still running simply reports again. - State: the last report of every relay is kept in
<stateDir>/relays.json, so a restarted server is filled at once.stateDirdefaults to${XDG_STATE_HOME:-~/.local/state}/werkregie. - Journal: every minute the server, or the local mode, records the work of each day in
<stateDir>/journal/YYYY-MM-DD.json: the active time of each Claude Code session and Codex thread, and the items of all sources, sessions apart. A recorded span or item stays when its source forgets it; days older thanjournal.keepDays(400) are deleted. The relay reads the active time from the time stamps in the transcripts,claude.projectsDirand Codex' rollout files; entries apart by at mostjournal.gapMinutes(15) count as one span, a longer pause splits it.journal.zone(default the machine's zone) sets where a day begins.GET /api/journal/YYYY-MM-DDreturns a day, on a server only with the access token. - Werkjournal proposal: the tab Journal proposes the Werkjournal entries of a picked day, to correct, copy or download
as
werkjournal-YYYY-MM-DD.json, Werkjournal's import file of schema version 1.journal.customersnames the Werkjournal customers and their projects, each with itsrepositories(without owner),taigaproject slugs, Matrixrooms(ID or name) andpathsof working directories without a repository; work that no project names goes tojournal.fallback, else it stays a hint. There is one entry per Taiga story and day where a branch, a title or a commit message names a Taiga ID (Taiga#US-591,Taiga#591,Taiga591,US-591,TG-591); Taiga resolves it, a task to its story. Else there is one entry per project and day. The minutes are the union of the sessions' active spans and of 15 min before each action in Gitea, Taiga and Matrix; builds, reviews and pull requests are evidence only. A minute in which several entries are active counts once, shared equally among them. They are rounded half up to whole hours; a story below 30 min joins the entry of its project without story, less than 30 min after that is a hint, and a day over 12 h gives from its largest entry. Ids and descriptions derive from the day's data, so importing the same file twice adds nothing in Werkjournal. Withjournal.summary, a chat model through OpenRouter asclaude.summary(endpoint,model,apiKeyorapiKeyEnv), each entry of the file also gets an AI summary of its evidence; the switch "Regeln | KI" in the tab picks the text. A summary is kept by the hash of its evidence in<stateDir>/journal-summaries.json, 400 days after its last use, so the same day gives the same text, after a restart too; while the model fails, the entry keeps the rule text.GET /api/journal/YYYY-MM-DD/proposalreturns the proposal, on a server only with the access token.
Run locally
./gradlew installDist
build/install/werkregie-dashboard/bin/werkregie-dashboard # → http://localhost:4747/
or ./gradlew run. To start it with the session, see packaging/werkregie-dashboard.service.
./gradlew build needs Node 18 or newer: jsTest tests the JavaScript of the page with node --test,
without npm packages (ADR 0002).
The tests lie in src/test/js/; a tested function moves out of index.html into a script of its own, e.g. static/merges.js.
Server on a host (Hostsharing Managed Webspace)
The same shape as Werkator's: a runtime bundle with its own JRE, a systemd user service on the
port of the booked "eigener Serverdienst", and the platform's Apache as TLS-terminating proxy via .htaccess.
- Build the bundle on a Linux x86_64 machine (the JRE is Temurin 25, built against an old glibc; werkregie ADR 0001):
./gradlew runtimeBundle # → build/distributions/werkregie-dashboard-runtime-linux-x64.tar.gz - On the host, unpack it to
~/optand write the configuration, see dashboard.server.example.yml (portis the assigned port,server.publicBaseUrlthe domain). - Generate and install the unit and the
.htaccess; the command prints the remaining steps:~/opt/werkregie-dashboard/bin/werkregie-dashboard init server ~/werkregie-init ~/opt/werkregie-dashboard/bin/werkregie-dashboard tokenprints the access token for the browser and the relays.
To update: stop the service, unpack the new bundle over ~/opt/werkregie-dashboard, start the service.
Deployment by Werkator
Werkator's build deploy follows each green build of main:
packaging/werkregie-deploy builds the runtime bundle of the tested commit
and sends it over one ssh connection to packaging/werkregie-deploy-receive.
The receiver checks that the new launcher prints its version, keeps the running runtime as werkregie-dashboard.prev, swaps and restarts,
and waits up to 120 s until /api/dashboard answers with the new version; otherwise it puts the previous runtime back.
When the launcher fails or the new version does not answer, the build fails, and the service keeps running.
The repository says what the build does; the host decides when, where and with which key. The setup, once, by the human:
- As the user of the server, install the receiver:
~/bin/werkregie-deploy-receive, mode 0755. - As the user of Werkator, create a key without passphrase for the build
in the home that the build sees: natively
~/.ssh/, in the werkdock sandbox.git/werkator/buildenv/home/.ssh/. Name the server there as the alias that the script connects to, and add its host key toknown_hosts, e.g. byssh-keyscan mih09.hostsharing.net, after comparing the fingerprint:Host werkregie-deploy HostName mih09.hostsharing.net User mih09-werkregie IdentityFile ~/.ssh/werkregie-deploy IdentitiesOnly yes - Add the key to
~/.ssh/authorized_keysof the server's user, restricted to the receiver:command="/absolute/path/bin/werkregie-deploy-receive",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… werkregie-deploy - In
.git/werkator/.werkator.ymlof Werkator's checkout of werkregie, add the trigger; it is pinned there:builds: deploy: trigger: afterSuccessOf: default branches: ["main"]
Relay on a machine with coding agents
Configuration as in dashboard.relay.example.yml — relay.serverUrl and the token,
best in a file of its own (relay.tokenFile, mode 0600). Then:
build/install/werkregie-dashboard/bin/werkregie-dashboard init relay ~/werkregie-init # prints how to enable the unit
Matrix
Werkregie reads Matrix as a device of the user's own account,
so that end-to-end encrypted rooms are readable once the device has their keys.
It runs in the modes local and relay, never on the server: the device keys stay on the user's machine.
- Configure
matrix.homeserver; there is no default. - Stop the relay or the local mode, then sign in; it asks for user and password:
werkregie-dashboard matrix login - Verify the device with the recovery key, formerly "Security Key", or the security phrase;
this also unlocks the key backup, so that older encrypted messages become readable:
werkregie-dashboard matrix verify - Start the relay or the local mode again; it decrypts the rows shown as "verschlüsselt" once more.
The session and the keys live in <stateDir>/matrix/ (mode 0700):
an H2 database client.mv.db and the shown rows in messages.json.
To sign in anew, delete that directory and remove the device "Werkregie " in Element.
A relay reports only the start of each text, at most 80 characters, to the server.
One process per machine runs the client, the one that holds sync.lock:
a relay and the local mode share the state directory, and two clients with the same keys would spoil each other's sessions.
The other process shows the same messages from messages.json and takes over when the first one ends.
Until the device has the keys, an encrypted message shows as "verschlüsselt", with room, sender and time.
Each direct chat and configured room keeps its last message in the panel, however old.
A message the user has not read yet is marked "Neu"; read ones are subdued.
Read means up to the later of the user's read receipts and Element's read marker m.fully_read,
since an Element without read receipts only moves its marker.
A click opens the message by matrix.link: matrix.to (default) in the browser,
io.element.desktop in Element Desktop, element in its nightly build; kept messages take the setting, too.
On a phone the page links to matrix.to, which the Element app takes over:
unlike Android, a Linux desktop has no app that takes https://matrix.to from the browser.
Reactions
An agent marks a message of the user with a reaction, e.g. an announced pull request once it is approved:
werkregie-dashboard matrix react --room "HSAdmin NG" --contains /pulls/329 ✅️
The target is the newest own message among the last 500 events of the room whose text contains --contains,
not followed by a letter or a digit, so /pulls/329 finds no /pulls/3290.
--room takes the room's ID or its name.
The command prints one line:
REACTED <message> <reaction>; without<reaction>when the client has no event ID for it yet;PRESENT <message>when the user reacted with that key already, the variation selector U+FE0F aside;NO-MESSAGE, with exit code 1.
A failure ends with exit code 3, a wrong call with 2.
It sends an encrypted m.reaction in an encrypted room, as the device of matrix login.
A running relay or local mode sends the reaction with its own client, so it keeps running:
the process that holds sync.lock serves the Unix domain socket <stateDir>/matrix/control.sock,
which takes only a reaction, and matrix react asks it first.
When nobody answers there, the command takes sync.lock and opens a client of its own, for about 50 s.
A process that holds the lock but does not answer, e.g. a relay of an older version or one that is just starting,
makes the command fail; restart it with systemctl --user restart werkregie-relay.
Messages
An agent writes a message into a room, or reads the room, as the device of matrix login:
werkregie-dashboard matrix send --room "HSAdmin NG" "Build ist grün"
werkregie-dashboard matrix send --room "HSAdmin NG" - < nachricht.txt
werkregie-dashboard matrix read --room "HSAdmin NG" --since 2026-10-23T14:00
send sends the text as it is, as m.text without formatting, encrypted like the room;
with - it reads the text from stdin, without its last line break.
It prints SENT <event>; without <event> when the client has no event ID for it yet.
read prints the messages of the room, oldest first, a block for each one:
time, sender and event ID in one line, then the text indented by two spaces.
A message whose key the device does not have shows as (nicht entschlüsselt).
Without --since it prints the newest 20; --since takes an event ID, for the messages after it,
or a time, for those from it on, e.g. 2026-10-23, 2026-10-23T14:00 in local time or 2026-10-23T12:00:00Z.
It looks back at most 500 events; --limit N prints at most the newest N.
Both go the way of matrix react: through a running relay or local mode, else with a client of their own.
A failure ends with exit code 3, a wrong call with 2.
A relay of an older version answers both with an error; restart it after the update.
Configuration
~/.config/werkregie/dashboard.yml, or --config FILE, or WERKREGIE_DASHBOARD_CONFIG.
See dashboard.example.yml for the local mode; without a file only Claude Code and Codex are shown.
An entry this version does not know is named on stderr and skipped, so that a newer version may add entries before its deployment.
-
Claude Code: a session that is not busy is marked 💬 Claude fragt when it waits for the user: Frage offen or Dialog offen by its process file, Frage offen by an unanswered AskUserQuestion in its transcript, wartet auf dich when Jev rates Claude's last text at least
claude.jev.threshold(default 0.75). Only withclaude.jevdoes the last text, at most 3000 characters, go to Jev, once per message; its key comes fromapiKeyorapiKeyEnv, e.g.TYPESAFE_API_KEYin the relay'sdashboard.env. An archived session carries no marker. ✕ on a session in Du bist dran dismisses it until the session changes, e.g. with a new message; the server keeps the dismissals indismissed.jsonof its state directory, for all devices, and takes them only with the access token as bearer header. The other rows show ✕ dimmed, so that the controls align; its tooltip says why the row cannot be dismissed. The tooltip of its row in "Du bist dran" shows the user's last input and Claude's question: the open AskUserQuestion, else Claude's last text. The last input is a prompt or the answers to an AskUserQuestion, searched up to 8 MiB back in the transcript. On a phone, which has no tooltip, the info symbol ⓘ of the row shows both texts below its title. -
Session of a pull request: each pull request, in its panel and in Du bist dran, shows the session that works on it. That is the session that named its address last, in any checkout: in a shell command, e.g.
watch-pror a call of Gitea's API, in the output of a command that names this pull request alone, e.g. Gitea's answer to opening it, or in a text of Claude. A session whose command named it beats one that only wrote about it, e.g. a session that reports on the work of others. The user's texts, files and messages to other sessions do not count;http,https, Gitea's API and/issues/are one address. Without such a session, the pull request shows the session that last worked on its head branch, in a checkout of its repository, by thegitBranchof the transcript's lines, other than a detachedHEAD. The relay reads each transcript from where it stopped, at first only its last 32 MiB, and keeps the last 20 pull requests per session. Its button opens the session, as the session's own link does; under Festgefahren the copy symbol beside it copies a message for the session, e.g. to rebase ontomainafter a conflict. Each non-archived session counts, older than the window too; a relay older than 0.47.0 reports none, one older than werkregie PR#98 no addresses. A text over 160 characters is shortened byclaude.summary, by defaultanthropic/claude-haiku-5.5through OpenRouter, with its key fromapiKeyEnv, e.g.OPENROUTER_API_KEYin the relay'sdashboard.env; each text costs one call, since the summaries stay insummaries.jsonin the state directory for a day after their last use. Withoutclaude.summary, or while the model fails, such a text is cut. Both texts go with the relay's report to the server. -
Werkator: private repositories are redacted for anonymous readers.
controlToken(orcontrolTokenEnv) is sent as thewerkator_tokencookie, the way Werkator's own pages do;tools/remote --env-file .env.<instance> werkator control-tokenprints it. -
werkrevisor:
werkrevisorswithnameandurlof the statistics service; its costs are read anonymously. -
Gitea: a token with read scopes, as
token,tokenEnvorteaLogin(a login of~/.config/tea/config.yml, by name or user).pullRequests: minelists PRs created by, assigned to or awaiting review from the user,allevery open PR the user can see. A PR is the user's own (its author is the token's user), an agent's (its author is inagents) or a colleague's. Each PR is marked by what it asks of whom; the panel sorts by these markers, in this order: ⚠ Festgefahren (werkrevisor escalated, RED for an hour without a new commit, ERROR, a run without verdict, or an agent's PR in conflict for 30 min without a new commit sincemainleft its base; never a colleague's PR), ✋ Dein Approval (an agent's or a colleague's PR with a final ORANGE, then YELLOW, then without werkrevisor 2 min after its last change; a review requested from the user counts once werkrevisor's result is there, also after an approval of the user), ➜ Mergen (the user's own PR with the official approvals and green checks that the branch protection of its base asks for, with "hinter main: rebasen, dann mergen" when its base moved on; without a branch protection, approved by another human; or an agent's PR approved by anyone but its author, with all checks green; in each case no automerge scheduled), 🔧 Nacharbeit (conflict or red build on the user's own PR), ✍ Kommentar (another human commented on the user's own PR, or reviewed it with comments or requested changes, after its last commit and the user's last comment;reviewBotsdo not count), ⏳ Wartet auf Review (the user's own PR waits for another human), ⚙ Agent arbeitet (instead of Dein Approval while an agent's session named the PR's URL in a shell command, e.g.watch-pr, or wrote on the PR's branch, within 10 min; a text about the PR does not count), ✓ Von dir approved. Unmarked PRs follow, drafts come last; drafts carry no marker. Within each group the PR that waits longest comes first.reviewBots(default[ai-reviewer]) names accounts whose approval is no human's.agents(default[claude-code]) names the logins of the coding agents, ignoring case; a colleague merges and rebases a PR of its own, so it shows neither Mergen nor Festgefahren.mergeAuthors, e.g.[mi, claude-code], names the logins whose merges into the default branch the chart counts, by default the token's user;[]shows no chart. A pull request counts by its author on the day of its merge, in the time zone of the browser; a week starts on Monday. The merges are read every 10 min; each merged pull request is asked once for its base branch and its changed lines, the added plus the deleted ones of the whole diff. -
Taiga:
taskboardswithtype: taigalist the open user stories, tasks and issues assigned to the user in the configuredprojects, in the panel Aufgaben. There is no default instance or project. The API address comes from the fieldapiof<url>/conf.json, as for Taiga's own web app, else<url>/api/v1;apiUrloverrides it. Werkregie logs in with the user and the password of the variablepasswordEnvand logs in again when the token expires; on a server the variable stands indashboard.env, in the local mode it has to be in the environment.statusesnames the most important statuses first, within one the latest change first; a list in one place holds equal names of different projects, e.g.[In Arbeit, In Progress]. An open status named nowhere comes first, marked Status unbekannt, so that it gets added.hiddenStatuses, e.g.[Erledigt, Verworfen], are hidden until the panel's count shows them. Each status carries its colour from Taiga.
Local sources refresh every 2–3 s, Werkator every 30 s (three repositories at a time, its lines every 5 min), Gitea every 30 s (its merges every 10 min), Taiga every 60 s, werkrevisor every 2 min; cached data is served at once while a due source refreshes in the background.