feat(desktop): extend kanban native notifications to blocker/failure events

Builds on @nductien's completion-notify module (PR #87705):

- Notify on the gateway watcher's full terminal set — blocked, gave_up,
  crashed, timed_out, block_loop_detected — not just completed. A worker
  hitting a blocker while the user is away was the original community ask.
- Route all notification copy through the kanban plugin i18n bundles
  (en/ja/zh/zh-hant), with an English-bundle fallback when the translator
  isn't bound yet.
- Wire the ctx.os.notify door so events also fire a NATIVE OS notification
  while the user is away from the Hermes window (host.notify toast covers
  the foreground). OS-door failures are isolated from the toast path.
- Docs: Desktop notifications section in kanban.md, including the
  app-running coverage window.
This commit is contained in:
Teknium
2026-08-17 16:51:12 -07:00
parent 1802620847
commit 9aa1413781
7 changed files with 305 additions and 28 deletions

View File

@@ -9,7 +9,7 @@
* desktop's selection never flips the server-wide current-board pointer.
*/
import { atom, type PluginRestOptions, type PluginStorage, queryClient } from '@hermes/plugin-sdk'
import { atom, type PluginOs, type PluginRestOptions, type PluginStorage, type PluginTranslate, queryClient } from '@hermes/plugin-sdk'
// Native completion notification.
import { bindCompletionNotify, type CompletionEvent, onKanbanEventsFrame } from './completion-notify'
@@ -85,9 +85,9 @@ interface Persisted<T> {
* runs on unload/disable — so nothing (store sync, socket) survives a toggle
* or duplicates on re-enable. The events socket is pinned to a board at
* handshake, so a board switch closes + reopens it. */
export function bindApi(r: Rest, storage: PluginStorage, socket: Socket): () => void {
export function bindApi(r: Rest, storage: PluginStorage, socket: Socket, notifyDoors?: { os?: PluginOs; t?: PluginTranslate }): () => void {
rest = r
bindCompletionNotify(r)
bindCompletionNotify(r, notifyDoors?.t, notifyDoors?.os)
const unsubs: Array<() => void> = []
// Hydrate an atom from storage and keep storage in sync with it.

View File

@@ -15,16 +15,23 @@ import type { CompletionEvent } from './completion-notify'
/** Mirrors the module's public surface (no `typeof import()` — repo eslint
* bans import() in type annotations). */
type Rest = <T>(path: string, opts?: PluginRestOptions) => Promise<T>
type Translate = (key: string, ...args: unknown[]) => string
interface OsDoor { notify: (input: { title: string; body?: string; silent?: boolean }) => void }
interface Mod {
bindCompletionNotify: (r: Rest) => void
onKanbanEventsFrame: (slug: string, events?: CompletionEvent[]) => Promise<boolean>
bindCompletionNotify(r: Rest, t?: Translate, os?: OsDoor): void
onKanbanEventsFrame(slug: string, events?: CompletionEvent[]): Promise<boolean>
}
const { hostMock } = vi.hoisted(() => ({
hostMock: { notify: vi.fn(), navigate: vi.fn() },
}))
vi.mock('@hermes/plugin-sdk', () => ({ host: hostMock }))
vi.mock('@hermes/plugin-sdk', () => ({
host: hostMock,
// Pulled in transitively via ./i18n (the module reads its `en` bundle for
// fallback titles); never called in these tests.
usePluginI18n: () => (key: string) => key,
}))
type NotifyInput = { message: string; title?: string; kind?: string; detail?: string; action?: { label: string; onClick: () => void } }
const lastNotify = (): NotifyInput => hostMock.notify.mock.calls[hostMock.notify.mock.calls.length - 1][0] as NotifyInput
@@ -376,3 +383,130 @@ describe('notification failure isolation', () => {
expect(lastNotify().message).toBe('t103')
})
})
describe('terminal kinds beyond completed', () => {
it('blocked notifies with the payload reason and a warning toast', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
const fired = await m.onKanbanEventsFrame('smoke', [ev(101, 'blocked', { reason: 'needs API key' })])
expect(fired).toBe(true)
expect(lastNotify()).toMatchObject({
kind: 'warning',
title: 'Task blocked — needs your input',
message: 'needs API key',
detail: 't101',
})
})
it('block_loop_detected notifies (routed-to-triage handoff)', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
const fired = await m.onKanbanEventsFrame('smoke', [ev(101, 'block_loop_detected', { reason: 'same cause 3x' })])
expect(fired).toBe(true)
expect(lastNotify()).toMatchObject({ kind: 'warning', message: 'same cause 3x' })
})
it('gave_up carries the payload error; crashed and timed_out fall back to the task id', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
await m.onKanbanEventsFrame('smoke', [ev(101, 'gave_up', { error: 'spawn failed' })])
expect(lastNotify()).toMatchObject({ kind: 'error', message: 'spawn failed' })
await m.onKanbanEventsFrame('smoke', [ev(102, 'crashed'), ev(103, 'timed_out', { limit_seconds: 900 })])
expect(hostMock.notify).toHaveBeenCalledTimes(3)
expect(hostMock.notify.mock.calls[1][0]).toMatchObject({ kind: 'error', message: 't102' })
expect(hostMock.notify.mock.calls[2][0]).toMatchObject({ kind: 'warning', message: 't103' })
})
it('silent kinds (status/archived/unblocked) advance the cursor but never notify', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
await m.onKanbanEventsFrame('smoke', [ev(101, 'status', { status: 'running' }), ev(102, 'archived'), ev(103, 'unblocked')])
expect(hostMock.notify).not.toHaveBeenCalled()
// Cursor moved past 103: a replayed blocked at 102 stays silent, 104 fires.
await m.onKanbanEventsFrame('smoke', [ev(102, 'blocked'), ev(104, 'blocked')])
expect(hostMock.notify).toHaveBeenCalledTimes(1)
expect(lastNotify().message).toBe('t104')
})
})
describe('native OS door', () => {
it('forwards title and body through the bound ctx.os door', async () => {
const os = { notify: vi.fn() }
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never, undefined, os)
await m.onKanbanEventsFrame('smoke', [ev(101, 'blocked', { reason: 'needs input' })])
expect(os.notify).toHaveBeenCalledTimes(1)
expect(os.notify.mock.calls[0][0]).toEqual({
title: 'Task blocked — needs your input',
body: 'needs input\nt101',
})
})
it('an os door that throws never breaks the toast or the frame result', async () => {
const os = { notify: vi.fn(() => { throw new Error('no shell') }) }
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never, undefined, os)
// The OS-door throw is isolated: the toast fires and the event still
// counts as notified.
await expect(m.onKanbanEventsFrame('smoke', [ev(101, 'completed')])).resolves.toBe(true)
expect(hostMock.notify).toHaveBeenCalledTimes(1)
const fired = await m.onKanbanEventsFrame('smoke', [ev(102, 'completed')])
expect(fired).toBe(true)
expect(hostMock.notify).toHaveBeenCalledTimes(2)
})
it('no os door bound: toast-only, no crash', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
const fired = await m.onKanbanEventsFrame('smoke', [ev(101, 'blocked')])
expect(fired).toBe(true)
expect(hostMock.notify).toHaveBeenCalledTimes(1)
})
})
describe('i18n routing', () => {
it('uses the bound plugin translator when it resolves the key', async () => {
const t = vi.fn((key: string, ...args: unknown[]) => {
if (key === 'notify.completedTitle') {return 'タスク完了'}
if (key === 'notify.openKanban') {return 'かんばんを開く'}
if (key === 'notify.artifacts') {return `成果物 ${args[0]} 件`}
return key
})
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never, t)
await m.onKanbanEventsFrame('smoke', [ev(101, 'completed', { summary: 'Done', artifacts: ['/a/1.md', '/b/2.md'] })])
expect(lastNotify()).toMatchObject({
title: 'タスク完了',
detail: 't101 · 成果物 2 件',
action: { label: 'かんばんを開く', onClick: expect.any(Function) },
})
})
it('falls back to the English bundle when the translator echoes the key', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never, ((key: string) => key) as never)
await m.onKanbanEventsFrame('smoke', [ev(101, 'timed_out')])
expect(lastNotify().title).toBe('Task timed out — will retry')
})
})

