diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index b4e5ede05e..1d65727df3 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -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 { * 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. diff --git a/apps/desktop/src/plugins/kanban/completion-notify.test.ts b/apps/desktop/src/plugins/kanban/completion-notify.test.ts index 6378f06bbd..ee1252d497 100644 --- a/apps/desktop/src/plugins/kanban/completion-notify.test.ts +++ b/apps/desktop/src/plugins/kanban/completion-notify.test.ts @@ -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 = (path: string, opts?: PluginRestOptions) => Promise +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 + bindCompletionNotify(r: Rest, t?: Translate, os?: OsDoor): void + onKanbanEventsFrame(slug: string, events?: CompletionEvent[]): Promise } 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') + }) +}) diff --git a/apps/desktop/src/plugins/kanban/completion-notify.ts b/apps/desktop/src/plugins/kanban/completion-notify.ts index ab40220c64..efdba3f47b 100644 --- a/apps/desktop/src/plugins/kanban/completion-notify.ts +++ b/apps/desktop/src/plugins/kanban/completion-notify.ts @@ -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 = (path: string, opts?: PluginRestOptions) => Promise @@ -29,13 +40,55 @@ export interface CompletionEvent { payload?: Record | 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([ + ['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() const baselinePending = new Set() 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 | 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 { @@ -52,20 +105,51 @@ async function ensureBaseline(slug: string): Promise { } } -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 { @@ -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 diff --git a/apps/desktop/src/plugins/kanban/i18n.ts b/apps/desktop/src/plugins/kanban/i18n.ts index 34a64fccbb..76836755d1 100644 --- a/apps/desktop/src/plugins/kanban/i18n.ts +++ b/apps/desktop/src/plugins/kanban/i18n.ts @@ -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). */ diff --git a/apps/desktop/src/plugins/kanban/plugin.tsx b/apps/desktop/src/plugins/kanban/plugin.tsx index 605f58ad3c..e4131a359e 100644 --- a/apps/desktop/src/plugins/kanban/plugin.tsx +++ b/apps/desktop/src/plugins/kanban/plugin.tsx @@ -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 diff --git a/contributors/emails/nguyentien01634@gmail.com b/contributors/emails/nguyentien01634@gmail.com new file mode 100644 index 0000000000..c7e2e05767 --- /dev/null +++ b/contributors/emails/nguyentien01634@gmail.com @@ -0,0 +1 @@ +nductien diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index b978f97c91..ebb1c26d65 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -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`.