System HUD
Friday knows things. Background tasks are running. Scheduled tasks are queued. There's a reminder about a dentist appointment somewhere in conversation history – maybe. To find any of it, you ask. One question at a time, one system at a time.
"What background tasks are running?" That's one command. "What's scheduled next?" That's another. "Did I set a reminder for that thing?" Good luck – if it was said in conversation and the context window has turned over, it's gone.
Background tasks live in their own table. Scheduled tasks live in another. Ad-hoc reminders don't exist at all – if you want to remember something, you mention it in conversation and hope for the best. If a background task finishes while you're away, the result goes to Telegram and scrolls past. If a scheduled task is about to fire, you'd only know if you remembered to check.
The Aggregator Pattern
Background tasks and scheduled tasks already have their own tables. We need something that pulls from both and renders one view. So we build a HudAggregator – a class that takes both stores, fetches from each independently, and returns a single snapshot. Each fetch gets its own try/catch. If one throws, it returns an empty array with an error flag while the other renders normally.
The class doesn't know how to fetch anything itself. It takes both stores as constructor arguments and calls each one:
async aggregate(): Promise<HudSnapshot> {
const [entries, scheduled, background] = await Promise.allSettled([
this.hudStore.findActive(),
this.scheduledStore.findUpcoming(this.limit),
this.backgroundStore.findActive(),
]);
return {
entries: entries.status === 'fulfilled' ? entries.value : [],
scheduled: scheduled.status === 'fulfilled' ? scheduled.value : [],
background: background.status === 'fulfilled' ? background.value : [],
errors: [entries, scheduled, background]
.filter((r) => r.status === 'rejected')
.map((r) => r.reason),
};
}
This is from
src/hud/aggregator.ts
Promise.allSettled is the key. Promise.all would fail the whole snapshot if one source threw. allSettled lets each source succeed or fail independently, and we sort out the results afterwards.
The scheduled task limit defaults to five. You don't need to see all forty-seven scheduled tasks at a glance – you need the next few. The limit is configurable in src/config.ts under hud.scheduled-task-limit, but five has been the right number in practice.
HUD Entries as First-Class Data
The aggregator pulls from existing systems for scheduled tasks and background tasks. But reminders and alerts didn't have a home. That's what HUD entries are – a new table for persistent, typed, short-lived data that needs to be visible.
Each entry has a title, an optional body, an optional due date, and a type field. The type is a free-form string, not an enum. The initial set – reminder, alert, deadline, note, background-task – emerged from use. New types can appear without schema changes. Conventions grow from practice, not from up-front taxonomy.
Dismissing an entry sets dismissed_at rather than deleting the row. History is preserved. A separate clear() method hard-deletes dismissed entries as a housekeeping step. The "dismiss" action should feel lightweight and reversible – you're hiding something from the active view, not destroying it.
The API is four routes on localhost:
POST /hud/entries– create an entryGET /hud/entries– list active entriesPATCH /hud/entries/:id– update an entryDELETE /hud/entries/:id– dismiss (soft-delete)
Nothing fancy. Standard CRUD over HTTP, same patterns as the rest of Friday's internal API. The interesting part isn't the routes themselves – it's who calls them.
Background Task Integration
Background tasks run in their own ACP sessions with no direct database access. When a task wants to write to the HUD, it POSTs to localhost:{PORT}/hud/entries like any other HTTP client. If the POST fails, it logs and moves on – a failed HUD write must never crash the notifier.
Where results go is controlled by the post_to field, which names a channel. Channels are composable – telegram:chris sends to one person, telegram:all fans out to everyone, and hud writes an entry to the dashboard. New channels can be defined without changing the background task system.
A health check that reports "all good" every five minutes doesn't need a Telegram notification. Posting to hud keeps it visible without generating noise.
Two Surfaces
The aggregator has data. Now it needs a screen. The web page is an inline template string – dark theme, minimal CSS, and a <meta http-equiv="refresh" content="30"> tag. It reloads every thirty seconds. Crude – it flashes, it doesn't preserve scroll position – but it can't break.
The Telegram view is the same data in a different format. Send /hud and you get pinned entries with due dates, the next five scheduled tasks, and active background tasks grouped by status.
Both surfaces call aggregator.aggregate() and format the result differently. Adding a third would follow the same pattern.
Conclusion
One command or one browser tab gives you everything that used to take three separate queries.
The feature works exactly as designed. And I barely use it.
I don't open dashboards. I ask questions. The HUD is built for glancing, but my workflow is conversational. The web page refreshes every thirty seconds to an audience of nobody.
The aggregation and the entry system are solid foundations. What's missing is the push – surfacing entries proactively in conversation instead of waiting for me to come looking. It's a good system. I just haven't gotten in the habit of using it yet.