View File

@@ -1,24 +1,35 @@
/**
* Native kanban task-completion notification.
* Native kanban terminal-event notification (completion, blocker, failure).
*
* No maintained exact-fit OSS exists and the SDK
* has no kanban event door, so this module rides the kanban plugin's EXISTING
* /events socket (api.ts onEventsFrame). No new WebSocket, no new process,
* no DB, no auth, no persistence — cursor is an in-memory per-board high-water
* mark. Reuses native completion finality: kind == 'completed' events written
* by kanban_db.complete_task (payload: summary + artifacts).
* mark. Notifies on the same terminal kinds the gateway watcher pings
* (gateway/kanban_watchers.py): 'completed' (kanban_db.complete_task —
* payload: summary + artifacts), 'blocked' (payload: reason), 'gave_up'
* (payload: error), 'crashed', 'timed_out', and 'block_loop_detected'
* (payload: reason — the routed-to-triage human handoff).
*
* Two delivery doors, complementary by design:
* - `host.notify` — the in-app toast, covers the foreground case;
* - `ctx.os.notify` (when bound) — the native OS notification, which the
* desktop shell fires only while the user is AWAY from Hermes. This is the
* door that covers "walked away and the worker hit a blocker".
*
* Cursor contract: first observation of a board baselines
* seen[board] = GET /board latest_event_id (MAX task_events.id for that
* board). Events id <= seen are historical/replay — never notified, no
* cursor change. id > seen advances cursor for EVERY kind; only 'completed'
* emits. Reconnect replays from 0; cursor filters. Board switch never mixes
* cursors; returning reuses prior cursor (never reset to current MAX).
* cursor change. id > seen advances cursor for EVERY kind; only terminal
* kinds emit. Reconnect replays from 0; cursor filters. Board switch never
* mixes cursors; returning reuses prior cursor (never reset to current MAX).
* Fail-closed: while a board's baseline is unknown, no event can be
* classified so none is notified. Empty slug ('') suppressed.
*/
import { host, type PluginRestOptions } from '@hermes/plugin-sdk'
import { host, type PluginOs, type PluginRestOptions, type PluginTranslate } from '@hermes/plugin-sdk'
import { en } from './i18n'
type Rest = <T>(path: string, opts?: PluginRestOptions) => Promise<T>
@@ -29,13 +40,55 @@ export interface CompletionEvent {
payload?: Record<string, unknown> | null
}
type ToastKind = 'error' | 'success' | 'warning'
/** Terminal kinds → toast severity + i18n title key. Mirrors the gateway
* watcher's ping set (gateway/kanban_watchers.py) minus the intentionally
* silent kinds (status/archived/unblocked, which only advance the cursor). */
const TERMINAL_NOTIFY = new Map<string, { titleKey: string; toast: ToastKind }>([
['blocked', { titleKey: 'notify.blockedTitle', toast: 'warning' }],
['block_loop_detected', { titleKey: 'notify.blockLoopTitle', toast: 'warning' }],
['completed', { titleKey: 'notify.completedTitle', toast: 'success' }],
['crashed', { titleKey: 'notify.crashedTitle', toast: 'error' }],
['gave_up', { titleKey: 'notify.gaveUpTitle', toast: 'error' }],
['timed_out', { titleKey: 'notify.timedOutTitle', toast: 'warning' }]
])
const seenEventIdByBoard = new Map<string, number>()
const baselinePending = new Set<string>()
let rest: Rest | null = null
let translate: PluginTranslate | null = null
let osDoor: PluginOs | null = null
export function bindCompletionNotify(r: Rest): void {
/** Resolve a dot-path against the plugin's own English bundle — the same
* last-rung fallback the plugin i18n registry applies, usable before (or
* without) a bound translator. */
function fallbackT(key: string, ...args: unknown[]): string {
let node: unknown = en
for (const part of key.split('.')) {
node = (node as Record<string, unknown> | undefined)?.[part]
}
if (typeof node === 'function') {
return (node as (...a: unknown[]) => string)(...args)
}
return typeof node === 'string' ? node : key
}
function t(key: string, ...args: unknown[]): string {
const translated = translate?.(key, ...args)
// The registry returns the raw key when the bundle isn't registered yet.
return translated && translated !== key ? translated : fallbackT(key, ...args)
}
export function bindCompletionNotify(r: Rest, pluginTranslate?: PluginTranslate, os?: PluginOs): void {
rest = r
translate = pluginTranslate ?? null
osDoor = os ?? null
}
async function ensureBaseline(slug: string): Promise<void> {
@@ -52,20 +105,51 @@ async function ensureBaseline(slug: string): Promise<void> {
}
}
function notifyOne(ev: CompletionEvent): void {
const taskId = (ev.task_id ?? '').trim()
const summary = typeof ev.payload?.summary === 'string' ? ev.payload.summary.trim() : ''
function trimmed(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
const artifacts = Array.isArray(ev.payload?.artifacts)
/** The human handoff carried in the event payload, per kind (mirrors the
* payload contract the gateway watcher reads). */
function bodyFor(kind: string, ev: CompletionEvent): string {
const payload = ev.payload
if (kind === 'completed') {
return trimmed(payload?.summary)
}
if (kind === 'blocked' || kind === 'block_loop_detected') {
return trimmed(payload?.reason)
}
if (kind === 'gave_up') {
return trimmed(payload?.error)
}
return ''
}
function notifyOne(kind: string, spec: { titleKey: string; toast: ToastKind }, ev: CompletionEvent): void {
const taskId = (ev.task_id ?? '').trim()
const body = bodyFor(kind, ev)
const artifacts = kind === 'completed' && Array.isArray(ev.payload?.artifacts)
? (ev.payload!.artifacts as unknown[]).filter((a): a is string => typeof a === 'string' && a.trim().length > 0).map(a => a.trim())
: []
const artifactText = artifacts.length === 1 ? (artifacts[0].split(/[\\/]/).pop() || artifacts[0]) : artifacts.length > 1 ? `${artifacts.length} artifacts` : ''
const artifactText = artifacts.length === 1 ? (artifacts[0].split(/[\\/]/).pop() || artifacts[0]) : artifacts.length > 1 ? t('notify.artifacts', artifacts.length) : ''
const detail = [taskId, artifactText].filter(Boolean).join(' · ')
host.notify({ kind: 'success', title: 'Task completed', message: summary || taskId || 'Task completed', ...(detail ? { detail } : {}), action: { label: 'Open Kanban', onClick: () => host.navigate('/kanban') } })
const title = t(spec.titleKey)
const message = body || taskId || title
host.notify({ kind: spec.toast, title, message, ...(detail ? { detail } : {}), action: { label: t('notify.openKanban'), onClick: () => host.navigate('/kanban') } })
// Native OS notification — the desktop shell fires it only while the user
// is away from Hermes (the toast above covers the foreground case). Isolated:
// a missing/broken shell must not mark the toast as unfired.
try { osDoor?.notify({ title, body: [message, detail].filter(Boolean).join('\n') }) } catch { /* swallowed */ }
}
/** Consume one /events frame for a board. Returns true when a completion
/** Consume one /events frame for a board. Returns true when a terminal-event
* notification was fired. Never throws: notification failure cannot
* interfere with api.ts cache invalidation. */
export async function onKanbanEventsFrame(slug: string, events?: CompletionEvent[]): Promise<boolean> {
@@ -81,8 +165,9 @@ export async function onKanbanEventsFrame(slug: string, events?: CompletionEvent
if (typeof ev.id !== 'number' || ev.id <= cursor) { continue }
cursor = ev.id
seenEventIdByBoard.set(slug, cursor)
const spec = TERMINAL_NOTIFY.get(ev.kind ?? '')
if (ev.kind === 'completed') { try { notifyOne(ev); fired = true } catch { /* swallowed */ } }
if (spec) { try { notifyOne(ev.kind!, spec, ev); fired = true } catch { /* swallowed */ } }
}
return fired

View File

@@ -185,9 +185,20 @@ type KanbanMessages = {
profileDescriptionsHint: string
profileGoodAt: string
auto: string
// native/toast notifications for terminal worker events (completion-notify)
notify: {
completedTitle: string
blockedTitle: string
blockLoopTitle: string
gaveUpTitle: string
crashedTitle: string
timedOutTitle: string
openKanban: string
artifacts: (n: number) => string
}
}
const en: KanbanMessages = {
export const en: KanbanMessages = {
nav: 'Kanban',
openBoard: 'Kanban: Open board',
newTaskCommand: 'Kanban: New task',
@@ -376,7 +387,17 @@ const en: KanbanMessages = {
profileDescriptionsHint:
'Descriptions guide the decomposer’s routing. Auto-generate with the auxiliary model, or write your own.',
profileGoodAt: 'What is this profile good at?',
auto: 'Auto'
auto: 'Auto',
notify: {
completedTitle: 'Task completed',
blockedTitle: 'Task blocked — needs your input',
blockLoopTitle: 'Task routed to triage — needs a decision',
gaveUpTitle: 'Task gave up',
crashedTitle: 'Worker crashed — will retry',
timedOutTitle: 'Task timed out — will retry',
openKanban: 'Open Kanban',
artifacts: (n: number) => `${n} artifacts`
}
}
const ja: KanbanMessages = {
@@ -567,7 +588,17 @@ const ja: KanbanMessages = {
profileDescriptionsHint:
'説明はデコンポーザーのルーティングを導きます。補助モデルで自動生成するか、自分で書いてください。',
profileGoodAt: 'このプロフィールの得意分野は?',
auto: '自動'
auto: '自動',
notify: {
completedTitle: 'タスク完了',
blockedTitle: 'タスクがブロック中 — 入力が必要です',
blockLoopTitle: 'タスクをトリアージへ移動 — 判断が必要です',
gaveUpTitle: 'タスクを断念しました',
crashedTitle: 'ワーカーがクラッシュ — 再試行します',
timedOutTitle: 'タスクがタイムアウト — 再試行します',
openKanban: 'かんばんを開く',
artifacts: (n: number) => `成果物 ${n} 件`
}
}
const zh: KanbanMessages = {
@@ -755,7 +786,17 @@ const zh: KanbanMessages = {
profileDescriptions: '配置档说明',
profileDescriptionsHint: '说明用于引导分解器的路由。可用辅助模型自动生成,或自行填写。',
profileGoodAt: '这个配置档擅长什么?',
auto: '自动'
auto: '自动',
notify: {
completedTitle: '任务已完成',
blockedTitle: '任务受阻 — 需要你的输入',
blockLoopTitle: '任务已转入分类 — 需要人工决定',
gaveUpTitle: '任务已放弃',
crashedTitle: '工作单元崩溃 — 将重试',
timedOutTitle: '任务超时 — 将重试',
openKanban: '打开看板',
artifacts: (n: number) => `${n} 个产物`
}
}
const zhHant: KanbanMessages = {
@@ -943,7 +984,17 @@ const zhHant: KanbanMessages = {
profileDescriptions: '設定檔說明',
profileDescriptionsHint: '說明用於引導分解器的路由。可用輔助模型自動產生,或自行填寫。',
profileGoodAt: '這個設定檔擅長什麼?',
auto: '自動'
auto: '自動',
notify: {
completedTitle: '任務已完成',
blockedTitle: '任務受阻 — 需要你的輸入',
blockLoopTitle: '任務已轉入分類 — 需要人工決定',
gaveUpTitle: '任務已放棄',
crashedTitle: '工作單元當機 — 將重試',
timedOutTitle: '任務逾時 — 將重試',
openKanban: '開啟看板',
artifacts: (n: number) => `${n} 個產物`
}
}
/** Registered via `ctx.i18n.register` at plugin load (disposer tracked). */

View File

@@ -84,7 +84,7 @@ const plugin: HermesPlugin = {
defaultEnabled: false,
register(ctx) {
ctx.i18n.register(KANBAN_LOCALES)
ctx.onDispose(bindApi(ctx.rest, ctx.storage, ctx.socket))
ctx.onDispose(bindApi(ctx.rest, ctx.storage, ctx.socket, { os: ctx.os, t: ctx.i18n.t }))
// The plugin command pattern: ONE action id (`kanban.newTask`) wired into
// two areas — a keybind (dispatch + rebindable panel row) and a palette row

View File

@@ -0,0 +1 @@
nductien

View File

@@ -1000,6 +1000,12 @@ hermes kanban create "monthly report" \
Workers receive `$HERMES_TENANT` and namespace their memory writes by prefix. The board, the dispatcher, and the profile definitions are all shared; only the data is scoped.
## Desktop notifications
The Desktop app's Kanban plugin surfaces the same terminal events natively — no gateway platform required. While the Kanban board's live event socket is connected, each `completed`, `blocked`, `gave_up`, `crashed`, `timed_out`, or routed-to-triage (`block_loop_detected`) event raises an in-app toast with the worker's handoff (summary, block reason, or error) and an "Open Kanban" action. When you're away from the Hermes window, the same event also fires a native OS notification (gated by **Settings ▸ Notifications ▸ Plugin notifications**), so a task hitting a blocker while you're in another app still reaches you.
Coverage window: desktop notifications ride the live event stream, so they fire only while the app is running with the Kanban plugin enabled. Events that land while the app is closed are not replayed as notifications on next launch — use a gateway subscription (below) for delivery that must survive the app being closed.
## Gateway notifications
When you run `/kanban create …` from the gateway (Telegram, Discord, Slack, etc.), the originating chat is automatically subscribed to the new task. The gateway's background notifier polls `task_events` every few seconds and delivers one message per terminal event (`completed`, `blocked`, `gave_up`, `crashed`, `timed_out`) to that chat. Completed tasks also send the first line of the worker's `--result` so you see the outcome without having to `/kanban show`.