Merge pull request #61173 from NousResearch/bb/desktop-kanban

feat(desktop): Kanban — the founding plugin on the desktop SDK
This commit is contained in:
brooklyn! 2026-07-31 13:00:10 -05:00 committed by GitHub
commit 0324849fe4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 6334 additions and 12 deletions

View file

@ -67,7 +67,7 @@ import {
closeCommandPalette,
setCommandPaletteOpen
} from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { $bindings, bindingsFor } from '@/store/keybinds'
import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout'
import { openPetGenerate } from '@/store/pet-generate'
import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects'
@ -328,7 +328,9 @@ const PaletteRow = memo(function PaletteRow({
const Icon = item.icon
// The row's live keybind, else a static modifier-variant hint (⌘↵). One slot,
// so every downstream `ml-auto` fallback below keeps working unchanged.
const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint
// `bindingsFor`, not a raw lookup: a plugin's action is contributed after
// $bindings was seeded, so its combo only resolves through the fallback chain.
const combo = (item.action ? bindingsFor(item.action, bindings)[0] : undefined) ?? item.comboHint
// While ⌘/⌃ is held, a row with a modifier variant previews it: the label
// swaps to the variant's copy so Enter reads as what it will actually do.
const modPreview = modHeld && Boolean(item.modLabel)

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { createPluginContext } from './plugin'
describe('createPluginContext.onDispose', () => {
it('collects arbitrary cleanups so the host runs them on deactivate', () => {
const disposers: Array<() => void> = []
const ctx = createPluginContext('demo', dispose => disposers.push(dispose))
let cleaned = false
ctx.onDispose(() => {
cleaned = true
})
// The cleanup is tracked alongside contribution/socket disposers, so the
// loader's deactivate (which runs every collected disposer) tears it down.
expect(disposers).toHaveLength(1)
disposers.forEach(dispose => dispose())
expect(cleaned).toBe(true)
})
})

View file

@ -40,6 +40,10 @@ export interface PluginContext {
register: (c: PluginContribution) => () => void
/** Register several at once; the returned disposer removes all of them. */
registerMany: (cs: PluginContribution[]) => () => void
/** Register an arbitrary cleanup to run on unload/disable for side effects
* that aren't contributions or sockets (store subscriptions, timers). Runs
* alongside every other disposer when the plugin deactivates. */
onDispose: (fn: () => void) => void
/** REST to this plugin's own backend namespace (`/api/plugins/<id>`); `path`
* is relative ('/board'). The sanctioned door for a plugin that ships a
* `plugin_api.py` profile-aware, namespace-scoped by construction. Use
@ -108,6 +112,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
source,
register: c => track(registry.register(scope(c))),
registerMany: cs => track(registry.registerMany(cs.map(scope))),
onDispose: fn => void track(fn),
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
storage: createPluginStorage(pluginId),

View file

@ -0,0 +1,77 @@
import { type MouseEvent as ReactMouseEvent, type RefObject, useState } from 'react'
// Grab-to-pan for overflow containers — the shared primitive behind "scrub the
// board/timeline by dragging its background" (kanban lanes, trace waterfalls,
// wide tables). Sibling of lib/trackpad-gestures.ts: that file classifies
// wheel gestures, this one owns pointer-drag panning, so surfaces stop
// re-deriving the same interaction (the dashboard kanban and the agent-traces
// waterfall each hand-rolled a copy).
//
// Behavior contract:
// - drags translate scrollLeft/scrollTop (both axes, whichever overflow);
// - interactive targets never start a pan (buttons, inputs, links,
// [draggable] cards keep their own drag semantics);
// - the native scrollbar gutters stay untouched as the fallback affordance;
// - selection can't start mid-pan (preventDefault on move), and window
// blur/mouseup always end it.
const BLOCKED_TARGETS = 'button,input,textarea,select,a,[role="button"],[draggable="true"]'
const SCROLLBAR_GUTTER_PX = 16
export interface GrabScroll {
/** True while a pan is in flight — drive `cursor-grabbing` styling. */
grabbing: boolean
/** Spread onto the scroll container. */
onMouseDown: (event: ReactMouseEvent) => void
}
export function useGrabScroll(ref: RefObject<HTMLElement | null>): GrabScroll {
const [grabbing, setGrabbing] = useState(false)
const onMouseDown = (event: ReactMouseEvent) => {
const el = ref.current
if (event.button !== 0 || !el) {
return
}
const canX = el.scrollWidth > el.clientWidth
const canY = el.scrollHeight > el.clientHeight
if ((!canX && !canY) || (event.target as HTMLElement).closest(BLOCKED_TARGETS)) {
return
}
const rect = el.getBoundingClientRect()
if (
(canX && event.clientY >= rect.bottom - SCROLLBAR_GUTTER_PX) ||
(canY && event.clientX >= rect.right - SCROLLBAR_GUTTER_PX)
) {
return
}
const start = { left: el.scrollLeft, top: el.scrollTop, x: event.clientX, y: event.clientY }
setGrabbing(true)
const onMove = (move: MouseEvent) => {
el.scrollLeft = start.left - (move.clientX - start.x)
el.scrollTop = start.top - (move.clientY - start.y)
move.preventDefault()
}
const stop = () => {
setGrabbing(false)
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', stop)
window.removeEventListener('blur', stop)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', stop, { once: true })
window.addEventListener('blur', stop, { once: true })
event.preventDefault()
}
return { grabbing, onMouseDown }
}

View file

@ -2953,5 +2953,5 @@ export const en: Translations = {
description: 'Displays the mobile sidebar.',
toggle: open => `${open ? 'Show' : 'Hide'} sidebar`
}
}
},
}

View file

@ -2798,5 +2798,5 @@ export const ja = defineLocale({
description: 'モバイルサイドバーを表示します。',
toggle: open => `サイドバーを${open ? '表示' : '非表示'}`
}
}
},
})

View file

@ -2684,5 +2684,5 @@ export const zhHant = defineLocale({
description: '顯示行動裝置側邊欄。',
toggle: open => `${open ? '顯示' : '隱藏'}側邊欄`
}
}
},
})

View file

@ -3114,5 +3114,5 @@ export const zh: Translations = {
description: '显示移动端侧边栏。',
toggle: open => `${open ? '显示' : '隐藏'}侧边栏`
}
}
},
}

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { createPluginContext } from '@/contrib/plugin'
import { registry } from '@/contrib/registry'
import { allKeybindActions, contributedKeybindHandler, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { bindingsFor } from '@/store/keybinds'
// The plugin-command contract: a plugin ships a hotkey through the `keybinds`
// area and it behaves like a built-in — it dispatches, it resolves a combo for
// the palette hint, and it survives the plugin being unloaded. These assert the
// relationship between the pieces, not the specific chord any one plugin picks.
describe('contributed keybind actions', () => {
it('dispatches, resolves its default combo, and disappears on unload', () => {
const ctx = createPluginContext('demo')
let ran = 0
const dispose = ctx.register({
id: 'new-thing',
area: KEYBINDS_AREA,
data: {
id: 'demo.newThing',
category: 'view',
defaults: ['mod+alt+n'],
label: 'Demo: New thing',
run: () => void (ran += 1)
}
})
// Dispatch path: use-keybinds looks the handler up by action id.
contributedKeybindHandler('demo.newThing')?.()
expect(ran).toBe(1)
// Hint path: $bindings was seeded before this action existed, so only the
// resolver (default fallback) finds the combo — a raw store lookup can't.
expect(bindingsFor('demo.newThing')).toEqual(['mod+alt+n'])
// Panel path: it shows up as a rebindable row alongside the built-ins.
expect(allKeybindActions().find(a => a.id === 'demo.newThing')?.label).toBe('Demo: New thing')
dispose()
expect(contributedKeybindHandler('demo.newThing')).toBeUndefined()
expect(allKeybindActions().some(a => a.id === 'demo.newThing')).toBe(false)
})
it('cannot shadow a built-in action id', () => {
const ctx = createPluginContext('demo')
const dispose = ctx.register({
id: 'steal-new-session',
area: KEYBINDS_AREA,
data: { id: 'session.new', defaults: ['mod+alt+n'], label: 'Demo: hijack', run: () => undefined }
})
// The built-in keeps its own combo and its own (i18n) label — the
// contribution is filtered out rather than overriding core.
expect(bindingsFor('session.new')).toEqual(['mod+n', 'shift+n'])
expect(allKeybindActions().filter(a => a.id === 'session.new')).toHaveLength(1)
dispose()
})
it('leaves no registry residue between plugin loads', () => {
expect(registry.getArea(KEYBINDS_AREA).filter(c => c.source === 'plugin:demo')).toHaveLength(0)
})
})

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { $bindings } from '@/store/keybinds'
import { $registryVersion } from '@/contrib/registry'
import { $bindings, bindingsFor } from '@/store/keybinds'
import { KEYBIND_READONLY } from './actions'
import { formatCombo } from './combo'
@ -12,7 +13,14 @@ import { formatCombo } from './combo'
export function useKeybindHint(actionId: string): string | null {
const bindings = useStore($bindings)
const rebindable = bindings[actionId]?.[0]
// `bindingsFor`, not a raw `bindings[id]`: $bindings is seeded at module init
// from the actions known THEN, so a plugin action contributed later isn't in
// it and a raw lookup renders no hint at all. The resolver falls through to
// the stored override and the action's own defaults. Subscribing to the
// registry version repaints the hint when that late registration lands.
useStore($registryVersion)
const rebindable = bindingsFor(actionId, bindings)[0]
if (rebindable) {
return formatCombo(rebindable)

View file

@ -0,0 +1,121 @@
/**
* Example plugin the authoring + publishing reference. A folder under
* `src/plugins/` with a `plugin.tsx` that default-exports a `HermesPlugin` is
* all it takes; `discoverBundledPlugins()` finds and registers it (no import,
* no registry edit). Delete this folder and everything below is gone.
*
* The ONLY import surface is `@hermes/plugin-sdk` (lint-enforced) the
* vscode-module model. This one plugin dogfoods the whole authoring kit:
* - `render()` contribution full stateful React in a statusbar slot;
* - `ctx.storage` the count survives reloads (namespaced persistence);
* - `host.onEvent('*')` live gateway stream, counted in the tooltip;
* - PALETTE + KEYBINDS contracts "Example: Reset click counter" in K
* and as a rebindable (default-unbound) action in the keybind panel;
* - plugin-local `atom` + `useValue` module state, leaf subscription;
* - `haptic` / `host.notify` / `Tip` / `cn` the design language.
*/
import {
atom,
cn,
haptic,
type HermesPlugin,
host,
type KeybindContribution,
KEYBINDS_AREA,
PALETTE_AREA,
type PaletteContribution,
STATUSBAR_AREAS,
Tip,
useValue
} from '@hermes/plugin-sdk'
const $clicks = atom(0)
const $events = atom(0)
function ClickCounter() {
const count = useValue($clicks)
const events = useValue($events)
const gateway = useValue(host.state.gateway)
return (
<Tip label={`Example plugin — gateway ${gateway}, ${events} events heard`}>
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground',
count > 0 && 'text-foreground'
)}
onClick={() => {
haptic('tap')
// Imperative read in the handler ($atom.get()), reactive read in the
// render (useValue) — never a stale closure.
const next = $clicks.get() + 1
$clicks.set(next)
if (next % 10 === 0) {
host.notify({ kind: 'success', message: `Example plugin: ${next} clicks!` })
}
}}
type="button"
>
<span aria-hidden></span>
<span>{count === 0 ? 'click me' : `clicked ${count}×`}</span>
</button>
</Tip>
)
}
const plugin: HermesPlugin = {
id: 'example',
name: 'Example Plugin',
register(ctx) {
// Persisted count: hydrate once, write through on every change.
$clicks.set(ctx.storage.get('clicks', 0))
$clicks.listen(clicks => ctx.storage.set('clicks', clicks))
// Hear the live gateway stream (deltas, lifecycle, tools — everything).
host.onEvent('*', () => $events.set($events.get() + 1))
const reset = () => {
$clicks.set(0)
host.notify({ kind: 'info', message: 'Example plugin: counter reset' })
}
// Provenance (source: 'plugin:example') and the namespaced registry ids
// (example:counter, …) are stamped by ctx — authors write plain
// contributions. The shared `example.reset` ACTION id links the palette
// row's hotkey hint to the keybind panel's live binding.
ctx.registerMany([
{
id: 'counter',
area: STATUSBAR_AREAS.right,
order: 100,
render: () => <ClickCounter />
},
{
id: 'reset',
area: PALETTE_AREA,
data: {
id: 'example.reset',
action: 'example.reset',
label: 'Example: Reset click counter',
keywords: ['example', 'plugin', 'counter'],
run: reset
} satisfies PaletteContribution
},
{
id: 'reset',
area: KEYBINDS_AREA,
data: {
id: 'example.reset',
label: 'Example: Reset click counter',
defaults: [],
run: reset
} satisfies KeybindContribution
}
])
}
}
export default plugin

View file

@ -0,0 +1,375 @@
/**
* Gateway pill the core statusbar gateway-health item implemented 1:1 as a
* plugin: same trigger chrome (declarative `variant: 'menu'` StatusbarItem
* the app's own portal/popover plumbing, so nothing clips), same menu panel
* (connection/inference rows, restart, reason, RECENT ACTIVITY tail,
* messaging platforms), same copy (`useI18n`), same readiness logic
* (`evaluateRuntimeReadiness` over `host.request`). The point: a plugin can
* rebuild a REAL core feature through the SDK alone only
* `@hermes/plugin-sdk` + react (lint-fenced).
*
* Pattern notes:
* - a module-level `atom` shares the readiness poll between the live label
* elements and the menu panel (the same primitive `host.state` uses);
* - label/detail/icon of a DATA item are ReactNodes, so they can be tiny
* components that subscribe a static item shape with live innards.
*/
import {
atom,
Button,
cn,
evaluateRuntimeReadiness,
type HermesPlugin,
host,
icons,
LogView,
type RuntimeReadinessResult,
type StatusbarItem,
StatusDot,
type StatusResponse,
type StatusTone,
Tip,
useI18n,
useValue
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useRef, useState } from 'react'
const READINESS_POLL_MS = 15_000
const LOG_TAIL = 120
const LOG_VISIBLE = 40
const LOG_POLL_MS = 3_000
// Per-connection WebSocket churn (accept/close/heartbeat) drowns out anything
// useful — strip it so the tail reads as real gateway activity at a glance.
const LOG_NOISE_RE = /\bws (?:accepted|closed|response sent|ping|pong)\b/i
// Strip leading "YYYY-MM-DD HH:MM:SS,mmm " and "[runtime_id] " prefixes.
const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[,.\d]*\s+/
const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/
const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '')
const PLATFORM_TONE: Record<string, StatusTone> = {
connected: 'good',
connecting: 'warn',
retrying: 'warn',
pending_restart: 'warn',
startup_failed: 'bad',
fatal: 'bad'
}
const prettyState = (state: string) => state.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase())
const SYSTEM_PANEL_ROUTE = '/command-center?section=system'
// ---------------------------------------------------------------------------
// Readiness poll — one loop at plugin scope, shared by label + panel.
// ---------------------------------------------------------------------------
const $readiness = atom<null | RuntimeReadinessResult>(null)
function startReadinessPoll() {
let timer: null | number = null
const stop = () => {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
$readiness.set(null)
}
const refresh = () =>
evaluateRuntimeReadiness(host.request)
.then(next => $readiness.set(next))
.catch(() => undefined)
const sync = (gateway: string) => {
if (gateway !== 'open') {
stop()
return
}
if (timer === null) {
void refresh()
timer = window.setInterval(() => void refresh(), READINESS_POLL_MS)
}
}
sync(host.state.gateway.get())
host.state.gateway.listen(sync)
}
// ---------------------------------------------------------------------------
// Live trigger innards (the item is static DATA; these subscribe).
// ---------------------------------------------------------------------------
function useHealth() {
const gateway = useValue(host.state.gateway)
const readiness = useValue($readiness)
return {
connecting: gateway === 'connecting',
open: gateway === 'open',
readiness,
ready: gateway === 'open' && readiness?.ready === true
}
}
function PillIcon() {
const { connecting, open, ready } = useHealth()
return (
<span
className={cn(
'inline-flex',
ready ? 'text-(--ui-text-tertiary)' : open || connecting ? 'text-amber-500' : 'text-red-400'
)}
>
{ready ? <icons.Activity className="size-3" /> : <icons.AlertCircle className="size-3" />}
</span>
)
}
function PillDetail() {
const { t } = useI18n()
const copy = t.shell.statusbar
const { connecting, open, readiness, ready } = useHealth()
const detail = open
? ready
? copy.gatewayReady
: readiness
? copy.gatewayNeedsSetup
: copy.gatewayChecking
: connecting
? copy.gatewayConnecting
: copy.gatewayOffline
return <>{detail}</>
}
// ---------------------------------------------------------------------------
// The menu panel — the real GatewayMenuPanel, rebuilt on SDK doors.
// ---------------------------------------------------------------------------
/** Live gui-log tail while the popover is mounted (i.e. open). */
function useGatewayLogTail(): string[] {
const [lines, setLines] = useState<string[]>([])
useEffect(() => {
let cancelled = false
const load = () =>
host
.logs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (!cancelled) {
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
}
})
.catch(() => undefined)
void load()
const timer = window.setInterval(load, LOG_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [])
return lines
}
function Section({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('border-t border-border/50 px-3 py-2', className)}>{children}</div>
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>
)
}
function GatewayMenuPanel({ onClose }: { onClose: () => void }) {
const { t } = useI18n()
const copy = t.shell.gatewayMenu
const gateway = useValue(host.state.gateway)
const { readiness, ready } = useHealth()
const [snapshot, setSnapshot] = useState<null | StatusResponse>(null)
const recentLogs = useGatewayLogTail()
useEffect(() => {
void host
.status()
.then(setSnapshot)
.catch(() => undefined)
}, [])
const openSystem = () => {
onClose()
host.navigate(SYSTEM_PANEL_ROUTE)
}
const restart = () => {
onClose()
void host.restartGateway().catch(() => undefined)
}
const gatewayOpen = gateway === 'open'
const gatewayConnecting = gateway === 'connecting'
const connectionLabel = gatewayOpen
? copy.connected
: gatewayConnecting
? copy.connecting
: prettyState(gateway || copy.offline)
const inferenceLabel = gatewayOpen
? readiness?.ready
? copy.inferenceReady
: readiness
? copy.inferenceNotReady
: copy.checkingInference
: copy.disconnected
const platforms = Object.entries(snapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
// Keep the tail pinned to the latest line as it streams.
const logScrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = logScrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
}
}, [recentLogs])
return (
<div className="text-sm">
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex min-w-0 flex-col gap-1 text-[0.7rem] leading-none">
<span className="flex items-center gap-1.5 font-medium">
<StatusDot tone={gatewayOpen ? 'good' : gatewayConnecting ? 'warn' : 'bad'} />
{connectionLabel}
</span>
<span className="flex items-center gap-1.5 text-muted-foreground">
<StatusDot tone={ready ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
{inferenceLabel}
</span>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Tip label={t.commandCenter.restartGateway}>
<Button
aria-label={t.commandCenter.restartGateway}
className="text-muted-foreground hover:text-foreground"
onClick={restart}
size="icon-xs"
variant="ghost"
>
<icons.RefreshCw />
</Button>
</Tip>
<Tip label={copy.openSystem}>
<Button
aria-label={copy.openSystem}
className="text-muted-foreground hover:text-foreground"
onClick={openSystem}
size="icon-xs"
variant="ghost"
>
<icons.LayoutDashboard />
</Button>
</Tip>
</div>
</div>
{readiness?.reason && (
<Section className="text-xs text-muted-foreground">
<div className="line-clamp-3">{readiness.reason}</div>
</Section>
)}
{recentLogs.length > 0 && (
<Section>
<div className="flex items-center justify-between gap-2">
<SectionLabel>{copy.recentActivity}</SectionLabel>
<Button
className="-mr-2 h-auto py-0 font-medium leading-none text-muted-foreground"
onClick={openSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</Button>
</div>
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
{recentLogs.map(trimLogLine).join('\n')}
</LogView>
</Section>
)}
{platforms.length > 0 && (
<Section>
<SectionLabel>{copy.messagingPlatforms}</SectionLabel>
<ul className="mt-1.5 space-y-1">
{platforms.map(([name, platform]) => (
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
<span className="truncate capitalize">{name}</span>
<span className="flex items-center gap-1.5 text-[0.66rem] text-muted-foreground">
<StatusDot tone={PLATFORM_TONE[platform.state] || 'muted'} />
{prettyState(platform.state)}
</span>
</li>
))}
</ul>
</Section>
)}
</div>
)
}
function PillLabel() {
const { t } = useI18n()
return <>{t.shell.statusbar.gateway}</>
}
// ---------------------------------------------------------------------------
const plugin: HermesPlugin = {
id: 'gateway-pill',
name: 'Gateway Pill',
register(ctx) {
startReadinessPoll()
// Declarative menu item — the app's own trigger/popover chrome renders it
// (portal, w-72, side=top), the plugin supplies live innards + the panel.
ctx.register({
id: 'pill',
area: 'statusBar.right',
order: 90,
data: {
icon: <PillIcon />,
id: 'gateway-pill',
label: <PillLabel />,
detail: <PillDetail />,
menuClassName: 'w-72',
menuContent: (close: () => void) => <GatewayMenuPanel onClose={close} />,
variant: 'menu'
} satisfies StatusbarItem
})
}
}
export default plugin

View file

@ -0,0 +1,38 @@
/**
* Runtime-loaded example this file is NOT bundled as a module: it ships as
* raw text (`?raw`) and goes through the real runtime pipeline (specifier
* rewrite -> SDK/react shim blobs -> blob import -> register). Plain ESM js
* with `jsx()` calls exactly what an agent (or a compiler) writes into
* `~/.hermes/desktop-plugins/<name>/plugin.js`.
*/
import { cn, host, Tip, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
function RuntimeChip() {
const gateway = useValue(host.state.gateway)
return jsx(Tip, {
label: `Loaded at RUNTIME through blob import + SDK injection (gateway: ${gateway})`,
children: jsxs('span', {
className: cn(
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem]',
'text-(--ui-text-tertiary)'
),
children: [jsx('span', { 'aria-hidden': true, children: '⚡' }), jsx('span', { children: 'runtime' })]
})
})
}
export default {
id: 'hello-runtime',
name: 'Hello Runtime',
register(ctx) {
ctx.register({
id: 'chip',
area: 'statusBar.right',
order: 110,
render: () => jsx(RuntimeChip, {})
})
}
}

View file

@ -0,0 +1,253 @@
/**
* Kanban data layer. Everything goes through `ctx.rest` the plugin's own
* `/api/plugins/kanban/*` FastAPI router (`plugins/kanban/dashboard/plugin_api.py`),
* reused as-is via the desktop's namespace-scoped REST door. No new backend.
*
* Fetching, caching, polling, dedupe, and invalidation are React Query's job
* (the app's standard, via the SDK). This module owns the query keys, the REST
* calls, and the selected-board atom every call passes `?board=<slug>` so the
* desktop's selection never flips the server-wide current-board pointer.
*/
import { atom, type PluginRestOptions, type PluginStorage, queryClient } from '@hermes/plugin-sdk'
import type {
BoardMeta,
BoardsResponse,
KanbanBoard,
KanbanProfile,
KanbanProject,
KanbanTask,
KanbanTaskDetail,
OrchestrationSettings,
TaskEstimate,
WorkerLog
} from './types'
type Rest = <T>(path: string, opts?: PluginRestOptions) => Promise<T>
type Socket = (path: string, onMessage: (data: unknown) => void) => () => void
let rest: null | Rest = null
/** Selected board slug ('' = the server's current board). Persisted. */
export const $boardSlug = atom<string>('')
/** Whether the "how this board works" intro was dismissed. Persisted. */
export const $introDismissed = atom<boolean>(false)
/** Sub-group the Running lane by assignee (the dashboard's "lanes by
* profile"). Persisted. */
export const $lanesByProfile = atom<boolean>(false)
/** Per-lane collapse OVERRIDES (true=collapsed, false=expanded). Absence means
* auto: empty lanes collapse to a rail, occupied lanes expand. Persisted. */
export const $collapsedLanes = atom<Record<string, boolean>>({})
const BOARD_SLUG_KEY = 'boardSlug'
const INTRO_KEY = 'introDismissed'
const LANES_KEY = 'lanesByProfile'
const COLLAPSED_KEY = 'collapsedLanes'
/** One live `task_events` frame precise cache invalidation: the board, plus
* each touched task's detail. The polls (8s board / 4s drawer) stay as the
* fallback the socket just makes the board feel instant. */
function onEventsFrame(slug: string, data: unknown): void {
const events = (data as { events?: Array<{ task_id?: string }> })?.events
if (!events?.length) {
return
}
void queryClient.invalidateQueries({ queryKey: ['kanban', 'board'] })
// Any event can change a board's card count — keep the switcher badge honest.
void queryClient.invalidateQueries({ queryKey: BOARDS_KEY })
for (const taskId of new Set(events.map(event => event.task_id).filter(Boolean))) {
void queryClient.invalidateQueries({ queryKey: taskKey(slug, taskId!) })
}
}
// A persisted, subscribable atom (the structural slice we need — avoids
// importing nanostore's type just to describe one).
interface Persisted<T> {
get(): T
set(value: T): void
listen(cb: (value: T) => void): () => void
}
/** Bind the plugin's doors at register time and return a disposer the host
* 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 {
rest = r
const unsubs: Array<() => void> = []
// Hydrate an atom from storage and keep storage in sync with it.
const persist = <T>(atom: Persisted<T>, key: string, fallback: T) => {
atom.set(storage.get(key, fallback))
unsubs.push(atom.listen(value => storage.set(key, value)))
}
persist($boardSlug, BOARD_SLUG_KEY, '')
persist($introDismissed, INTRO_KEY, false)
persist($lanesByProfile, LANES_KEY, false)
persist($collapsedLanes, COLLAPSED_KEY, {})
let close: (() => void) | null = null
const open = (slug: string) => {
close?.()
close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data))
}
open($boardSlug.get())
unsubs.push($boardSlug.listen(open))
return () => {
unsubs.forEach(unsub => unsub())
close?.()
rest = null
}
}
function call<T>(path: string, opts?: PluginRestOptions): Promise<T> {
return rest ? rest<T>(path, opts) : Promise.reject(new Error('kanban api not ready'))
}
/** Append the selected board (and other params) to a path. */
function withBoard(path: string, params: Record<string, string> = {}): string {
const search = new URLSearchParams(params)
const slug = $boardSlug.get()
if (slug) {
search.set('board', slug)
}
const qs = search.toString()
return qs ? `${path}?${qs}` : path
}
// ── query keys (all board-scoped so switching boards is a clean cache miss) ──
export const boardKey = (slug: string, archived: boolean) => ['kanban', 'board', slug, archived] as const
export const taskKey = (slug: string, id: string) => ['kanban', 'task', slug, id] as const
export const logKey = (slug: string, id: string) => ['kanban', 'log', slug, id] as const
export const BOARDS_KEY = ['kanban', 'boards'] as const
export const PROFILES_KEY = ['kanban', 'profiles'] as const
export const PROJECTS_KEY = ['kanban', 'projects'] as const
export const ORCHESTRATION_KEY = ['kanban', 'orchestration'] as const
// ── reads ─────────────────────────────────────────────────────────────────────
export const fetchBoard = (archived: boolean) =>
call<KanbanBoard>(withBoard('/board', archived ? { include_archived: 'true' } : {}))
export const fetchTask = (id: string) => call<KanbanTaskDetail>(withBoard(`/tasks/${id}`))
/** Worker stdout/stderr tail (last 16 KiB — plenty for the drawer). */
export const fetchLog = (id: string) => call<WorkerLog>(withBoard(`/tasks/${id}/log`, { tail: '16384' }))
export const fetchBoards = () => call<BoardsResponse>('/boards')
export const fetchProfiles = () => call<{ profiles: KanbanProfile[] }>('/profiles')
/** First-class Hermes projects, for scoping a board's default workspace. */
export const fetchProjects = () => call<{ projects: KanbanProject[] }>('/projects')
export const fetchOrchestration = () => call<OrchestrationSettings>('/orchestration')
// ── writes ────────────────────────────────────────────────────────────────────
// Every board edit nudges the dispatcher (debounced, fire-and-forget) so the
// change takes effect NOW instead of on the next 60s tick — create a ready
// task and the worker spawns immediately, no manual "nudge" ritual. The tick
// is lock-guarded and ~1ms when there's nothing to do, so over-nudging is
// free; failures are non-events (the periodic tick still exists).
let nudgeTimer: null | ReturnType<typeof setTimeout> = null
function autoNudge(): void {
if (nudgeTimer != null) {
clearTimeout(nudgeTimer)
}
nudgeTimer = setTimeout(() => {
nudgeTimer = null
nudgeDispatcher().catch(() => undefined)
}, 400)
}
/** Resolve the write, then kick the dispatcher. Rejections pass through. */
function nudged<T>(write: Promise<T>): Promise<T> {
return write.then(value => {
autoNudge()
return value
})
}
export const patchTask = (id: string, patch: Record<string, unknown>) =>
nudged(call(withBoard(`/tasks/${id}`), { method: 'PATCH', body: patch }))
export const createTask = (body: Record<string, unknown>) =>
nudged(call<{ task: KanbanTask | null; warning?: string }>(withBoard('/tasks'), { method: 'POST', body }))
// Deleting can unblock dependants (a gone parent no longer gates), so it
// nudges too.
export const deleteTask = (id: string) => nudged(call(withBoard(`/tasks/${id}`), { method: 'DELETE' }))
/** One patch, many ids independent per-id application; returns per-id
* outcomes so the UI can toast partial failures. */
export const bulkTasks = (ids: string[], patch: Record<string, unknown>) =>
nudged(
call<{ results: Array<{ id: string; ok: boolean; error?: string }> }>(withBoard('/tasks/bulk'), {
method: 'POST',
body: { ids, ...patch }
})
)
export const addComment = (id: string, body: string) =>
call(withBoard(`/tasks/${id}/comments`), { method: 'POST', body: { author: 'desktop', body } })
export const reassignTask = (id: string, profile: string) =>
nudged(call(withBoard(`/tasks/${id}/reassign`), { method: 'POST', body: { profile, reclaim_first: true } }))
export const reclaimTask = (id: string) => nudged(call(withBoard(`/tasks/${id}/reclaim`), { method: 'POST', body: {} }))
export const uploadAttachment = (id: string, upload: { filename: string; contentType?: string; bytes: ArrayBuffer }) =>
call(withBoard(`/tasks/${id}/attachments`), { method: 'POST', upload })
export const createBoard = (slug: string, name: string, projectId?: string) =>
call<{ board: { slug: string } }>('/boards', {
method: 'POST',
body: { slug, name, ...(projectId ? { project_id: projectId } : {}) }
})
/** Rough auxiliary-model estimate for a task (tokens + complexity). Makes a
* model call gate behind an explicit user action + disclaimer. */
export const estimateTask = (id: string) =>
call<TaskEstimate>(withBoard(`/tasks/${id}/estimate`), { method: 'POST', body: {} })
/** Estimate from typed title/body before a task exists (create dialog). */
export const estimateNew = (title: string, body: string) =>
call<TaskEstimate>('/estimate', { method: 'POST', body: { title, body: body || undefined } })
/** Edit a board's display metadata + default project directory. Pass
* `default_workdir: ''` to clear it. Slug is immutable. */
export const updateBoard = (slug: string, patch: Record<string, unknown>) =>
call<{ board: BoardMeta }>(`/boards/${encodeURIComponent(slug)}`, { method: 'PATCH', body: patch })
export const nudgeDispatcher = () => call<{ spawned?: unknown[] }>(withBoard('/dispatch'), { method: 'POST', body: {} })
export const saveOrchestration = (patch: Record<string, unknown>) =>
call<OrchestrationSettings>('/orchestration', { method: 'PUT', body: patch })
export const saveProfileDescription = (name: string, description: string) =>
call(`/profiles/${encodeURIComponent(name)}`, { method: 'PATCH', body: { description } })
export const autoDescribeProfile = (name: string) =>
call<{ ok: boolean; reason?: null | string; description?: null | string }>(
`/profiles/${encodeURIComponent(name)}/describe-auto`,
{ method: 'POST', body: { overwrite: true } }
)

View file

@ -0,0 +1,243 @@
/**
* Titlebar board switcher the board page projects this into `titleBar.center`
* (where chat shows the session-title dropdown) via `<Contribute>`, so it
* exists exactly while the page is mounted no route sniffing. Same chrome as
* the session title: quiet label + chevron, menu on click.
*/
import {
Button,
Codicon,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
host,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
useMutation,
useQuery,
useQueryClient,
useValue
} from '@hermes/plugin-sdk'
import { useEffect, useState } from 'react'
import { $boardSlug, BOARDS_KEY, createBoard, fetchBoards, fetchProjects, PROJECTS_KEY, updateBoard } from './api'
import type { BoardMeta } from './types'
import { errText, FIELD_LABEL, useKanban } from './ui'
const NO_PROJECT = '__none__'
/** Board scope = a first-class Hermes project. Its primary repo becomes the
* board's default workspace root; new tasks inherit it as a worktree with a
* deterministic branch. "No project" falls back to scratch sandboxes. */
function ProjectPicker({ onChange, value }: { onChange: (id: string) => void; value: string }) {
const k = useKanban()
const { data } = useQuery({ queryKey: PROJECTS_KEY, queryFn: fetchProjects, staleTime: 30_000 })
const projects = data?.projects ?? []
return (
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>{k.project}</span>
<Select onValueChange={id => onChange(id === NO_PROJECT ? '' : id)} value={value || NO_PROJECT}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PROJECT}>{k.noProject}</SelectItem>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">
{k.projectHintPre}
<span className="font-mono">{k.projectHintCmd}</span>.
</span>
</label>
)
}
function NewBoardDialog({ onClose, open }: { onClose: () => void; open: boolean }) {
const k = useKanban()
const qc = useQueryClient()
const [name, setName] = useState('')
const [project, setProject] = useState('')
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
useEffect(() => {
if (open) {
setName('')
setProject('')
}
}, [open])
const create = useMutation({
mutationFn: () => createBoard(slug, name.trim(), project || undefined),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: result => {
$boardSlug.set(result.board.slug)
void qc.invalidateQueries({ queryKey: BOARDS_KEY })
onClose()
}
})
return (
<Dialog onOpenChange={o => !o && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{k.newBoard}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>{k.name}</span>
<Input
autoFocus
onChange={event => setName(event.target.value)}
onKeyDown={event => event.key === 'Enter' && slug && !project && create.mutate()}
placeholder={k.boardNamePlaceholder}
value={name}
/>
{slug && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">{k.slug(slug)}</span>}
</label>
<ProjectPicker onChange={setProject} value={project} />
</div>
<DialogFooter>
<Button onClick={onClose} variant="text">
{k.cancel}
</Button>
<Button disabled={!slug || create.isPending} onClick={() => create.mutate()}>
{k.createBoard}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function BoardSettingsDialog({ board, onClose }: { board: BoardMeta | null; onClose: () => void }) {
const k = useKanban()
const qc = useQueryClient()
const [name, setName] = useState('')
const [project, setProject] = useState('')
useEffect(() => {
if (board) {
setName(board.name || '')
setProject(board.project_id || '')
}
}, [board])
const save = useMutation({
// Slug is immutable; send name + project_id ('' clears the scope, which
// also drops the mirrored default_workdir on the backend).
mutationFn: () => updateBoard(board!.slug, { name: name.trim(), project_id: project }),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: BOARDS_KEY })
onClose()
}
})
return (
<Dialog onOpenChange={o => !o && onClose()} open={Boolean(board)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{board ? k.boardSettingsFor(board.name || board.slug) : k.boardSettings}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1">
<span className={FIELD_LABEL}>{k.name}</span>
<Input onChange={event => setName(event.target.value)} placeholder={k.boardNamePlaceholder} value={name} />
{board && <span className="text-[0.6875rem] text-(--ui-text-quaternary)">{k.slug(board.slug)}</span>}
</label>
<ProjectPicker onChange={setProject} value={project} />
</div>
<DialogFooter>
<Button onClick={onClose} variant="text">
{k.cancel}
</Button>
<Button disabled={save.isPending} onClick={() => save.mutate()}>
{k.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function BoardSwitcher() {
const k = useKanban()
const slug = useValue($boardSlug)
const { data: boards } = useQuery({ queryFn: fetchBoards, queryKey: BOARDS_KEY, staleTime: 30_000 })
const [adding, setAdding] = useState(false)
const [settingsFor, setSettingsFor] = useState<BoardMeta | null>(null)
if (!boards) {
return null
}
const currentSlug = slug || boards.current
const current = boards.boards.find(meta => meta.slug === currentSlug)
const label = current?.name || current?.slug || k.board
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-7 max-w-56 gap-1.5 px-2" size="sm" variant="ghost">
<span className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{label}</span>
{typeof current?.total === 'number' && (
<span className="text-[0.6875rem] tabular-nums text-(--ui-text-quaternary)">{current.total}</span>
)}
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
{boards.boards.map(meta => (
<DropdownMenuItem
key={meta.slug}
onSelect={() => $boardSlug.set(meta.slug === boards.current ? '' : meta.slug)}
>
{meta.name || meta.slug}
{typeof meta.total === 'number' && (
<span className="text-[0.625rem] tabular-nums text-(--ui-text-quaternary)">{meta.total}</span>
)}
{meta.slug === currentSlug && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{current && (
<DropdownMenuItem onSelect={() => setSettingsFor(current)}>
<Codicon name="settings-gear" size="0.8rem" />
{k.boardSettings}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setAdding(true)}>
<Codicon name="add" size="0.8rem" />
{k.newBoardDots}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewBoardDialog onClose={() => setAdding(false)} open={adding} />
<BoardSettingsDialog board={settingsFor} onClose={() => setSettingsFor(null)} />
</>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,945 @@
/**
* Task drawer the desktop port of the dashboard's task detail, flat-styled:
* status menu + meta table, DIAGNOSTICS (the "why is this stuck" panel, with
* reassign recovery), description (editable), result/summary, dependencies,
* comments (+composer), activity, run history, and the worker log tail.
*/
import {
Badge,
Button,
cn,
Codicon,
compactNumber,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
ErrorState,
host,
Loader,
LogView,
Textarea,
Tip,
useMutation,
useQuery,
useQueryClient,
useValue
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import {
$boardSlug,
addComment,
deleteTask,
estimateTask,
fetchLog,
fetchProfiles,
fetchTask,
logKey,
patchTask,
PROFILES_KEY,
reassignTask,
reclaimTask,
taskKey,
uploadAttachment
} from './api'
import {
type Diagnostic,
type DiagnosticAction,
type KanbanAttachment,
type KanbanEvent,
type KanbanTaskDetail,
SEVERITY_TONE,
type TaskEstimate
} from './types'
import {
ago,
Avatar,
Callout,
columnLabel,
duration,
errText,
isLockedTarget,
type KanbanText,
lockedReason,
ScrollFade,
Section,
shortId,
StatusMenu,
useDefaultAssignee,
useKanban
} from './ui'
/**
* Turn a task_events row into an operator-readable line. The backend logs
* machine payloads ("status" + {"status":"ready"}); rendering the raw kind
* made the feed useless ("status · 2 sec. ago" after a drag). Known kinds get
* prose with the payload folded in; unknown kinds fall back to kind + compact
* key=value detail so new backend events still say something.
*/
function eventText(event: KanbanEvent, k: KanbanText): { detail?: string; label: string } {
let p: Record<string, unknown> = {}
if (typeof event.payload === 'string' && event.payload) {
try {
p = JSON.parse(event.payload) as Record<string, unknown>
} catch {
return { label: event.kind.replace(/_/g, ' '), detail: event.payload }
}
} else if (event.payload && typeof event.payload === 'object') {
p = event.payload as Record<string, unknown>
}
const str = (key: string): null | string => {
const value = p[key]
return typeof value === 'string' && value ? value : null
}
const col = (key: string) => {
const value = str(key)
return value ? columnLabel(k, value) : null
}
switch (event.kind) {
case 'created':
return { label: k.evtCreated(col('status') ?? '', str('assignee') ?? '') }
case 'status': {
const reason = str('reason')
return {
label: k.evtMovedTo(col('status') ?? '?'),
detail: reason === 'parent_reopened' ? k.evtParentReopened(str('parent') ?? '') : (reason ?? undefined)
}
}
case 'assigned': {
const assignee = str('assignee')
return { label: assignee ? k.evtAssignedTo(assignee) : k.evtUnassigned }
}
case 'commented':
return { label: k.evtCommentBy(str('author') ?? k.someone) }
case 'claimed':
return { label: str('source_status') === 'review' ? k.evtClaimedReview : k.evtClaimedWorker }
case 'spawned':
return { label: k.evtWorkerStarted, detail: p.pid != null ? `pid ${p.pid}` : undefined }
case 'completed':
return { label: k.evtCompleted }
case 'blocked':
return { label: k.evtBlocked, detail: str('reason') ?? undefined }
case 'unblocked':
return { label: k.evtUnblocked(col('status') ?? '') }
case 'reclaimed':
return { label: k.evtReclaimed, detail: str('reason') ?? undefined }
case 'specified':
return { label: k.evtSpecified }
case 'promoted':
return { label: k.evtPromoted }
case 'scheduled':
return { label: k.evtScheduled, detail: str('reason') ?? undefined }
case 'archived':
return { label: k.evtArchived }
case 'reprioritized':
return { label: k.evtReprioritized(String(p.priority ?? '?')) }
default: {
const detail = Object.entries(p)
.filter(([, value]) => value != null && typeof value !== 'object')
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ')
return { label: event.kind.replace(/_/g, ' '), detail: detail || undefined }
}
}
}
function MetaRow({ children, label }: { children: ReactNode; label: string }) {
return (
<>
<span className="text-(--ui-text-quaternary)">{label}</span>
<span className="min-w-0 truncate text-(--ui-text-secondary)">{children}</span>
</>
)
}
/** The dashboard's diagnostics panel: severity-toned, plain-English, with the
* backend's structured recovery actions as buttons. `reassign` is skipped
* the Assignee control in the meta table IS that action, inline. */
function Diagnostics({ items, onReclaim }: { items: Diagnostic[]; onReclaim: () => void }) {
const k = useKanban()
const act = (action: DiagnosticAction) => {
if (action.kind === 'reclaim') {
onReclaim()
} else if (action.kind === 'cli_hint') {
void navigator.clipboard.writeText(String(action.payload?.command ?? action.label))
host.notify({ kind: 'info', message: k.commandCopied })
}
}
return (
<div className="flex flex-col gap-2">
{items.map(diag => {
const tone = SEVERITY_TONE[diag.severity]
const actions = diag.actions.filter(action => action.kind === 'reclaim' || action.kind === 'cli_hint')
return (
<Callout
key={`${diag.kind}-${diag.last_seen_at}`}
title={`${diag.title}${diag.count > 1 ? ` ×${diag.count}` : ''}`}
tone={tone}
>
<p className="whitespace-pre-wrap text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">
{diag.detail}
</p>
{actions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{actions.map(action => (
<Button
key={`${action.kind}-${action.label}`}
onClick={() => act(action)}
size="xs"
variant={action.suggested ? 'secondary' : 'outline'}
>
{action.kind === 'cli_hint' && <Codicon name="copy" size="0.7rem" />}
{action.label}
</Button>
))}
</div>
)}
</Callout>
)
})}
</div>
)
}
/** Jira-style inline assignee editor: the meta row IS the control click the
* assignee to reassign (reclaims a running worker first, resets the failure
* streak the explicit human recovery action). */
function AssigneeMenu({
current,
onReassign
}: {
current: null | string | undefined
onReassign: (p: string) => void
}) {
const k = useKanban()
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="-mx-1 inline-flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-(--chrome-action-hover)"
type="button"
>
{current ? (
<>
<Avatar name={current} size="0.875rem" />
<span className="truncate">{current}</span>
</>
) : (
<span className="text-(--ui-text-quaternary)">{k.unassigned}</span>
)}
<Codicon className="shrink-0 text-(--ui-text-quaternary)" name="chevron-down" size="0.65rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(roster?.profiles ?? []).map(profile => (
<DropdownMenuItem key={profile.name} onSelect={() => onReassign(profile.name)}>
<Avatar name={profile.name} size="0.875rem" />
{profile.name}
{profile.name === current && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
// Mirrors the review pane's commit-message field: one row tall to start
// (button-height), CSS field-sizing grows it with content, button hugs the
// bottom edge as it grows.
//
// On a RUNNING task the worker polls its comment thread and folds new notes
// into the live turn (OUT-OF-BAND steer), so a plain note reaches the agent
// mid-run within a few seconds — no block/unblock dance. `onRequeue` is the
// heavier option: post the note AND reclaim so the task restarts from scratch
// with the note in context (use when the current run has gone off the rails).
function CommentComposer({
onRequeue,
onSubmit,
pending,
running
}: {
onRequeue?: (body: string) => void
onSubmit: (body: string) => void
pending: boolean
running?: boolean
}) {
const k = useKanban()
const [body, setBody] = useState('')
const submit = () => {
const trimmed = body.trim()
if (trimmed && !pending) {
onSubmit(trimmed)
setBody('')
}
}
const requeue = () => {
const trimmed = body.trim()
if (trimmed && !pending && onRequeue) {
onRequeue(trimmed)
setBody('')
}
}
return (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Textarea
className={cn('field-sizing-content max-h-40 min-h-0 resize-none', running ? 'pr-[3.5rem]' : 'pr-[5rem]')}
onChange={event => setBody(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}}
placeholder={running ? k.messageWorker : k.addComment}
rows={1}
size="sm"
value={body}
/>
<Button
className="absolute top-1 right-1"
disabled={!body.trim() || pending}
onClick={submit}
size="xs"
variant="secondary"
>
{running ? k.send : k.comment}
</Button>
</div>
{running && onRequeue && (
<div className="flex items-center justify-between gap-2">
<span className="text-[0.625rem] leading-tight text-(--ui-text-quaternary)">{k.deliveredLive}</span>
<Button className="shrink-0" disabled={!body.trim() || pending} onClick={requeue} size="xs" variant="outline">
<Codicon name="debug-restart" size="0.7rem" />
{k.requeueWithNote}
</Button>
</div>
)}
</div>
)
}
function DescriptionSection({ body, onSave }: { body: null | string | undefined; onSave: (body: string) => void }) {
const k = useKanban()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
return (
<Section
action={
<Button
aria-label={editing ? k.cancelEdit : k.editDescription}
onClick={() => {
setDraft(body ?? '')
setEditing(!editing)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={editing ? 'close' : 'edit'} size="0.75rem" />
</Button>
}
label={k.description}
>
{editing ? (
<div className="flex flex-col gap-1.5">
<Textarea
className="min-h-24 text-[0.75rem]"
onChange={event => setDraft(event.target.value)}
value={draft}
/>
<Button
className="self-end"
onClick={() => {
onSave(draft)
setEditing(false)
}}
size="xs"
variant="secondary"
>
{k.save}
</Button>
</div>
) : body ? (
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{body}</p>
) : (
<p className="text-[0.8125rem] text-(--ui-text-quaternary)">{k.noDescription}</p>
)}
</Section>
)
}
// `latest_summary` is just the newest non-null run summary. A reclaim writes an
// administrative note into that slot; hide those (Runs still shows them).
const isAdminSummary = (summary: string) => /^status changed to \w+ \(dashboard\/direct\)$/.test(summary)
function AttachmentsSection({
attachments,
onUpload,
pending
}: {
attachments: KanbanAttachment[]
onUpload: (file: File) => void
pending: boolean
}) {
const k = useKanban()
const fileRef = useRef<HTMLInputElement>(null)
return (
<Section
action={
<>
<input
hidden
onChange={event => {
const file = event.target.files?.[0]
if (file) {
onUpload(file)
}
event.target.value = ''
}}
ref={fileRef}
type="file"
/>
<Button
aria-label={k.uploadAttachment}
disabled={pending}
onClick={() => fileRef.current?.click()}
size="icon-xs"
variant="ghost"
>
<Codicon name={pending ? 'sync' : 'cloud-upload'} size="0.8rem" spinning={pending} />
</Button>
</>
}
label={k.attachments(attachments.length)}
>
{attachments.length > 0 ? (
<ul className="flex flex-col gap-1">
{attachments.map(attachment => (
<li className="flex items-center gap-1.5 text-[0.75rem] text-(--ui-text-tertiary)" key={attachment.id}>
<Codicon name="file" size="0.75rem" />
{attachment.filename}
</li>
))}
</ul>
) : (
<p className="text-[0.75rem] text-(--ui-text-quaternary)">{k.noAttachments}</p>
)}
</Section>
)
}
// Rough effort estimate via the auxiliary (auto-routed) model. Tokens +
// complexity, never dollars — providers don't report cost reliably. Gated
// behind an explicit click + disclaimer since it makes a model call. The
// control keeps a stable footprint (spinner swaps in place) so there's no
// layout jump when it runs.
function EstimateSection({ id }: { id: string }) {
const k = useKanban()
const [result, setResult] = useState<null | TaskEstimate>(null)
const est = useMutation({
mutationFn: () => estimateTask(id),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: r => {
if (r.ok) {
setResult(r)
} else {
host.notify({ kind: 'warning', message: r.reason || k.couldNotEstimate })
}
}
})
// A new task resets the cached estimate (the drawer reuses one instance).
useEffect(() => setResult(null), [id])
return (
<Section label={k.estimate}>
{result?.ok ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-[0.8125rem]">
<span className="font-medium tabular-nums text-(--ui-text-secondary)">
~{compactNumber(result.est_tokens)} {k.tokUnit}
</span>
{result.complexity && (
<span className="text-(--ui-text-tertiary)">· {k.complexity[result.complexity] ?? result.complexity}</span>
)}
<Tip label={k.reEstimate}>
<Button
aria-label={k.reEstimate}
className="ml-auto"
disabled={est.isPending}
onClick={() => est.mutate()}
size="icon-xs"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" spinning={est.isPending} />
</Button>
</Tip>
</div>
{result.rationale && (
<p className="text-[0.6875rem] leading-relaxed text-(--ui-text-quaternary)">{result.rationale}</p>
)}
</div>
) : (
<div className="flex items-center gap-2">
<Button disabled={est.isPending} onClick={() => est.mutate()} size="xs" variant="outline">
<Codicon name={est.isPending ? 'loading' : 'dashboard'} size="0.75rem" spinning={est.isPending} />
{est.isPending ? k.estimating : k.estimateEffort}
</Button>
<Tip label={k.estimateTipLong}>
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{k.makesModelCall}</span>
</Tip>
</div>
)}
</Section>
)
}
export function TaskDrawer({
columns,
id,
onClose,
onOpen
}: {
columns: string[]
id: null | string
onClose: () => void
onOpen: (id: string) => void
}) {
const k = useKanban()
const qc = useQueryClient()
const slug = useValue($boardSlug)
// Socket-invalidated (bindApi); the interval is only the socketless heartbeat.
const { data: detail, error } = useQuery({
enabled: !!id,
queryFn: () => fetchTask(id!),
queryKey: taskKey(slug, id ?? ''),
refetchInterval: 30_000
})
const task = detail?.task
const running = task?.status === 'running'
const defaultAssignee = useDefaultAssignee()
const { data: log } = useQuery({
enabled: !!id,
queryFn: () => fetchLog(id!),
queryKey: logKey(slug, id ?? ''),
refetchInterval: running ? 3_000 : 15_000
})
// Esc closes the drawer even though it isn't modal (no backdrop to click off).
useEffect(() => {
if (!id) {
return
}
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [id, onClose])
const invalidate = () => {
void qc.invalidateQueries({ queryKey: taskKey(slug, id!) })
void qc.invalidateQueries({ queryKey: ['kanban', 'board', slug] })
}
// Optimistic status change against the task cache; rolls back + toasts on a
// rejected transition (the backend enforces the workflow).
const moveMut = useMutation({
mutationFn: (status: string) => patchTask(id!, { status }),
onMutate: async status => {
await qc.cancelQueries({ queryKey: taskKey(slug, id!) })
const previous = qc.getQueryData<KanbanTaskDetail>(taskKey(slug, id!))
if (previous) {
qc.setQueryData(taskKey(slug, id!), { ...previous, task: { ...previous.task, status } })
}
return { previous }
},
onError: (err, _status, context) => {
if (context?.previous) {
qc.setQueryData(taskKey(slug, id!), context.previous)
}
host.notify({ kind: 'error', message: errText(err) })
},
onSettled: invalidate
})
const mutate = (fn: () => Promise<unknown>, onDone?: () => void) => () =>
fn().then(
() => {
invalidate()
onDone?.()
},
(err: unknown) => host.notify({ kind: 'error', message: errText(err) })
)
const commentMut = useMutation({
mutationFn: (body: string) => addComment(id!, body),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: invalidate
})
// "Note & requeue" for a running task: post the note, then reclaim so the
// dispatcher re-runs it with the note in the worker's context — the one-click
// replacement for the block → comment → unblock dance.
const requeueMut = useMutation({
mutationFn: async (body: string) => {
await addComment(id!, body)
await reclaimTask(id!)
},
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: () => {
host.notify({ kind: 'info', message: k.notePosted })
invalidate()
}
})
const uploadMut = useMutation({
mutationFn: async (file: File) =>
uploadAttachment(id!, {
bytes: await file.arrayBuffer(),
contentType: file.type || undefined,
filename: file.name
}),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: invalidate
})
if (!id) {
return null
}
const errorMessage = error ? errText(error) : null
const move = (status: string) => {
if (!task || status === task.status) {
return
}
if (isLockedTarget(status)) {
host.notify({ kind: 'info', message: lockedReason(k, status) })
return
}
moveMut.mutate(status)
}
return (
<div className="absolute inset-y-0 right-0 z-20 flex w-[26rem] flex-col border-l border-(--ui-stroke-tertiary) bg-(--ui-bg-elevated) duration-150 ease-out animate-in fade-in slide-in-from-right-4">
<header className="flex flex-col gap-2 px-4 pt-3.5 pb-3">
<div className="flex items-center gap-2">
{task ? (
<StatusMenu columns={columns} onMove={move} status={task.status} />
) : (
<span className="font-mono text-sm text-(--ui-text-tertiary)">{shortId(id)}</span>
)}
{task && (
<span className="font-mono text-[0.625rem] text-(--ui-text-quaternary)" data-selectable-text="true">
{shortId(task.id)}
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
{task && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={k.taskActions}
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
type="button"
>
<Codicon name="ellipsis" size="0.9rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.id)
host.notify({ kind: 'info', message: k.copiedId(task.id) })
}}
>
<Codicon name="copy" size="0.85rem" />
{k.copyTaskId}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(task.title || task.id)
host.notify({ kind: 'info', message: k.copiedTitle })
}}
>
<Codicon name="copy" size="0.85rem" />
{k.copyTitle}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={mutate(() => patchTask(task.id, { status: 'archived' }), onClose)}>
<Codicon name="archive" size="0.85rem" />
{k.archiveTask}
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onSelect={mutate(() => deleteTask(task.id), onClose)}>
<Codicon name="trash" size="0.85rem" />
{k.deleteTask}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
aria-label={k.close}
className="grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClose}
type="button"
>
<Codicon name="close" size="0.9rem" />
</button>
</div>
</div>
{task && (
<h2 className="text-sm leading-snug font-semibold text-foreground" data-selectable-text="true">
{task.title || task.id}
</h2>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4" data-selectable-text="true">
{errorMessage ? (
<ErrorState title={errorMessage} />
) : !detail || !task ? (
<div className="grid h-32 place-items-center">
<Loader type="lemniscate-bloom" />
</div>
) : (
<div className="flex flex-col gap-4 text-sm">
<div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-x-3 gap-y-1 text-[0.71rem]">
<MetaRow label={k.assignee}>
<AssigneeMenu
current={task.assignee}
onReassign={profile => void mutate(() => reassignTask(task.id, profile))()}
/>
</MetaRow>
{typeof task.priority === 'number' && <MetaRow label={k.metaPriority}>{task.priority}</MetaRow>}
{task.tenant && <MetaRow label={k.metaTenant}>{task.tenant}</MetaRow>}
{task.workspace_path && (
<MetaRow label={k.workspace}>
{task.workspace_kind ? `${task.workspace_kind}: ` : ''}
{task.workspace_path}
</MetaRow>
)}
{task.created_by && <MetaRow label={k.metaCreatedBy}>{task.created_by}</MetaRow>}
{ago(task.created_at) && <MetaRow label={k.metaCreated}>{ago(task.created_at)}</MetaRow>}
{running && task.worker_pid ? <MetaRow label={k.metaWorkerPid}>{task.worker_pid}</MetaRow> : null}
</div>
{task.status === 'ready' && !task.assignee && !defaultAssignee && (
<Callout title={k.readyUnassignedTitle} tone={SEVERITY_TONE.warning}>
<p className="text-[0.71rem] leading-relaxed text-(--ui-text-secondary)">{k.readyUnassignedBody}</p>
</Callout>
)}
{task.diagnostics && task.diagnostics.length > 0 && (
<Section label={k.diagnosticsN(task.diagnostics.length)}>
<Diagnostics items={task.diagnostics} onReclaim={() => void mutate(() => reclaimTask(task.id))()} />
</Section>
)}
<DescriptionSection body={task.body} onSave={body => void mutate(() => patchTask(task.id, { body }))()} />
<EstimateSection id={task.id} />
{task.result && (
<Section label={k.result}>
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.result}</p>
</Section>
)}
{task.latest_summary && !isAdminSummary(task.latest_summary) && (
<Section label={k.latestSummary}>
<p className="whitespace-pre-wrap text-[0.8125rem] text-(--ui-text-secondary)">{task.latest_summary}</p>
</Section>
)}
{(detail.links.parents.length > 0 || detail.links.children.length > 0) && (
<Section label={k.dependencies}>
{(['parents', 'children'] as const).map(side =>
detail.links[side].length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5" key={side}>
<span className="text-[0.6875rem] text-(--ui-text-quaternary)">
{side === 'parents' ? k.blockedBy : k.blocks}
</span>
{detail.links[side].map(linked => (
<button
className="rounded bg-(--ui-bg-quaternary) px-1.5 py-0.5 font-mono text-[0.625rem] text-(--ui-text-secondary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
key={linked}
onClick={() => onOpen(linked)}
type="button"
>
{shortId(linked)}
</button>
))}
</div>
) : null
)}
</Section>
)}
<Section
action={
<Tip label={running ? k.commentsHelpRunning : k.commentsHelp}>
<span className="grid size-5 place-items-center rounded text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)">
<Codicon name="question" size="0.8rem" />
</span>
</Tip>
}
label={k.comments(detail.comments.length)}
>
{detail.comments.length > 0 && (
<ul className="flex flex-col gap-2">
{detail.comments.map(comment => (
<li className="text-[0.75rem]" key={comment.id}>
<span className="font-medium text-(--ui-text-secondary)">{comment.author}</span>
<span className="ml-2 text-[0.625rem] text-(--ui-text-quaternary)">
{ago(comment.created_at)}
</span>
<p className="whitespace-pre-wrap text-(--ui-text-tertiary)">{comment.body}</p>
</li>
))}
</ul>
)}
<CommentComposer
onRequeue={body => requeueMut.mutate(body)}
onSubmit={body => commentMut.mutate(body)}
pending={commentMut.isPending || requeueMut.isPending}
running={running}
/>
</Section>
{detail.events.length > 0 && (
<Section label={k.activity(detail.events.length)}>
<ScrollFade deps={detail.events.length} max="7rem">
<ul className="flex flex-col gap-1">
{detail.events.map(event => {
const { detail: extra, label } = eventText(event, k)
return (
<li className="flex items-baseline gap-2 text-[0.6875rem]" key={event.id}>
<span className="shrink-0 text-(--ui-text-secondary)">{label}</span>
{extra && (
<span
className="min-w-0 truncate text-[0.625rem] text-(--ui-text-quaternary)"
title={extra}
>
{extra}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">{ago(event.created_at)}</span>
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{detail.runs.length > 0 && (
<Section label={k.runs(detail.runs.length)}>
<ScrollFade max="11rem">
<ul className="flex flex-col gap-1.5">
{detail.runs.map(run => {
const failed = ['crashed', 'failed', 'timed_out', 'gave_up'].includes(run.outcome ?? run.status)
return (
<li className="flex flex-col gap-0.5 text-[0.71rem]" key={run.id}>
<div className="flex items-center gap-2">
<Badge size="xs" variant={failed ? 'destructive' : 'muted'}>
{run.outcome ?? run.status}
</Badge>
{run.profile && <span className="text-(--ui-text-tertiary)">{run.profile}</span>}
{duration(run.started_at, run.ended_at) && (
<span className="text-(--ui-text-quaternary)">
{duration(run.started_at, run.ended_at)}
</span>
)}
<span className="ml-auto shrink-0 text-(--ui-text-quaternary)">
{ago(run.ended_at ?? run.started_at)}
</span>
</div>
{(run.error || run.summary) && (
<p
className={cn(
'line-clamp-2 whitespace-pre-wrap',
run.error ? 'text-destructive' : 'text-(--ui-text-quaternary)'
)}
>
{run.error ?? run.summary}
</p>
)}
</li>
)
})}
</ul>
</ScrollFade>
</Section>
)}
{log?.exists && log.content && (
<Section label={log.truncated ? k.workerLogTail : k.workerLog}>
<ScrollFade deps={log.content.length} max="12rem">
<LogView className="border-0 px-0">{log.content}</LogView>
</ScrollFade>
</Section>
)}
<AttachmentsSection
attachments={detail.attachments}
onUpload={file => uploadMut.mutate(file)}
pending={uploadMut.isPending}
/>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,970 @@
/**
* Plugin-scoped i18n for kanban bundles shipped under the plugin id via
* ctx.i18n.register (#67303), never touching core en.ts. usePluginI18n('kanban')
* returns a stringly-typed t(key, ); `useKanban()` binds it to the message
* SHAPE so components keep typed `k.newTask` / `k.moveTo(label)` access.
*/
import { type PluginLocaleBundles, type PluginTranslate, usePluginI18n } from '@hermes/plugin-sdk'
import { useMemo } from 'react'
type KanbanMessages = {
nav: string
openBoard: string
/** Command label shows in the K palette AND as the keybind panel row,
* so it carries the "Kanban: " prefix the palette convention wants. */
newTaskCommand: string
countTip: (running: number, ready: number) => string
col: Record<
'archived' | 'blocked' | 'done' | 'ready' | 'review' | 'running' | 'scheduled' | 'todo' | 'triage',
{ label: string; help: string }
>
locked: { review: string; running: string; scheduled: string }
arcRunning: string
arcStale: string
title: string
orchestrationSettings: string
newTask: string
filterCards: string
noMatch: string
noTasks: string
open: string
select: string
deselect: string
moveTo: (label: string) => string
delete: string
reviewChecking: string
attachedTip: (name: string) => string
orchestratorTip: (name: string) => string
autoAssignTip: (name: string) => string
wontRun: string
wontRunTip: string
noHeartbeat: string
expand: (label: string) => string
collapse: (label: string) => string
newTaskIn: (label: string) => string
empty: string
unassigned: string
filters: string
allProfiles: string
allTenants: string
showArchived: string
groupRunning: string
nSelected: (n: number) => string
moveToShort: string
assign: string
unassignAction: string
archive: string
clearSelection: string
refused: string
bulkFailed: (failed: number, total: number, err: string) => string
titlePlaceholderTriage: string
titlePlaceholder: string
descPlaceholder: string
priority: string
workspace: string
boardDefaultSuffix: string
workspaceOverride: string
workspaceInherit: string
workspaceInheritDir: (dir: string) => string
workspaceInheritGeneric: string
assignee: string
defaultOption: (name: string) => string
parkedOption: string
skills: string
skillsPlaceholder: string
parent: string
noParent: string
goalMode: string
creating: string
createTask: string
cancel: string
save: string
estimate: string
estimateEffort: string
estimating: string
reEstimate: string
makesModelCall: string
estimateTip: string
estimateTipLong: string
roughEstimate: string
tokUnit: string
couldNotEstimate: string
complexity: Record<'L' | 'M' | 'S', string>
introBody: string
introGotIt: string
// drawer — activity prose
evtCreated: (where: string, assignee: string) => string
evtMovedTo: (col: string) => string
evtParentReopened: (parent: string) => string
evtAssignedTo: (assignee: string) => string
evtUnassigned: string
evtCommentBy: (author: string) => string
evtClaimedReview: string
evtClaimedWorker: string
evtWorkerStarted: string
evtCompleted: string
evtBlocked: string
evtUnblocked: (col: string) => string
evtReclaimed: string
evtSpecified: string
evtPromoted: string
evtScheduled: string
evtArchived: string
evtReprioritized: (priority: string) => string
someone: string
// drawer — meta + sections
metaPriority: string
metaTenant: string
metaCreatedBy: string
metaCreated: string
metaWorkerPid: string
readyUnassignedTitle: string
readyUnassignedBody: string
diagnosticsN: (n: number) => string
commandCopied: string
description: string
editDescription: string
cancelEdit: string
noDescription: string
result: string
latestSummary: string
dependencies: string
blockedBy: string
blocks: string
comments: (n: number) => string
commentsHelpRunning: string
commentsHelp: string
send: string
comment: string
messageWorker: string
addComment: string
deliveredLive: string
requeueWithNote: string
notePosted: string
activity: (n: number) => string
runs: (n: number) => string
workerLog: string
workerLogTail: string
attachments: (n: number) => string
noAttachments: string
uploadAttachment: string
taskActions: string
copyTaskId: string
copyTitle: string
copiedId: (id: string) => string
copiedTitle: string
archiveTask: string
deleteTask: string
close: string
working: string
// board switcher
board: string
newBoard: string
newBoardDots: string
boardSettings: string
boardSettingsFor: (name: string) => string
name: string
boardNamePlaceholder: string
slug: (slug: string) => string
project: string
noProject: string
projectHintPre: string
projectHintCmd: string
createBoard: string
// orchestration
orchestratorProfile: string
defaultAssignee: string
defaultParen: string
autoDecompose: string
profileDescriptions: string
profileDescriptionsHint: string
profileGoodAt: string
auto: string
}
const en: KanbanMessages = {
nav: 'Kanban',
openBoard: 'Kanban: Open board',
newTaskCommand: 'Kanban: New task',
countTip: (running, ready) => `Kanban — ${running} running, ${ready} ready`,
col: {
triage: { label: 'Triage', help: 'Raw ideas — a specifier fleshes out the spec.' },
todo: { label: 'Todo', help: 'Waiting on dependencies, or unassigned.' },
scheduled: { label: 'Scheduled', help: 'Waiting for a scheduled time to arrive.' },
ready: { label: 'Ready', help: 'Dependencies satisfied — assign a profile and the dispatcher runs it.' },
running: { label: 'Running', help: 'Claimed by a worker — an agent is on it. Set by the dispatcher.' },
blocked: { label: 'Blocked', help: 'The worker asked for human input.' },
review: { label: 'Review', help: 'A review agent is checking the work. Set by the dispatcher.' },
done: { label: 'Done', help: 'Completed; dependent children become ready.' },
archived: { label: 'Archived', help: 'Hidden from the default board view.' }
},
locked: {
review: 'Review is entered by the dispatcher when a review agent takes the card.',
running: 'Running is set by the dispatcher when a worker claims the card.',
scheduled: 'Scheduled needs a wake-up time — agents set it; it cant be dragged into.'
},
arcRunning: 'An agent is working on this now.',
arcStale: 'Claimed, but no worker heartbeat for 2+ minutes — the dispatcher will reclaim it.',
title: 'Kanban',
orchestrationSettings: 'Orchestration settings',
newTask: 'New task',
filterCards: 'Filter cards…',
noMatch: 'No tasks match the filters',
noTasks: 'No tasks on this board',
open: 'Open',
select: 'Select (⌘-click)',
deselect: 'Deselect',
moveTo: label => `Move to ${label}`,
delete: 'Delete',
reviewChecking: 'A review agent is checking the completed work.',
attachedTip: name => `${name} is attached — the dispatcher hands this over on its next tick (≤1m).`,
orchestratorTip: name => `${name} (the orchestrator) picks this up on the next tick and writes the spec.`,
autoAssignTip: name => `Auto-assigns to “${name}” (kanban.default_assignee) on the next dispatch tick.`,
wontRun: "won't run",
wontRunTip:
'Ready cards only run once a profile is assigned. Open the card and set an assignee, or configure a default assignee in orchestration settings.',
noHeartbeat: 'no heartbeat',
expand: label => `Expand ${label}`,
collapse: label => `Collapse ${label}`,
newTaskIn: label => `New task in ${label}`,
empty: 'Empty',
unassigned: 'unassigned',
filters: 'Filters',
allProfiles: 'All profiles',
allTenants: 'All tenants',
showArchived: 'Show archived',
groupRunning: 'Group Running by profile',
nSelected: n => `${n} selected`,
moveToShort: 'Move to',
assign: 'Assign',
unassignAction: 'Unassign',
archive: 'Archive',
clearSelection: 'Clear selection (Esc)',
refused: 'refused',
bulkFailed: (failed, total, err) => `${failed} of ${total} failed — ${err}. Failed cards stay selected.`,
titlePlaceholderTriage: 'Rough idea — a specifier will flesh it out',
titlePlaceholder: 'Title',
descPlaceholder: 'Description (optional)',
priority: 'Priority',
workspace: 'Workspace',
boardDefaultSuffix: ' · board default',
workspaceOverride: 'Workspace path (optional override)',
workspaceInherit: 'Inherits the boards project directory',
workspaceInheritDir: dir => `Leave empty to inherit ${dir}`,
workspaceInheritGeneric: 'Leave empty to inherit the boards project directory.',
assignee: 'Assignee',
defaultOption: name => `${name} (default)`,
parkedOption: "unassigned (parked — won't run)",
skills: 'Skills (comma-separated)',
skillsPlaceholder: 'translation, github',
parent: "Parent (blocks until it's done)",
noParent: '— no parent —',
goalMode: "Goal mode (worker loops until a judge agrees it's done)",
creating: 'Creating…',
createTask: 'Create task',
cancel: 'Cancel',
save: 'Save',
estimate: 'Estimate',
estimateEffort: 'Estimate effort',
estimating: 'Estimating…',
reEstimate: 'Re-estimate',
makesModelCall: 'makes a model call',
estimateTip: 'Rough token + complexity estimate from the auxiliary model — makes a model call.',
estimateTipLong: 'Runs a quick auxiliary-model call to estimate tokens + complexity. A rough guide, not a bill.',
roughEstimate: 'Rough estimate',
tokUnit: 'tok',
couldNotEstimate: 'Could not estimate',
complexity: { S: 'Small', M: 'Medium', L: 'Large' },
introBody:
'You dont run the cards — agents do. Put a card in Ready with an assignee and an agent picks it up within a minute. No assignee, no run. Triage: an agent rewrites the idea into a proper task first. Todo: waiting on other cards. Scheduled: waiting on a timer. Running and Review: the agents lanes, hands off. Blocked: its waiting on you. Results come back on the card.',
introGotIt: 'Got it',
evtCreated: (where, assignee) => `created${where ? ` in ${where}` : ''}${assignee ? ` · assigned to ${assignee}` : ''}`,
evtMovedTo: col => `moved to ${col}`,
evtParentReopened: parent => `parent ${parent} reopened`,
evtAssignedTo: assignee => `assigned to ${assignee}`,
evtUnassigned: 'unassigned',
evtCommentBy: author => `comment by ${author}`,
evtClaimedReview: 'claimed by a review agent',
evtClaimedWorker: 'claimed by a worker',
evtWorkerStarted: 'worker started',
evtCompleted: 'completed',
evtBlocked: 'blocked — needs human input',
evtUnblocked: col => `unblocked${col ? `${col}` : ' → Ready'}`,
evtReclaimed: 'reclaimed — returned to the queue',
evtSpecified: 'spec written by the triage agent',
evtPromoted: 'dependencies done — promoted to Ready',
evtScheduled: 'scheduled for later',
evtArchived: 'archived',
evtReprioritized: priority => `priority set to ${priority}`,
someone: 'someone',
metaPriority: 'Priority',
metaTenant: 'Tenant',
metaCreatedBy: 'Created by',
metaCreated: 'Created',
metaWorkerPid: 'Worker pid',
readyUnassignedTitle: 'Ready, but unassigned — this card will never run.',
readyUnassignedBody:
'The dispatcher only claims Ready cards that have an assignee. Pick a profile in the Assignee field above (or set a default assignee in the orchestration settings) and it runs within a minute.',
diagnosticsN: n => `Diagnostics · ${n}`,
commandCopied: 'Command copied',
description: 'Description',
editDescription: 'Edit description',
cancelEdit: 'Cancel edit',
noDescription: 'No description yet.',
result: 'Result',
latestSummary: 'Latest summary',
dependencies: 'Dependencies',
blockedBy: 'Blocked by',
blocks: 'Blocks',
comments: n => `Comments · ${n}`,
commentsHelpRunning:
'This task is running. Your note is folded into the workers current turn within a few seconds — no block/unblock dance. “Requeue with note” instead restarts the task from scratch with your note in context.',
commentsHelp:
'Comments are added to the task thread. When a worker picks the task up it reads them as part of its context.',
send: 'Send',
comment: 'Comment',
messageWorker: 'Message the running worker…',
addComment: 'Add a comment…',
deliveredLive: 'Delivered to the running worker within a few seconds.',
requeueWithNote: 'Requeue with note',
notePosted: 'Note posted — worker requeued',
activity: n => `Activity · ${n}`,
runs: n => `Runs · ${n}`,
workerLog: 'Worker log',
workerLogTail: 'Worker log · tail',
attachments: n => `Attachments · ${n}`,
noAttachments: 'No attachments yet.',
uploadAttachment: 'Upload attachment',
taskActions: 'Task actions',
copyTaskId: 'Copy task id',
copyTitle: 'Copy title',
copiedId: id => `Copied ${id}`,
copiedTitle: 'Copied title',
archiveTask: 'Archive task',
deleteTask: 'Delete task',
close: 'Close',
working: 'working',
board: 'Board',
newBoard: 'New board',
newBoardDots: 'New board…',
boardSettings: 'Board settings…',
boardSettingsFor: name => `Board settings — ${name}`,
name: 'Name',
boardNamePlaceholder: 'Board name',
slug: slug => `slug: ${slug}`,
project: 'Project',
noProject: 'No project (scratch sandboxes)',
projectHintPre:
'New tasks run in the projects repo (a worktree per task); each task can still override its workspace at creation. Manage projects with ',
projectHintCmd: 'hermes project',
createBoard: 'Create board',
orchestratorProfile: 'Orchestrator profile',
defaultAssignee: 'Default assignee',
defaultParen: '(default)',
autoDecompose: 'Auto-decompose triage tasks',
profileDescriptions: 'Profile descriptions',
profileDescriptionsHint:
'Descriptions guide the decomposers routing. Auto-generate with the auxiliary model, or write your own.',
profileGoodAt: 'What is this profile good at?',
auto: 'Auto'
}
const ja: KanbanMessages = {
nav: 'カンバン',
openBoard: 'カンバン: ボードを開く',
newTaskCommand: 'カンバン: 新しいタスク',
countTip: (running, ready) => `カンバン — 実行中 ${running}、待機 ${ready}`,
col: {
triage: { label: 'トリアージ', help: '生のアイデア — スペシファイアが仕様に整えます。' },
todo: { label: 'Todo', help: '依存関係の待ち、または未割り当て。' },
scheduled: { label: 'スケジュール', help: '予定時刻を待っています。' },
ready: { label: 'Ready', help: '依存関係が解決済み — プロフィールを割り当てるとディスパッチャが実行します。' },
running: { label: '実行中', help: 'ワーカーが取得済み — エージェントが作業中。ディスパッチャが設定します。' },
blocked: { label: 'ブロック', help: 'ワーカーが人間の入力を求めています。' },
review: { label: 'レビュー', help: 'レビューエージェントが作業を確認中。ディスパッチャが設定します。' },
done: { label: '完了', help: '完了。依存する子タスクが Ready になります。' },
archived: { label: 'アーカイブ', help: 'デフォルトのボード表示から非表示。' }
},
locked: {
review: 'レビューは、レビューエージェントがカードを取得するとディスパッチャによって設定されます。',
running: '実行中は、ワーカーがカードを取得するとディスパッチャによって設定されます。',
scheduled: 'スケジュールには起動時刻が必要です — エージェントが設定します。ドラッグでは移動できません。'
},
arcRunning: 'エージェントが現在作業中です。',
arcStale: '取得済みですが、2分以上ワーカーのハートビートがありません — ディスパッチャが再取得します。',
title: 'カンバン',
orchestrationSettings: 'オーケストレーション設定',
newTask: '新しいタスク',
filterCards: 'カードを絞り込み…',
noMatch: 'フィルタに一致するタスクはありません',
noTasks: 'このボードにタスクはありません',
open: '開く',
select: '選択(⌘クリック)',
deselect: '選択解除',
moveTo: label => `${label} へ移動`,
delete: '削除',
reviewChecking: 'レビューエージェントが完了した作業を確認中です。',
attachedTip: name => `${name} が担当 — ディスパッチャが次のティック≤1分で引き渡します。`,
orchestratorTip: name => `${name}(オーケストレーター)が次のティックでこれを取得し、仕様を書きます。`,
autoAssignTip: name => `次のディスパッチティックで「${name}kanban.default_assigneeに自動割り当てされます。`,
wontRun: '実行されません',
wontRunTip:
'Ready のカードはプロフィールが割り当てられて初めて実行されます。カードを開いて担当を設定するか、オーケストレーション設定でデフォルトの担当を設定してください。',
noHeartbeat: 'ハートビートなし',
expand: label => `${label} を展開`,
collapse: label => `${label} を折りたたむ`,
newTaskIn: label => `${label} に新しいタスク`,
empty: '空',
unassigned: '未割り当て',
filters: 'フィルタ',
allProfiles: 'すべてのプロフィール',
allTenants: 'すべてのテナント',
showArchived: 'アーカイブを表示',
groupRunning: '実行中をプロフィールでグループ化',
nSelected: n => `${n} 件選択中`,
moveToShort: '移動',
assign: '割り当て',
unassignAction: '割り当て解除',
archive: 'アーカイブ',
clearSelection: '選択をクリアEsc',
refused: '拒否されました',
bulkFailed: (failed, total, err) => `${total} 件中 ${failed} 件が失敗 — ${err}。失敗したカードは選択されたままです。`,
titlePlaceholderTriage: '大まかなアイデア — スペシファイアが具体化します',
titlePlaceholder: 'タイトル',
descPlaceholder: '説明(任意)',
priority: '優先度',
workspace: 'ワークスペース',
boardDefaultSuffix: '・ボード既定',
workspaceOverride: 'ワークスペースパス(任意の上書き)',
workspaceInherit: 'ボードのプロジェクトディレクトリを継承',
workspaceInheritDir: dir => `空欄にすると ${dir} を継承します`,
workspaceInheritGeneric: '空欄にするとボードのプロジェクトディレクトリを継承します。',
assignee: '担当',
defaultOption: name => `${name}(既定)`,
parkedOption: '未割り当て(保留 — 実行されません)',
skills: 'スキル(カンマ区切り)',
skillsPlaceholder: 'translation, github',
parent: '親(完了するまでブロック)',
noParent: '— 親なし —',
goalMode: 'ゴールモード(ジャッジが完了と認めるまでワーカーがループ)',
creating: '作成中…',
createTask: 'タスクを作成',
cancel: 'キャンセル',
save: '保存',
estimate: '見積もり',
estimateEffort: '工数を見積もり',
estimating: '見積もり中…',
reEstimate: '再見積もり',
makesModelCall: 'モデル呼び出しあり',
estimateTip: '補助モデルによるトークン数と複雑度の概算 — モデル呼び出しを行います。',
estimateTipLong: '補助モデルを呼び出してトークン数と複雑度を概算します。目安であり、請求ではありません。',
roughEstimate: '概算',
tokUnit: 'tok',
couldNotEstimate: '見積もりできませんでした',
complexity: { S: '小', M: '中', L: '大' },
introBody:
'カードはあなたではなくエージェントが実行します。担当を設定したカードを Ready に置くと、1分以内にエージェントが取得します。担当がなければ実行されません。トリアージ: エージェントがまずアイデアを適切なタスクに書き直します。Todo: 他のカード待ち。スケジュール: タイマー待ち。実行中とレビュー: エージェントのレーンなので手を出さないでください。ブロック: あなたの対応待ちです。結果はカードに戻ってきます。',
introGotIt: '了解',
evtCreated: (where, assignee) =>
`作成${where ? `${where}` : ''}${assignee ? `${assignee} に割り当て` : ''}`,
evtMovedTo: col => `${col} へ移動`,
evtParentReopened: parent => `${parent} が再オープン`,
evtAssignedTo: assignee => `${assignee} に割り当て`,
evtUnassigned: '割り当て解除',
evtCommentBy: author => `${author} のコメント`,
evtClaimedReview: 'レビューエージェントが取得',
evtClaimedWorker: 'ワーカーが取得',
evtWorkerStarted: 'ワーカー開始',
evtCompleted: '完了',
evtBlocked: 'ブロック — 人間の入力が必要',
evtUnblocked: col => `ブロック解除${col ? `${col}` : ' → Ready'}`,
evtReclaimed: '再取得 — キューに戻しました',
evtSpecified: 'トリアージエージェントが仕様を作成',
evtPromoted: '依存関係が完了 — Ready に昇格',
evtScheduled: '後で実行するようスケジュール',
evtArchived: 'アーカイブ済み',
evtReprioritized: priority => `優先度を ${priority} に設定`,
someone: '誰か',
metaPriority: '優先度',
metaTenant: 'テナント',
metaCreatedBy: '作成者',
metaCreated: '作成',
metaWorkerPid: 'ワーカー PID',
readyUnassignedTitle: 'Ready ですが未割り当て — このカードは実行されません。',
readyUnassignedBody:
'ディスパッチャは担当のある Ready カードのみ取得します。上の担当フィールドでプロフィールを選ぶまたはオーケストレーション設定でデフォルトの担当を設定すると、1分以内に実行されます。',
diagnosticsN: n => `診断・${n}`,
commandCopied: 'コマンドをコピーしました',
description: '説明',
editDescription: '説明を編集',
cancelEdit: '編集をキャンセル',
noDescription: 'まだ説明はありません。',
result: '結果',
latestSummary: '最新のサマリー',
dependencies: '依存関係',
blockedBy: 'ブロック元',
blocks: 'ブロック先',
comments: n => `コメント・${n}`,
commentsHelpRunning:
'このタスクは実行中です。あなたのメモは数秒以内にワーカーの現在のターンに取り込まれます — ブロック/解除の操作は不要です。「メモを付けて再キュー」を選ぶと、メモを文脈に含めてタスクを最初からやり直します。',
commentsHelp: 'コメントはタスクのスレッドに追加されます。ワーカーがタスクを取得すると、文脈の一部として読み込みます。',
send: '送信',
comment: 'コメント',
messageWorker: '実行中のワーカーにメッセージ…',
addComment: 'コメントを追加…',
deliveredLive: '数秒以内に実行中のワーカーへ届きます。',
requeueWithNote: 'メモを付けて再キュー',
notePosted: 'メモを投稿しました — ワーカーを再キューしました',
activity: n => `アクティビティ・${n}`,
runs: n => `実行・${n}`,
workerLog: 'ワーカーログ',
workerLogTail: 'ワーカーログ・末尾',
attachments: n => `添付・${n}`,
noAttachments: 'まだ添付はありません。',
uploadAttachment: '添付をアップロード',
taskActions: 'タスクの操作',
copyTaskId: 'タスク ID をコピー',
copyTitle: 'タイトルをコピー',
copiedId: id => `${id} をコピーしました`,
copiedTitle: 'タイトルをコピーしました',
archiveTask: 'タスクをアーカイブ',
deleteTask: 'タスクを削除',
close: '閉じる',
working: '作業中',
board: 'ボード',
newBoard: '新しいボード',
newBoardDots: '新しいボード…',
boardSettings: 'ボード設定…',
boardSettingsFor: name => `ボード設定 — ${name}`,
name: '名前',
boardNamePlaceholder: 'ボード名',
slug: slug => `slug: ${slug}`,
project: 'プロジェクト',
noProject: 'プロジェクトなし(スクラッチのサンドボックス)',
projectHintPre:
'新しいタスクはプロジェクトのリポジトリで実行されます(タスクごとに worktree。各タスクは作成時にワークスペースを上書きできます。プロジェクトの管理は ',
projectHintCmd: 'hermes project',
createBoard: 'ボードを作成',
orchestratorProfile: 'オーケストレータープロフィール',
defaultAssignee: 'デフォルトの担当',
defaultParen: '(既定)',
autoDecompose: 'トリアージタスクを自動分解',
profileDescriptions: 'プロフィールの説明',
profileDescriptionsHint:
'説明はデコンポーザーのルーティングを導きます。補助モデルで自動生成するか、自分で書いてください。',
profileGoodAt: 'このプロフィールの得意分野は?',
auto: '自動'
}
const zh: KanbanMessages = {
nav: '看板',
openBoard: '看板:打开面板',
newTaskCommand: '看板:新建任务',
countTip: (running, ready) => `看板 — 运行中 ${running}、就绪 ${ready}`,
col: {
triage: { label: '分诊', help: '原始想法 — 由细化代理整理出规格。' },
todo: { label: '待办', help: '等待依赖,或未分配。' },
scheduled: { label: '已排期', help: '等待预定时间到来。' },
ready: { label: '就绪', help: '依赖已满足 — 分配一个配置档,调度器即会运行它。' },
running: { label: '运行中', help: '已被工作单元领取 — 有代理在处理。由调度器设置。' },
blocked: { label: '受阻', help: '工作单元需要人工输入。' },
review: { label: '审查', help: '审查代理正在检查工作。由调度器设置。' },
done: { label: '完成', help: '已完成;依赖它的子任务变为就绪。' },
archived: { label: '已归档', help: '从默认面板视图中隐藏。' }
},
locked: {
review: '审查状态由调度器在审查代理领取卡片时设置。',
running: '运行中由调度器在工作单元领取卡片时设置。',
scheduled: '排期需要唤醒时间 — 由代理设置;无法拖入。'
},
arcRunning: '有代理正在处理它。',
arcStale: '已领取,但超过 2 分钟没有工作单元心跳 — 调度器将重新领取。',
title: '看板',
orchestrationSettings: '编排设置',
newTask: '新建任务',
filterCards: '筛选卡片…',
noMatch: '没有符合筛选条件的任务',
noTasks: '此面板暂无任务',
open: '打开',
select: '选择(⌘点击)',
deselect: '取消选择',
moveTo: label => `移动到 ${label}`,
delete: '删除',
reviewChecking: '审查代理正在检查已完成的工作。',
attachedTip: name => `${name} 已接手 — 调度器将在下一个周期≤1 分钟)移交。`,
orchestratorTip: name => `${name}(编排者)将在下一个周期领取并撰写规格。`,
autoAssignTip: name => `将在下一个调度周期自动分配给“${name}kanban.default_assignee`,
wontRun: '不会运行',
wontRunTip: '就绪卡片只有在分配了配置档后才会运行。打开卡片设置负责人,或在编排设置中配置默认负责人。',
noHeartbeat: '无心跳',
expand: label => `展开 ${label}`,
collapse: label => `折叠 ${label}`,
newTaskIn: label => `${label} 新建任务`,
empty: '空',
unassigned: '未分配',
filters: '筛选',
allProfiles: '所有配置档',
allTenants: '所有租户',
showArchived: '显示已归档',
groupRunning: '按配置档分组运行中',
nSelected: n => `已选择 ${n}`,
moveToShort: '移动到',
assign: '分配',
unassignAction: '取消分配',
archive: '归档',
clearSelection: '清除选择Esc',
refused: '被拒绝',
bulkFailed: (failed, total, err) => `${total} 个中有 ${failed} 个失败 — ${err}。失败的卡片仍保持选中。`,
titlePlaceholderTriage: '大致想法 — 细化代理会补全',
titlePlaceholder: '标题',
descPlaceholder: '描述(可选)',
priority: '优先级',
workspace: '工作区',
boardDefaultSuffix: '・面板默认',
workspaceOverride: '工作区路径(可选覆盖)',
workspaceInherit: '继承面板的项目目录',
workspaceInheritDir: dir => `留空则继承 ${dir}`,
workspaceInheritGeneric: '留空则继承面板的项目目录。',
assignee: '负责人',
defaultOption: name => `${name}(默认)`,
parkedOption: '未分配(搁置 — 不会运行)',
skills: '技能(逗号分隔)',
skillsPlaceholder: 'translation, github',
parent: '父任务(完成前会阻塞)',
noParent: '— 无父任务 —',
goalMode: '目标模式(工作单元循环直到评判代理认可完成)',
creating: '创建中…',
createTask: '创建任务',
cancel: '取消',
save: '保存',
estimate: '估算',
estimateEffort: '估算工作量',
estimating: '估算中…',
reEstimate: '重新估算',
makesModelCall: '会调用模型',
estimateTip: '由辅助模型对令牌数和复杂度的粗略估算 — 会调用模型。',
estimateTipLong: '快速调用辅助模型来估算令牌数和复杂度。仅供参考,并非账单。',
roughEstimate: '粗略估算',
tokUnit: 'tok',
couldNotEstimate: '无法估算',
complexity: { S: '小', M: '中', L: '大' },
introBody:
'卡片不由你运行,而是由代理运行。把带有负责人的卡片放入“就绪”,代理会在一分钟内领取。没有负责人就不会运行。分诊:代理先把想法改写成合适的任务。待办:等待其他卡片。已排期:等待计时器。运行中与审查:这是代理的通道,请勿插手。受阻:正在等你。结果会回到卡片上。',
introGotIt: '知道了',
evtCreated: (where, assignee) =>
`已创建${where ? `${where}` : ''}${assignee ? `・分配给 ${assignee}` : ''}`,
evtMovedTo: col => `移动到 ${col}`,
evtParentReopened: parent => `父任务 ${parent} 已重新打开`,
evtAssignedTo: assignee => `分配给 ${assignee}`,
evtUnassigned: '取消分配',
evtCommentBy: author => `${author} 的评论`,
evtClaimedReview: '被审查代理领取',
evtClaimedWorker: '被工作单元领取',
evtWorkerStarted: '工作单元已启动',
evtCompleted: '已完成',
evtBlocked: '受阻 — 需要人工输入',
evtUnblocked: col => `已解除阻塞${col ? `${col}` : ' → 就绪'}`,
evtReclaimed: '已重新领取 — 已放回队列',
evtSpecified: '分诊代理已撰写规格',
evtPromoted: '依赖已完成 — 提升为就绪',
evtScheduled: '已排期稍后运行',
evtArchived: '已归档',
evtReprioritized: priority => `优先级设为 ${priority}`,
someone: '某人',
metaPriority: '优先级',
metaTenant: '租户',
metaCreatedBy: '创建者',
metaCreated: '创建于',
metaWorkerPid: '工作单元 PID',
readyUnassignedTitle: '就绪但未分配 — 这张卡片永远不会运行。',
readyUnassignedBody:
'调度器只领取有负责人的就绪卡片。在上面的负责人字段选择一个配置档(或在编排设置中设置默认负责人),它会在一分钟内运行。',
diagnosticsN: n => `诊断・${n}`,
commandCopied: '命令已复制',
description: '描述',
editDescription: '编辑描述',
cancelEdit: '取消编辑',
noDescription: '暂无描述。',
result: '结果',
latestSummary: '最新摘要',
dependencies: '依赖关系',
blockedBy: '受阻于',
blocks: '阻塞',
comments: n => `评论・${n}`,
commentsHelpRunning:
'此任务正在运行。你的备注会在几秒内融入工作单元当前的回合 — 无需阻塞/解除操作。选择“附带备注重新入队”则会带着你的备注从头重跑任务。',
commentsHelp: '评论会添加到任务讨论串。工作单元领取任务时会将其作为上下文的一部分读取。',
send: '发送',
comment: '评论',
messageWorker: '给运行中的工作单元发消息…',
addComment: '添加评论…',
deliveredLive: '几秒内送达运行中的工作单元。',
requeueWithNote: '附带备注重新入队',
notePosted: '备注已发布 — 工作单元已重新入队',
activity: n => `活动・${n}`,
runs: n => `运行・${n}`,
workerLog: '工作单元日志',
workerLogTail: '工作单元日志・末尾',
attachments: n => `附件・${n}`,
noAttachments: '暂无附件。',
uploadAttachment: '上传附件',
taskActions: '任务操作',
copyTaskId: '复制任务 ID',
copyTitle: '复制标题',
copiedId: id => `已复制 ${id}`,
copiedTitle: '已复制标题',
archiveTask: '归档任务',
deleteTask: '删除任务',
close: '关闭',
working: '进行中',
board: '面板',
newBoard: '新建面板',
newBoardDots: '新建面板…',
boardSettings: '面板设置…',
boardSettingsFor: name => `面板设置 — ${name}`,
name: '名称',
boardNamePlaceholder: '面板名称',
slug: slug => `slug: ${slug}`,
project: '项目',
noProject: '无项目(临时沙箱)',
projectHintPre: '新任务将在项目的仓库中运行(每个任务一个 worktree每个任务在创建时仍可覆盖其工作区。管理项目请使用 ',
projectHintCmd: 'hermes project',
createBoard: '创建面板',
orchestratorProfile: '编排者配置档',
defaultAssignee: '默认负责人',
defaultParen: '(默认)',
autoDecompose: '自动分解分诊任务',
profileDescriptions: '配置档说明',
profileDescriptionsHint: '说明用于引导分解器的路由。可用辅助模型自动生成,或自行填写。',
profileGoodAt: '这个配置档擅长什么?',
auto: '自动'
}
const zhHant: KanbanMessages = {
nav: '看板',
openBoard: '看板:開啟面板',
newTaskCommand: '看板:新增任務',
countTip: (running, ready) => `看板 — 執行中 ${running}、就緒 ${ready}`,
col: {
triage: { label: '分類', help: '原始想法 — 由細化代理整理出規格。' },
todo: { label: '待辦', help: '等待相依項目,或未指派。' },
scheduled: { label: '已排程', help: '等待預定時間到來。' },
ready: { label: '就緒', help: '相依項目已滿足 — 指派一個設定檔,排程器便會執行它。' },
running: { label: '執行中', help: '已被工作單元領取 — 有代理在處理。由排程器設定。' },
blocked: { label: '受阻', help: '工作單元需要人工輸入。' },
review: { label: '審查', help: '審查代理正在檢查工作。由排程器設定。' },
done: { label: '完成', help: '已完成;相依它的子任務變為就緒。' },
archived: { label: '已封存', help: '從預設面板檢視中隱藏。' }
},
locked: {
review: '審查狀態由排程器在審查代理領取卡片時設定。',
running: '執行中由排程器在工作單元領取卡片時設定。',
scheduled: '排程需要喚醒時間 — 由代理設定;無法拖入。'
},
arcRunning: '有代理正在處理它。',
arcStale: '已領取,但超過 2 分鐘沒有工作單元心跳 — 排程器將重新領取。',
title: '看板',
orchestrationSettings: '編排設定',
newTask: '新增任務',
filterCards: '篩選卡片…',
noMatch: '沒有符合篩選條件的任務',
noTasks: '此面板尚無任務',
open: '開啟',
select: '選取(⌘點擊)',
deselect: '取消選取',
moveTo: label => `移至 ${label}`,
delete: '刪除',
reviewChecking: '審查代理正在檢查已完成的工作。',
attachedTip: name => `${name} 已接手 — 排程器將在下一個週期≤1 分鐘)移交。`,
orchestratorTip: name => `${name}(編排者)將在下一個週期領取並撰寫規格。`,
autoAssignTip: name => `將在下一個排程週期自動指派給「${name}kanban.default_assignee`,
wontRun: '不會執行',
wontRunTip: '就緒卡片只有在指派了設定檔後才會執行。開啟卡片設定負責人,或在編排設定中設定預設負責人。',
noHeartbeat: '無心跳',
expand: label => `展開 ${label}`,
collapse: label => `摺疊 ${label}`,
newTaskIn: label => `${label} 新增任務`,
empty: '空',
unassigned: '未指派',
filters: '篩選',
allProfiles: '所有設定檔',
allTenants: '所有租戶',
showArchived: '顯示已封存',
groupRunning: '依設定檔分組執行中',
nSelected: n => `已選取 ${n}`,
moveToShort: '移至',
assign: '指派',
unassignAction: '取消指派',
archive: '封存',
clearSelection: '清除選取Esc',
refused: '被拒絕',
bulkFailed: (failed, total, err) => `${total} 個中有 ${failed} 個失敗 — ${err}。失敗的卡片仍保持選取。`,
titlePlaceholderTriage: '大致想法 — 細化代理會補全',
titlePlaceholder: '標題',
descPlaceholder: '描述(選填)',
priority: '優先順序',
workspace: '工作區',
boardDefaultSuffix: '・面板預設',
workspaceOverride: '工作區路徑(選填覆寫)',
workspaceInherit: '繼承面板的專案目錄',
workspaceInheritDir: dir => `留空則繼承 ${dir}`,
workspaceInheritGeneric: '留空則繼承面板的專案目錄。',
assignee: '負責人',
defaultOption: name => `${name}(預設)`,
parkedOption: '未指派(擱置 — 不會執行)',
skills: '技能(以逗號分隔)',
skillsPlaceholder: 'translation, github',
parent: '父任務(完成前會阻擋)',
noParent: '— 無父任務 —',
goalMode: '目標模式(工作單元循環直到評判代理認可完成)',
creating: '建立中…',
createTask: '建立任務',
cancel: '取消',
save: '儲存',
estimate: '估算',
estimateEffort: '估算工作量',
estimating: '估算中…',
reEstimate: '重新估算',
makesModelCall: '會呼叫模型',
estimateTip: '由輔助模型對 token 數與複雜度的粗略估算 — 會呼叫模型。',
estimateTipLong: '快速呼叫輔助模型來估算 token 數與複雜度。僅供參考,並非帳單。',
roughEstimate: '粗略估算',
tokUnit: 'tok',
couldNotEstimate: '無法估算',
complexity: { S: '小', M: '中', L: '大' },
introBody:
'卡片不由你執行,而是由代理執行。把有負責人的卡片放入「就緒」,代理會在一分鐘內領取。沒有負責人就不會執行。分類:代理先把想法改寫成合適的任務。待辦:等待其他卡片。已排程:等待計時器。執行中與審查:這是代理的通道,請勿插手。受阻:正在等你。結果會回到卡片上。',
introGotIt: '知道了',
evtCreated: (where, assignee) =>
`已建立${where ? `${where}` : ''}${assignee ? `・指派給 ${assignee}` : ''}`,
evtMovedTo: col => `移至 ${col}`,
evtParentReopened: parent => `父任務 ${parent} 已重新開啟`,
evtAssignedTo: assignee => `指派給 ${assignee}`,
evtUnassigned: '取消指派',
evtCommentBy: author => `${author} 的留言`,
evtClaimedReview: '被審查代理領取',
evtClaimedWorker: '被工作單元領取',
evtWorkerStarted: '工作單元已啟動',
evtCompleted: '已完成',
evtBlocked: '受阻 — 需要人工輸入',
evtUnblocked: col => `已解除阻擋${col ? `${col}` : ' → 就緒'}`,
evtReclaimed: '已重新領取 — 已放回佇列',
evtSpecified: '分類代理已撰寫規格',
evtPromoted: '相依項目已完成 — 提升為就緒',
evtScheduled: '已排程稍後執行',
evtArchived: '已封存',
evtReprioritized: priority => `優先順序設為 ${priority}`,
someone: '某人',
metaPriority: '優先順序',
metaTenant: '租戶',
metaCreatedBy: '建立者',
metaCreated: '建立於',
metaWorkerPid: '工作單元 PID',
readyUnassignedTitle: '就緒但未指派 — 這張卡片永遠不會執行。',
readyUnassignedBody:
'排程器只領取有負責人的就緒卡片。在上方的負責人欄位選擇一個設定檔(或在編排設定中設定預設負責人),它會在一分鐘內執行。',
diagnosticsN: n => `診斷・${n}`,
commandCopied: '指令已複製',
description: '描述',
editDescription: '編輯描述',
cancelEdit: '取消編輯',
noDescription: '尚無描述。',
result: '結果',
latestSummary: '最新摘要',
dependencies: '相依關係',
blockedBy: '受阻於',
blocks: '阻擋',
comments: n => `留言・${n}`,
commentsHelpRunning:
'此任務正在執行。你的備註會在幾秒內融入工作單元目前的回合 — 無需阻擋/解除操作。選擇「附上備註重新排入佇列」則會帶著你的備註從頭重跑任務。',
commentsHelp: '留言會加入任務討論串。工作單元領取任務時會將其作為脈絡的一部分讀取。',
send: '傳送',
comment: '留言',
messageWorker: '傳訊給執行中的工作單元…',
addComment: '新增留言…',
deliveredLive: '幾秒內送達執行中的工作單元。',
requeueWithNote: '附上備註重新排入佇列',
notePosted: '備註已發布 — 工作單元已重新排入佇列',
activity: n => `活動・${n}`,
runs: n => `執行・${n}`,
workerLog: '工作單元日誌',
workerLogTail: '工作單元日誌・末尾',
attachments: n => `附件・${n}`,
noAttachments: '尚無附件。',
uploadAttachment: '上傳附件',
taskActions: '任務操作',
copyTaskId: '複製任務 ID',
copyTitle: '複製標題',
copiedId: id => `已複製 ${id}`,
copiedTitle: '已複製標題',
archiveTask: '封存任務',
deleteTask: '刪除任務',
close: '關閉',
working: '進行中',
board: '面板',
newBoard: '新增面板',
newBoardDots: '新增面板…',
boardSettings: '面板設定…',
boardSettingsFor: name => `面板設定 — ${name}`,
name: '名稱',
boardNamePlaceholder: '面板名稱',
slug: slug => `slug: ${slug}`,
project: '專案',
noProject: '無專案(暫存沙箱)',
projectHintPre: '新任務將在專案的儲存庫中執行(每個任務一個 worktree每個任務在建立時仍可覆寫其工作區。管理專案請使用 ',
projectHintCmd: 'hermes project',
createBoard: '建立面板',
orchestratorProfile: '編排者設定檔',
defaultAssignee: '預設負責人',
defaultParen: '(預設)',
autoDecompose: '自動分解分類任務',
profileDescriptions: '設定檔說明',
profileDescriptionsHint: '說明用於引導分解器的路由。可用輔助模型自動產生,或自行填寫。',
profileGoodAt: '這個設定檔擅長什麼?',
auto: '自動'
}
/** Registered via `ctx.i18n.register` at plugin load (disposer tracked). */
export const KANBAN_LOCALES: PluginLocaleBundles = { en, ja, zh, 'zh-hant': zhHant }
// Bind the message SHAPE to a plugin translator: string leaves resolve now,
// function leaves forward their args through t(path, …). One tiny generic
// instead of a hand-written accessor per key.
type Bound<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => string
? (...args: A) => string
: T[K] extends object
? Bound<T[K]>
: string
}
function bind<T extends object>(t: PluginTranslate, template: T, prefix = ''): Bound<T> {
const out = {} as Record<string, unknown>
for (const [key, value] of Object.entries(template)) {
const path = prefix ? `${prefix}.${key}` : key
out[key] =
typeof value === 'function'
? (...args: unknown[]) => t(path, ...args)
: value && typeof value === 'object'
? bind(t, value as object, path)
: t(path)
}
return out as Bound<T>
}
export type KanbanText = Bound<KanbanMessages>
/** The kanban strings for the active locale — one hook every component reads. */
export function useKanban(): KanbanText {
const t = usePluginI18n('kanban')
return useMemo(() => bind(t, en), [t])
}
// Column labels/help live in i18n; unknown backend statuses fall back to the id.
export const columnLabel = (k: KanbanText, name: string) => k.col[name as keyof KanbanText['col']]?.label ?? name
export const columnHelp = (k: KanbanText, name: string) => k.col[name as keyof KanbanText['col']]?.help ?? ''
export const lockedReason = (k: KanbanText, name: string) => k.locked[name as keyof KanbanText['locked']] ?? ''

View file

@ -0,0 +1,46 @@
/* Machine-activity arc a highlight that travels the card's border while an
agent is ACTUALLY working the card (amber-slow when the heartbeat is gone).
Queued/attached cards do NOT animate that's the footer's named-agent chip.
The tone rides --kanban-tone, set inline from the column meta. Painted as an
overlay ring (mask keeps only the border band) so the card stays flat. */
@property --kanban-arc-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.kanban-arc {
pointer-events: none;
position: absolute;
inset: -1px; /* sit on the card's own border line */
border-radius: inherit;
padding: 1.5px; /* arc thickness */
background: conic-gradient(
from var(--kanban-arc-angle),
transparent 0deg,
var(--kanban-tone, var(--ui-stroke-primary)) 55deg,
transparent 110deg
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: kanban-arc-spin 2.2s linear infinite;
}
/* Running but no heartbeat: amber crawl — still claimed, health unknown. */
.kanban-arc--stale {
--kanban-tone: #fbbf24;
animation-duration: 6s;
}
@keyframes kanban-arc-spin {
to {
--kanban-arc-angle: 360deg;
}
}

View file

@ -0,0 +1,181 @@
/**
* Orchestration settings the dashboard's dispatcher-knobs panel, flat-styled:
* orchestrator profile, default assignee, auto-decompose, and the profile
* descriptions the decomposer routes by (save / auto-generate per profile).
*/
import {
Button,
Codicon,
host,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
useMutation,
useQuery,
useQueryClient
} from '@hermes/plugin-sdk'
import { useState } from 'react'
import {
autoDescribeProfile,
fetchOrchestration,
fetchProfiles,
ORCHESTRATION_KEY,
PROFILES_KEY,
saveOrchestration,
saveProfileDescription
} from './api'
import type { KanbanProfile } from './types'
import { errText, FIELD_LABEL, useKanban } from './ui'
const DEFAULT_SENTINEL = '__default__'
function ProfilePicker({
label,
onSave,
profiles,
value
}: {
label: string
onSave: (name: string) => void
profiles: KanbanProfile[]
value: string
}) {
const k = useKanban()
return (
<label className="flex min-w-0 flex-col gap-1">
<span className={FIELD_LABEL}>{label}</span>
<Select onValueChange={name => onSave(name === DEFAULT_SENTINEL ? '' : name)} value={value || DEFAULT_SENTINEL}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_SENTINEL}>{k.defaultParen}</SelectItem>
{profiles.map(profile => (
<SelectItem key={profile.name} value={profile.name}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
function ProfileDescriptionRow({ profile }: { profile: KanbanProfile }) {
const k = useKanban()
const qc = useQueryClient()
const [draft, setDraft] = useState(profile.description)
const invalidate = () => void qc.invalidateQueries({ queryKey: PROFILES_KEY })
const save = useMutation({
mutationFn: () => saveProfileDescription(profile.name, draft.trim()),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: invalidate
})
const auto = useMutation({
mutationFn: () => autoDescribeProfile(profile.name),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: result => {
if (result.ok) {
setDraft(result.description ?? '')
invalidate()
} else {
host.notify({ kind: 'warning', message: result.reason || 'Auto-describe failed' })
}
}
})
return (
<div className="flex items-center gap-2">
<span className="w-24 shrink-0 truncate text-[0.75rem] font-medium text-(--ui-text-secondary)">
{profile.name}
{profile.is_default && <span className="ml-1 text-[0.625rem] text-(--ui-text-quaternary)">{k.defaultParen}</span>}
</span>
<Input
className="h-7 flex-1 text-[0.71rem]"
onChange={event => setDraft(event.target.value)}
placeholder={k.profileGoodAt}
value={draft}
/>
<Button
disabled={save.isPending || draft.trim() === profile.description}
onClick={() => save.mutate()}
size="xs"
variant="outline"
>
{k.save}
</Button>
{/* Overlay the spinner so the button keeps its "Auto" width the aux
model can take a few seconds and a text swap would jump the row. */}
<Button className="relative" disabled={auto.isPending} onClick={() => auto.mutate()} size="xs" variant="ghost">
<span className={auto.isPending ? 'invisible' : ''}>{k.auto}</span>
{auto.isPending && (
<span className="absolute inset-0 grid place-items-center">
<Codicon className="animate-spin [animation-duration:1.2s]" name="loading" size="0.75rem" />
</span>
)}
</Button>
</div>
)
}
export function OrchestrationPanel() {
const k = useKanban()
const qc = useQueryClient()
const { data: settings } = useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration })
const { data: roster } = useQuery({ queryKey: PROFILES_KEY, queryFn: fetchProfiles, staleTime: 60_000 })
const save = useMutation({
mutationFn: (patch: Record<string, unknown>) => saveOrchestration(patch),
onError: err => host.notify({ kind: 'error', message: errText(err) }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ORCHESTRATION_KEY })
})
if (!settings || !roster) {
return null
}
return (
<div className="flex flex-col gap-4 border-t border-(--ui-stroke-tertiary) px-4 py-3">
<div className="flex flex-wrap items-end gap-4">
<ProfilePicker
label={k.orchestratorProfile}
onSave={name => save.mutate({ orchestrator_profile: name })}
profiles={roster.profiles}
value={settings.orchestrator_profile}
/>
<ProfilePicker
label={k.defaultAssignee}
onSave={name => save.mutate({ default_assignee: name })}
profiles={roster.profiles}
value={settings.default_assignee}
/>
<label className="flex cursor-pointer items-center gap-2 pb-1.5 text-[0.75rem] text-(--ui-text-secondary)">
<Switch
aria-label={k.autoDecompose}
checked={settings.auto_decompose}
onCheckedChange={checked => save.mutate({ auto_decompose: checked })}
size="xs"
/>
{k.autoDecompose}
</label>
</div>
<div className="flex flex-col gap-1.5">
<span className={FIELD_LABEL}>{k.profileDescriptions}</span>
<p className="text-[0.6875rem] text-(--ui-text-quaternary)">{k.profileDescriptionsHint}</p>
{roster.profiles.map(profile => (
<ProfileDescriptionRow key={`${profile.name}:${profile.description}`} profile={profile} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,160 @@
/**
* Kanban the founding plugin use case, now pure SDK-consumer work: a
* first-class `/kanban` board page + sidebar nav row + a live statusbar count,
* all reusing the existing `plugins/kanban/dashboard/plugin_api.py` REST router
* through `ctx.rest` (namespace-scoped to `/api/plugins/kanban`). No new
* backend, no core edits.
*
* Ships OFF by default (`defaultEnabled: false`): it inventories in
* Settings Plugins and registers nothing until the user flips the switch.
*/
import './kanban.css'
import {
cn,
Codicon,
type HermesPlugin,
host,
type KeybindContribution,
KEYBINDS_AREA,
PALETTE_AREA,
type PaletteContribution,
type RouteContribution,
ROUTES_AREA,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
STATUSBAR_AREAS,
Tip,
useQuery,
useValue
} from '@hermes/plugin-sdk'
import { $boardSlug, bindApi, boardKey, fetchBoard } from './api'
import { KanbanBoardPage } from './board'
import { KANBAN_LOCALES } from './i18n'
import { $newTaskLane, useKanban } from './ui'
// Live "N running / ready" pill — one glance at fleet activity from anywhere,
// clicks through to the board. Shares the board query (one cache, one poll with
// the page); hidden when nothing is in flight (or unloaded).
function KanbanCount() {
const k = useKanban()
const slug = useValue($boardSlug)
// Socket-invalidated like the page (same cache); slow socketless heartbeat.
const { data: board } = useQuery({
queryFn: () => fetchBoard(false),
queryKey: boardKey(slug, false),
refetchInterval: 60_000
})
if (!board) {
return null
}
const count = (name: string) => board.columns.find(col => col.name === name)?.tasks.length ?? 0
const active = count('running') + count('ready')
if (active === 0) {
return null
}
return (
<Tip label={k.countTip(count('running'), count('ready'))}>
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={() => host.navigate('/kanban')}
type="button"
>
<Codicon name="project" size="0.7rem" />
<span>{active}</span>
</button>
</Tip>
)
}
const plugin: HermesPlugin = {
id: 'kanban',
name: 'Kanban',
defaultEnabled: false,
register(ctx) {
ctx.i18n.register(KANBAN_LOCALES)
ctx.onDispose(bindApi(ctx.rest, ctx.storage, ctx.socket))
// The plugin command pattern: ONE action id (`kanban.newTask`) wired into
// two areas — a keybind (dispatch + rebindable panel row) and a palette row
// whose `action` field points back at it, so ⌘K shows the live combo. The
// handler is route-independent: it navigates to the page and parks the
// request in `$newTaskLane`, so the hotkey works from anywhere, not just
// while the board happens to be mounted.
//
// ⌘⌥N / Ctrl+Alt+N: `mod+n` is `session.new` and `mod+shift+n` is
// `session.newWindow`, both core built-ins a plugin can't shadow. Adding
// Alt keeps the "N for new" mnemonic on a chord core leaves free — it uses
// `alt` only for the `mod+alt+1…9` profile slots, never with a letter. That
// makes ⌘⌥<letter> the natural namespace for plugin commands.
const newTask = () => {
$newTaskLane.set('triage')
host.navigate('/kanban')
}
ctx.registerMany([
{
id: 'page',
area: ROUTES_AREA,
data: { path: '/kanban' } satisfies RouteContribution,
render: () => <KanbanBoardPage />
},
{
id: 'nav',
area: SIDEBAR_NAV_AREA,
order: 50,
data: { codicon: 'project', label: 'Kanban', path: '/kanban' } satisfies SidebarNavContribution
},
{
id: 'count',
area: STATUSBAR_AREAS.right,
order: 80,
render: () => <KanbanCount />
},
{
id: 'open',
area: PALETTE_AREA,
data: {
id: 'kanban.open',
label: 'Kanban: Open board',
keywords: ['kanban', 'board', 'tasks', 'agents'],
run: () => host.navigate('/kanban')
} satisfies PaletteContribution
},
{
id: 'new-task',
area: PALETTE_AREA,
data: {
id: 'kanban.newTask',
action: 'kanban.newTask',
label: ctx.i18n.t('newTaskCommand'),
keywords: ['kanban', 'task', 'new', 'create', 'triage'],
run: newTask
} satisfies PaletteContribution
},
{
id: 'new-task',
area: KEYBINDS_AREA,
data: {
id: 'kanban.newTask',
category: 'view',
defaults: ['mod+alt+n'],
label: ctx.i18n.t('newTaskCommand'),
run: newTask
} satisfies KeybindContribution
}
])
}
}
export default plugin

View file

@ -0,0 +1,209 @@
/** The slice of the kanban REST contract the board renders. The backend
* (`plugins/kanban/dashboard/plugin_api.py`) returns much more per task; we
* type only what the UI reads so a schema addition never breaks the build. */
/** One card. `status` is the column id (see COLUMN_META). */
export interface KanbanTask {
id: string
title: string
body?: null | string
status: string
assignee?: null | string
priority?: number
tenant?: null | string
created_at?: number
latest_summary?: null | string
comment_count?: number
link_counts?: { parents: number; children: number }
/** N-of-M child completion, or null when the task has no children. */
progress?: null | { done: number; total: number }
/** Compact diagnostics rollup — present only when a card has warnings. */
warnings?: null | { count: number; highest_severity?: null | string }
/** Worker liveness (present on running cards) — drives the arc + run clock. */
started_at?: null | number
worker_pid?: null | number
last_heartbeat_at?: null | number
}
export interface KanbanColumn {
name: string
tasks: KanbanTask[]
}
export interface KanbanBoard {
columns: KanbanColumn[]
tenants: string[]
assignees: string[]
latest_event_id: number
now: number
}
/** A structured recovery action attached to a diagnostic. */
export interface DiagnosticAction {
kind: string
label: string
payload?: Record<string, unknown>
suggested?: boolean
}
/** One active distress signal on a task (kanban_diagnostics.Diagnostic). */
export interface Diagnostic {
kind: string
severity: 'critical' | 'error' | 'warning'
title: string
detail: string
actions: DiagnosticAction[]
count: number
last_seen_at: number
data: Record<string, unknown>
}
export interface KanbanRun {
id: number | string
profile?: null | string
status: string
outcome?: null | string
summary?: null | string
error?: null | string
metadata?: null | Record<string, unknown> | string
worker_pid?: null | number
started_at?: null | number
ended_at?: null | number
}
export interface KanbanComment {
id: number | string
author: string
body: string
created_at: number
}
export interface KanbanEvent {
id: number
kind: string
payload: unknown
created_at: number
}
export interface KanbanAttachment {
id: number | string
filename: string
size?: null | number
}
/** Fields present only on the detail endpoint (beyond the card's KanbanTask).
* `started_at`/`worker_pid`/`last_heartbeat_at` are inherited they live on
* KanbanTask now that the board's liveness arc reads them. */
export interface KanbanTaskFull extends KanbanTask {
result?: null | string
created_by?: null | string
completed_at?: null | number
last_failure_error?: null | string
workspace_kind?: null | string
workspace_path?: null | string
branch_name?: null | string
consecutive_failures?: number
diagnostics?: Diagnostic[]
}
/** GET /tasks/:id the task plus its related collections, which are SIBLINGS
* of `task`, not nested inside it. */
export interface KanbanTaskDetail {
task: KanbanTaskFull
comments: KanbanComment[]
events: KanbanEvent[]
attachments: KanbanAttachment[]
links: { parents: string[]; children: string[] }
runs: KanbanRun[]
}
/** GET /boards — every board on disk + which one is the server's current. */
export interface BoardMeta {
slug: string
name?: null | string
description?: null | string
is_current?: boolean
total?: number
/** Board-level project directory new tasks inherit (empty = none). */
default_workdir?: null | string
/** Recommended workspace kind derived from default_workdir by the backend
* (`scratch` when unset, `worktree` in a git repo, else `dir`). */
default_workspace_kind?: null | string
/** First-class Project the board is scoped to (id) + resolved name. */
project_id?: null | string
project_name?: null | string
}
/** GET /projects — first-class Hermes projects available to scope a board. */
export interface KanbanProject {
id: string
slug: string
name: string
primary_path?: null | string
icon?: null | string
color?: null | string
}
/** POST /tasks/:id/estimate — rough auxiliary-model estimate (never dollars). */
export interface TaskEstimate {
ok: boolean
reason?: null | string
est_tokens?: number
complexity?: 'L' | 'M' | 'S' | null
rationale?: null | string
model?: null | string
}
export interface BoardsResponse {
boards: BoardMeta[]
current: string
}
/** GET /tasks/:id/log — the worker's stdout/stderr tail. */
export interface WorkerLog {
exists: boolean
size_bytes: number
content: string
truncated: boolean
}
/** GET /orchestration — dispatcher knobs from config.yaml + resolved values. */
export interface OrchestrationSettings {
orchestrator_profile: string
default_assignee: string
auto_decompose: boolean
resolved_orchestrator_profile: string
resolved_default_assignee: string
}
/** GET /profiles — the roster the decomposer routes across. */
export interface KanbanProfile {
name: string
is_default: boolean
description: string
description_auto: boolean
}
/** Column presentation codicon + tone only. Labels + help live in i18n
* (plugin bundles); see `columnLabel`/`columnHelp` in i18n.ts. Order follows
* the backend's BOARD_COLUMNS; anything the backend adds renders via the
* fallback. */
export const COLUMN_META: Record<string, { codicon: string; tone: string }> = {
triage: { codicon: 'inbox', tone: 'var(--ui-text-tertiary)' },
todo: { codicon: 'circle-outline', tone: 'var(--ui-text-secondary)' },
scheduled: { codicon: 'watch', tone: '#a78bfa' },
ready: { codicon: 'play-circle', tone: '#60a5fa' },
running: { codicon: 'sync', tone: '#34d399' },
blocked: { codicon: 'error', tone: '#f87171' },
review: { codicon: 'eye', tone: '#fbbf24' },
done: { codicon: 'pass', tone: 'var(--ui-text-tertiary)' },
archived: { codicon: 'archive', tone: 'var(--ui-text-quaternary)' }
}
export const columnMeta = (name: string) =>
COLUMN_META[name] ?? { codicon: 'circle-outline', tone: 'var(--ui-text-secondary)' }
export const SEVERITY_TONE: Record<Diagnostic['severity'], string> = {
critical: 'var(--destructive, #f87171)',
error: 'var(--destructive, #f87171)',
warning: '#fbbf24'
}

View file

@ -0,0 +1,336 @@
/** Shared kanban UI atoms: formatters, the identity avatar, the status menu,
* section chrome, and the masked scroller. Pure SDK + tokens. */
import {
atom,
coarseElapsed,
Codicon,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
profileColor,
profileColorSoft,
relativeTime,
useQuery
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { fetchOrchestration, ORCHESTRATION_KEY } from './api'
import { columnLabel, useKanban } from './i18n'
import { columnMeta, type KanbanTask } from './types'
// Plugin-scoped i18n lives in ./i18n; re-exported so components import strings
// and chrome from one place (./ui).
export { columnHelp, columnLabel, type KanbanText, lockedReason, useKanban } from './i18n'
/** One-shot "open the new-task dialog in this lane" request, so a command that
* fires from ANYWHERE (keybind, palette) can reach the board page without the
* page having to exist yet: the handler navigates and drops the lane here, the
* page consumes it on arrival and clears it. Ephemeral by design never
* persisted, so a remount can't reopen a dialog the user already dismissed. */
export const $newTaskLane = atom<null | string>(null)
/** Orchestration knobs (cached app-wide; the settings panel invalidates). */
export function useOrchestration() {
return useQuery({ queryKey: ORCHESTRATION_KEY, queryFn: fetchOrchestration, staleTime: 60_000 }).data
}
/** The dispatcher's configured fallback for unassigned ready cards
* (`kanban.default_assignee`) '' when unset, i.e. unassigned never runs. */
export function useDefaultAssignee(): string {
return useOrchestration()?.default_assignee.trim() ?? ''
}
// System-owned drop targets — you can drag a card OUT of these, never INTO
// them, so lanes/menus must not offer them as targets. `running`/`review` are
// claimed by the dispatcher; `scheduled` needs a wake-up time only an agent or
// the CLI can attach (a bare status drag is refused with a 409). The reason
// copy lives in the plugin i18n bundle (`locked.*`); see `lockedReason`.
export const LOCKED_COLUMNS = ['review', 'running', 'scheduled'] as const
export const isLockedTarget = (name: string): boolean => (LOCKED_COLUMNS as readonly string[]).includes(name)
export const shortId = (id?: null | string) => (id ?? '').replace(/^t_/, '').slice(0, 6)
// The electron REST bridge throws `Error("409: {\"detail\":\"…\"}")`; pull out
// the human-readable detail for a toast.
export function errText(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err)
const brace = raw.indexOf('{')
if (brace !== -1) {
try {
return (JSON.parse(raw.slice(brace)) as { detail?: string }).detail ?? raw
} catch {
// Not JSON — fall through to the raw message.
}
}
return raw
}
/** Backend timestamps are epoch SECONDS; the canonical formatter takes ms. */
export const ago = (seconds?: null | number): null | string => (seconds ? relativeTime(seconds * 1000) : null)
const ELAPSED_SUFFIX = { day: 'd', hour: 'h', minute: 'm', second: 's' } as const
/** Compact run duration ("42s", "3m") off the canonical elapsed bucketing. */
export function duration(start?: null | number, end?: null | number): null | string {
if (!start || !end || end < start) {
return null
}
const { unit, value } = coarseElapsed((end - start) * 1000)
return `${value}${ELAPSED_SUFFIX[unit]}`
}
// ── liveness ─────────────────────────────────────────────────────────────────
/** Live elapsed label ("34s", "2m") that keeps ticking while mounted. */
function useTicking(start?: null | number): null | string {
const [, force] = useState(0)
useEffect(() => {
if (!start) {
return
}
const id = window.setInterval(() => force(n => n + 1), 5_000)
return () => window.clearInterval(id)
}, [start])
if (!start) {
return null
}
const { unit, value } = coarseElapsed(Math.max(0, Date.now() - start * 1000))
return `${value}${ELAPSED_SUFFIX[unit]}`
}
export type ArcState = 'queued' | 'running' | 'stale'
/**
* The card's machine-activity state. The board looked dead between "I made a
* card" and "it's suddenly running" this narrates the in-between. Only the
* working states animate the border arc (see kanban.css): running = brisk
* sweep, no-heartbeat = amber crawl. `queued` (triage / assigned-ready /
* review) renders as the footer's named-agent chip motion means work.
*/
export function arcState(task: KanbanTask, fallbackAssignee: string): ArcState | null {
if (task.status === 'running') {
// No heartbeat for 2+ min = the worker may have died; the dispatcher will
// reclaim it, but be honest instead of sweeping green forever.
const stale = task.last_heartbeat_at ? Date.now() / 1000 - task.last_heartbeat_at > 120 : false
return stale ? 'stale' : 'running'
}
const queued =
task.status === 'triage' ||
task.status === 'review' ||
(task.status === 'ready' && Boolean(task.assignee || fallbackAssignee))
return queued ? 'queued' : null
}
/** Ticking "working · 34s" line for running cards (elapsed since claim). */
export function RunClock({ task }: { task: KanbanTask }) {
const k = useKanban()
const elapsed = useTicking(task.started_at)
if (!elapsed) {
return null
}
return (
<span className="shrink-0 font-medium" style={{ color: columnMeta('running').tone }}>
{k.working} · {elapsed}
</span>
)
}
function initials(name: string): string {
const parts = name
.trim()
.split(/[\s_\-./]+/)
.filter(Boolean)
return `${parts[0]?.[0] ?? '?'}${parts[1]?.[0] ?? ''}`.toUpperCase()
}
export function Avatar({ name, size = '1.25rem' }: { name: string; size?: string }) {
// Same identity hue the rest of the app uses (profileColor); default/empty
// profiles are neutral. Soft tag fill + colored glyph, per the app's tags.
const color = profileColor(name)
return (
<span
className="grid shrink-0 place-items-center rounded-full font-semibold"
style={{
backgroundColor: color ? profileColorSoft(color, 22) : 'var(--ui-bg-quaternary)',
color: color ?? 'var(--ui-text-secondary)',
fontSize: '0.5625rem',
height: size,
width: size
}}
title={name}
>
{initials(name)}
</span>
)
}
// Jira-style status control: a colored button showing the current state, click
// to transition. Options carry their column dot; the active one is checked.
export function StatusMenu({
columns,
onMove,
status
}: {
columns: string[]
onMove: (status: string) => void
status: string
}) {
const k = useKanban()
const meta = columnMeta(status)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[0.6875rem] font-semibold uppercase tracking-wide transition-[filter] hover:brightness-105"
style={{ backgroundColor: `color-mix(in srgb, ${meta.tone} 15%, transparent)`, color: meta.tone }}
type="button"
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: meta.tone }} />
{columnLabel(k, status)}
<Codicon name="chevron-down" size="0.7rem" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{columns
.filter(name => name === status || !isLockedTarget(name))
.map(name => (
<DropdownMenuItem key={name} onSelect={() => onMove(name)}>
<span className="size-2 rounded-full" style={{ backgroundColor: columnMeta(name).tone }} />
{columnLabel(k, name)}
{name === status && <Codicon className="ml-auto" name="check" size="0.8rem" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
// The board's one field/section-label style — hoisted so Section (here), the
// create dialog's Field, and the orchestration panel all read identically.
export const FIELD_LABEL = 'text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-(--ui-text-quaternary)'
export function Section({ action, children, label }: { action?: ReactNode; children: ReactNode; label: string }) {
return (
<section className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className={FIELD_LABEL}>{label}</div>
{action}
</div>
{children}
</section>
)
}
// Tinted advisory panel: a `tone`-washed body with a matching left rule and a
// tone-colored icon+title header. Shared by the drawer's diagnostics and its
// ready-but-unassigned warning so both read identically.
export function Callout({
children,
icon = 'warning',
title,
tone
}: {
children?: ReactNode
icon?: string
title: ReactNode
tone: string
}) {
return (
<div
className="flex flex-col gap-2 rounded-md p-2.5"
style={{ backgroundColor: `color-mix(in srgb, ${tone} 7%, transparent)`, borderLeft: `2px solid ${tone}` }}
>
<div className="flex items-start gap-1.5 text-[0.75rem] font-medium" style={{ color: tone }}>
<Codicon className="mt-px shrink-0" name={icon} size="0.8rem" />
<span>{title}</span>
</div>
{children}
</div>
)
}
// A short, edge-masked scroll area. The fades are EDGE-AWARE like the rest of
// the app: a gradient only appears on a side that actually has clipped content
// (nothing to scroll → no mask at all), tracked via scroll + resize. Plus
// `overscroll-contain` so scrolling it never chains into the drawer. When
// `deps` is provided it re-pins to the bottom on change — the activity feed's
// newest-at-bottom behavior.
export function ScrollFade({ children, deps, max = '9rem' }: { children: ReactNode; deps?: unknown; max?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [edges, setEdges] = useState({ above: false, below: false })
const measure = () => {
const el = ref.current
if (!el) {
return
}
const above = el.scrollTop > 1
const below = el.scrollTop + el.clientHeight < el.scrollHeight - 1
setEdges(prev => (prev.above === above && prev.below === below ? prev : { above, below }))
}
useLayoutEffect(() => {
if (deps !== undefined && ref.current) {
ref.current.scrollTop = ref.current.scrollHeight
}
measure()
}, [deps])
useLayoutEffect(() => {
const el = ref.current
if (!el) {
return
}
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
const stops = [
edges.above ? 'transparent, black 1.25rem' : 'black',
edges.below ? 'calc(100% - 1.25rem), transparent' : 'black'
]
const mask = `linear-gradient(to bottom, ${stops[0]}, black ${stops[1]})`
return (
<div
className="overflow-y-auto overscroll-contain"
onScroll={measure}
ref={ref}
style={
edges.above || edges.below ? { maskImage: mask, maxHeight: max, WebkitMaskImage: mask } : { maxHeight: max }
}
>
{children}
</div>
)
}

View file

@ -200,6 +200,9 @@ export type {
* id with your plugin slug (`kanban:board-switcher`). */
export { Contribute, type ContributeProps } from '@/contrib/react/contribute'
export type { Contribution } from '@/contrib/types'
/** Grab-to-pan for overflow containers (boards, timelines, wide tables)
* the shared scrub primitive; don't hand-roll drag-to-scroll. */
export { type GrabScroll, useGrabScroll } from '@/hooks/use-grab-scroll'
/** Localized copy. `useI18n` reuses the app's strings; `usePluginI18n(id)` +
* `ctx.i18n.register` let a plugin ship its OWN locale bundles, scoped like
* `ctx.storage` and resolved against the app's active locale no core edit. */
@ -213,6 +216,9 @@ export {
useI18n,
usePluginI18n
} from '@/i18n'
/** THE compact-number formatter every user-facing count/token figure goes
* through here (1230 "1.2k", 1_500_000 "1.5M"). Don't hand-roll `/1000`. */
export { compactNumber } from '@/lib/format'
export { triggerHaptic as haptic } from '@/lib/haptics'
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
export * as icons from '@/lib/icons'
@ -234,8 +240,6 @@ export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left'
* setup.runtime_check, reconciled) pass `host.request`. Don't hand-roll
* readiness from raw RPC shapes. */
export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
/** Canonical time formatting every timestamp/age string in the app comes
* from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */
export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time'
export { cn } from '@/lib/utils'
export { THEMES_AREA } from '@/themes/user-themes'

View file

@ -673,6 +673,11 @@ def read_board_metadata(board: Optional[str] = None) -> dict:
"icon": "",
"color": "",
"default_workdir": None,
# Optional first-class Project this board is scoped to. When set, new
# tasks inherit it (deterministic worktree + branch under the project's
# primary repo) and ``default_workdir`` mirrors the project's primary
# path so the persistent-workspace inheritance path keeps working.
"project_id": None,
"created_at": None,
"archived": False,
}
@ -700,11 +705,16 @@ def write_board_metadata(
color: Optional[str] = None,
archived: Optional[bool] = None,
default_workdir: Optional[str] = None,
project_id: Optional[str] = None,
) -> dict:
"""Create / update ``board.json`` for ``board``.
Preserves any existing fields not mentioned in the call. Sets
``created_at`` on first write. Returns the resulting metadata dict.
``project_id``: ``None`` leaves it unchanged; empty string clears the
project scope; a value sets it (not validated here the caller resolves
it against ``projects_db``).
"""
_assert_not_delegated_child_mutation()
slug = _normalize_board_slug(board) or DEFAULT_BOARD
@ -724,6 +734,8 @@ def write_board_metadata(
meta["archived"] = bool(archived)
if default_workdir is not None:
meta["default_workdir"] = str(default_workdir) if default_workdir else None
if project_id is not None:
meta["project_id"] = str(project_id) if project_id else None
if not meta.get("created_at"):
meta["created_at"] = int(time.time())
path = board_metadata_path(slug)
@ -744,6 +756,7 @@ def create_board(
icon: Optional[str] = None,
color: Optional[str] = None,
default_workdir: Optional[str] = None,
project_id: Optional[str] = None,
) -> dict:
"""Create a new board directory + DB + metadata. Idempotent.
@ -761,6 +774,7 @@ def create_board(
icon=icon,
color=color,
default_workdir=default_workdir,
project_id=project_id,
)
# Touch the DB so list_boards() sees it immediately.
init_db(board=normed)
@ -2900,6 +2914,18 @@ def create_task(
if branch_name and workspace_kind != "worktree":
raise ValueError("branch_name is only valid for worktree workspaces")
# Inherit the board's scoped project when the caller didn't name one, so a
# project-scoped board anchors every new task to that project's repo
# (deterministic worktree + branch) without each surface repeating it.
if project_id is None:
try:
_bmeta = read_board_metadata(board if board else get_current_board())
_board_project = (_bmeta.get("project_id") or "").strip()
if _board_project:
project_id = _board_project
except Exception:
pass
# Resolve an optional first-class Project link. A project-linked task is
# anchored to the project's primary repo as a git worktree, so its branch
# can be named deterministically (project slug + task id) instead of the
@ -3554,6 +3580,33 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
]
def list_comments_after(
conn: sqlite3.Connection, task_id: str, *, after_id: int = 0
) -> list[Comment]:
"""Return comments on ``task_id`` with ``id > after_id`` (ascending).
Keyed on the monotonic rowid rather than ``created_at`` so a same-second
burst can't be skipped. Used by the live worker bridge to fold new
operator notes into a running task without a restart (see
``tools.kanban_tools.inject_new_comments_from_env``).
"""
rows = conn.execute(
"SELECT id, task_id, author, body, created_at FROM task_comments "
"WHERE task_id = ? AND id > ? ORDER BY id ASC",
(task_id, int(after_id)),
).fetchall()
return [
Comment(
id=r["id"],
task_id=r["task_id"],
author=r["author"],
body=r["body"],
created_at=r["created_at"],
)
for r in rows
]
# ---------------------------------------------------------------------------
# Attachments
# ---------------------------------------------------------------------------

View file

@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
# Explicit project link; when omitted, create_task inherits the board's
# scoped project (if any) so a project-scoped board anchors every task.
project_id: Optional[str] = None
@router.post("/tasks")
@ -636,6 +639,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
goal_max_turns=payload.goal_max_turns,
model_override=payload.model_override,
provider_override=payload.provider_override,
project_id=payload.project_id,
board=board,
)
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
@ -1735,6 +1740,134 @@ def reassign_task_endpoint(
conn.close()
# ---------------------------------------------------------------------------
# Estimate — a rough token/complexity estimate for a task via the auxiliary
# (auto-routed) model. NOT a dollar cost: providers don't report cost
# reliably, so we estimate tokens + a complexity band with a one-line why.
# ---------------------------------------------------------------------------
_ESTIMATE_SYSTEM_PROMPT = (
"You estimate how much work an autonomous coding agent will spend on a "
"kanban task. Given the task title and description, respond with STRICT "
"JSON only (no prose, no code fence):\n"
'{"est_tokens": <integer total tokens across the whole run>, '
'"complexity": "S"|"M"|"L", '
'"rationale": "<one short sentence>"}\n'
"Base the token figure on a realistic multi-turn agent run (reading files, "
"tool calls, edits, retries) — not a single reply. S≈small/localized, "
"M≈multi-file, L≈broad or ambiguous. Be honest that this is a rough guess."
)
class EstimateBody(BaseModel):
title: str = ""
body: Optional[str] = None
@router.post("/estimate")
def estimate_text_endpoint(payload: EstimateBody):
"""Estimate from raw title/body — used by the create dialog before a task
exists yet. Same outcome shape as the per-task endpoint below."""
return _run_estimate(payload.title, payload.body)
@router.post("/tasks/{task_id}/estimate")
def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)):
"""Rough token + complexity estimate for an existing task via the auxiliary
model. Returns ``{ok, est_tokens, complexity, rationale, model}``; a non-OK
outcome is NOT an HTTP error. Runs in FastAPI's threadpool (sync ``def``)
because the LLM call can take several seconds.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
task = kanban_db.get_task(conn, task_id)
finally:
conn.close()
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return _run_estimate(task.title, task.body)
def _run_estimate(title: str, body: Optional[str]) -> dict:
"""Shared estimate core: ask the auto-routed auxiliary model for a rough
token + complexity read on a task described by ``title``/``body``.
Never raises a bad config / parse / API error becomes
``{"ok": False, "reason": ...}`` so the UI can render it inline.
"""
if not (title or "").strip():
return {"ok": False, "reason": "a title is required to estimate"}
try:
from agent.auxiliary_client import call_llm
except Exception:
return {"ok": False, "reason": "auxiliary client unavailable"}
def _cap(s: Optional[str], n: int) -> str:
s = (s or "").strip()
return s if len(s) <= n else s[:n] + ""
user_msg = (
f"Title: {_cap(title, 400)}\n\n"
f"Description:\n{_cap(body, 4000) or '(none)'}"
)
try:
resp = call_llm(
task="kanban_estimator",
messages=[
{"role": "system", "content": _ESTIMATE_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.0,
max_tokens=300,
timeout=60,
)
except Exception as exc:
return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"}
try:
raw = (resp.choices[0].message.content or "").strip()
model = getattr(resp, "model", None)
except Exception:
raw, model = "", None
# Reuse the same tolerant JSON-blob extraction the specifier uses.
parsed: Optional[dict] = None
try:
import json as _json
import re as _re
blob = raw
if not blob.lstrip().startswith("{"):
m = _re.search(r"\{.*\}", blob, _re.DOTALL)
blob = m.group(0) if m else blob
obj = _json.loads(blob)
if isinstance(obj, dict):
parsed = obj
except Exception:
parsed = None
if not parsed:
return {"ok": False, "reason": "could not parse an estimate from the model"}
try:
est_tokens = int(parsed.get("est_tokens") or 0)
except (TypeError, ValueError):
est_tokens = 0
complexity = str(parsed.get("complexity") or "").strip().upper()
if complexity not in {"S", "M", "L"}:
complexity = None
rationale = str(parsed.get("rationale") or "").strip() or None
return {
"ok": True,
"est_tokens": est_tokens,
"complexity": complexity,
"rationale": rationale,
"model": model,
}
# ---------------------------------------------------------------------------
# Plugin config (read dashboard.kanban.* defaults from config.yaml)
# ---------------------------------------------------------------------------
@ -2078,6 +2211,10 @@ class CreateBoardBody(BaseModel):
icon: Optional[str] = None
color: Optional[str] = None
default_workdir: Optional[str] = None
# First-class Project (id or slug) to scope the board to. When set, the
# board's default_workdir mirrors the project's primary repo and new tasks
# inherit the project (deterministic worktree + branch).
project_id: Optional[str] = None
switch: bool = False
@ -2089,6 +2226,38 @@ class RenameBoardBody(BaseModel):
# Board-level default project directory for new tasks. ``None`` =
# leave unchanged; empty string = clear; a path = validate + set.
default_workdir: Optional[str] = None
# Project scope (id or slug). ``None`` = leave unchanged; empty = clear;
# a value = resolve + set (and mirror default_workdir to its primary repo).
project_id: Optional[str] = None
def _resolve_project(ref: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve a project id/slug to ``(id, name, primary_path)``.
Returns ``(None, None, None)`` for a falsy ref. Raises 400 when a
non-empty ref doesn't resolve to an existing project.
"""
if not ref or not ref.strip():
return None, None, None
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
proj = pdb.get_project(pconn, ref.strip())
except Exception as exc:
raise HTTPException(status_code=500, detail=f"projects unavailable: {exc}")
if proj is None:
raise HTTPException(status_code=400, detail=f"project {ref!r} does not exist")
return proj.id, proj.name, (proj.primary_path or None)
def _projects_by_id() -> dict[str, Any]:
"""Map every project id -> Project (archived included) for annotation."""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
return {p.id: p for p in pdb.list_projects(pconn, include_archived=True)}
except Exception:
return {}
def _board_counts(slug: str) -> dict[str, int]:
@ -2120,16 +2289,54 @@ def _default_workspace_kind(board: dict[str, Any]) -> str:
return "dir"
@router.get("/projects")
def list_kanban_projects():
"""List first-class Hermes projects for board scoping.
Returns ``{projects: [{id, slug, name, primary_path, icon, color}]}``.
Archived projects are excluded a board can only be scoped to a live one.
"""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
projects = pdb.list_projects(pconn, include_archived=False)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"failed to list projects: {exc}")
return {
"projects": [
{
"id": p.id,
"slug": p.slug,
"name": p.name,
"primary_path": p.primary_path or "",
"icon": p.icon or "",
"color": p.color or "",
}
for p in projects
]
}
@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Return every board on disk with task counts and the active slug."""
boards = kanban_db.list_boards(include_archived=include_archived)
current = kanban_db.get_current_board()
proj_map = _projects_by_id()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
b["total"] = sum(b["counts"].values())
# Live cards only — archived tasks are hidden from every default
# board view, so advertising them in the switcher badge makes the
# two counts visibly disagree.
b["total"] = sum(
n for status, n in b["counts"].items() if status != "archived"
)
b["default_workspace_kind"] = _default_workspace_kind(b)
pid = b.get("project_id") or None
b["project_id"] = pid
proj = proj_map.get(pid) if pid else None
b["project_name"] = proj.name if proj else None
return {"boards": boards, "current": current}
@ -2159,6 +2366,11 @@ def create_board_endpoint(payload: CreateBoardBody):
default_workdir = None
if payload.default_workdir:
default_workdir = _validate_workdir(payload.default_workdir)
# A chosen project scopes the board: its primary repo becomes the default
# workdir (unless one was passed explicitly) and the link is stored.
project_id, _pname, primary_path = _resolve_project(payload.project_id)
if primary_path and not default_workdir:
default_workdir = primary_path
try:
meta = kanban_db.create_board(
payload.slug,
@ -2167,6 +2379,7 @@ def create_board_endpoint(payload: CreateBoardBody):
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
project_id=project_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@ -2176,6 +2389,7 @@ def create_board_endpoint(payload: CreateBoardBody):
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return {"board": meta, "current": kanban_db.get_current_board()}
@ -2194,6 +2408,17 @@ def rename_board(slug: str, payload: RenameBoardBody):
if payload.default_workdir is not None:
raw = payload.default_workdir.strip()
default_workdir = _validate_workdir(raw) if raw else ""
# project_id: None = leave; "" = clear; value = resolve + mirror its repo
# into default_workdir (unless the caller set default_workdir explicitly).
project_id: Optional[str] = None
project_name: Optional[str] = None
if payload.project_id is not None:
if payload.project_id.strip():
project_id, project_name, primary_path = _resolve_project(payload.project_id)
if primary_path and default_workdir is None:
default_workdir = primary_path
else:
project_id = "" # clear the scope
meta = kanban_db.write_board_metadata(
normed,
name=payload.name,
@ -2201,8 +2426,10 @@ def rename_board(slug: str, payload: RenameBoardBody):
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
project_id=project_id,
)
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return {"board": meta}

View file

@ -3540,8 +3540,14 @@ class AIAgent:
self._last_activity_desc = desc
if os.environ.get("HERMES_KANBAN_TASK"):
try:
from tools.kanban_tools import heartbeat_current_worker_from_env
from tools.kanban_tools import (
heartbeat_current_worker_from_env,
inject_new_comments_from_env,
)
heartbeat_current_worker_from_env()
# Fold any new operator notes into the running turn (OUT-OF-BAND
# steer) so the user can talk to a live task without a restart.
inject_new_comments_from_env(self)
except Exception:
# Never let the bridge break the agent loop. The function
# already swallows exceptions internally; this outer guard

View file

@ -0,0 +1,87 @@
"""Board→project scoping in kanban_db.
A kanban board can be scoped to a first-class Hermes project so every task on
it anchors to that project (deterministic worktree + branch). Covers the
metadata round-trip and the create-time inheritance.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
from hermes_cli import projects_db as pdb
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
monkeypatch.delenv(var, raising=False)
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
kb._INITIALIZED_PATHS.clear()
return home
def test_board_metadata_project_id_roundtrip(fresh_home):
assert kb.read_board_metadata("default").get("project_id") is None
kb.write_board_metadata("default", project_id="p_abc123")
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
# None leaves unchanged; "" clears.
kb.write_board_metadata("default", name="Still Here")
assert kb.read_board_metadata("default")["project_id"] == "p_abc123"
kb.write_board_metadata("default", project_id="")
assert kb.read_board_metadata("default")["project_id"] is None
def test_create_board_accepts_project_id(fresh_home):
meta = kb.create_board("proj-board", name="Proj Board", project_id="p_xyz")
assert meta["project_id"] == "p_xyz"
assert kb.read_board_metadata("proj-board")["project_id"] == "p_xyz"
def test_create_task_inherits_board_project(fresh_home, tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
with pdb.connect_closing() as pconn:
proj_id = pdb.create_project(pconn, name="Widget", primary_path=str(repo))
kb.create_board("scoped", name="Scoped", project_id=proj_id)
conn = kb.connect(board="scoped")
try:
tid = kb.create_task(conn, title="inherit me", board="scoped")
assert kb.get_task(conn, tid).project_id == proj_id
finally:
conn.close()
def test_create_task_explicit_project_beats_board(fresh_home, tmp_path):
(tmp_path / "a").mkdir()
(tmp_path / "b").mkdir()
with pdb.connect_closing() as pconn:
board_proj = pdb.create_project(pconn, name="BoardProj", primary_path=str(tmp_path / "a"))
task_proj = pdb.create_project(pconn, name="TaskProj", primary_path=str(tmp_path / "b"))
kb.create_board("scoped2", name="Scoped2", project_id=board_proj)
conn = kb.connect(board="scoped2")
try:
tid = kb.create_task(conn, title="explicit", board="scoped2", project_id=task_proj)
assert kb.get_task(conn, tid).project_id == task_proj
finally:
conn.close()

View file

@ -0,0 +1,54 @@
"""Comment-watermark queries in kanban_db.
``list_comments_after`` backs the live worker bridge: it returns only comments
newer than a cursor so a running worker folds in new operator notes without
re-reading the whole thread.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
monkeypatch.delenv(var, raising=False)
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
kb._INITIALIZED_PATHS.clear()
return home
def test_list_comments_after_cursor(fresh_home):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="chat")
c1 = kb.add_comment(conn, tid, author="alice", body="first")
c2 = kb.add_comment(conn, tid, author="bob", body="second")
assert [c.id for c in kb.list_comments_after(conn, tid, after_id=0)] == [c1, c2]
newer = kb.list_comments_after(conn, tid, after_id=c1)
assert [c.id for c in newer] == [c2]
assert newer[0].body == "second"
assert kb.list_comments_after(conn, tid, after_id=c2) == []
finally:
conn.close()

View file

@ -0,0 +1,119 @@
"""Kanban dashboard plugin: project listing + project-scoped boards.
Attaches the plugin router to a bare FastAPI app (as in
test_kanban_dashboard_plugin.py) and exercises the project surface:
GET /projects, board create/patch/list carrying project scope, and a task
on a scoped board inheriting the project.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
from hermes_cli import projects_db as pdb
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_proj_test", plugin_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
@pytest.fixture
def project(tmp_path):
repo = tmp_path / "widget-repo"
repo.mkdir()
with pdb.connect_closing() as conn:
pid = pdb.create_project(conn, name="Widget", primary_path=str(repo))
return {"id": pid, "primary_path": str(repo)}
def test_list_projects(client, project):
r = client.get("/api/plugins/kanban/projects")
assert r.status_code == 200
hit = next(p for p in r.json()["projects"] if p["id"] == project["id"])
assert hit["name"] == "Widget"
assert hit["primary_path"] == project["primary_path"]
def test_create_board_with_project_mirrors_workdir(client, project):
r = client.post(
"/api/plugins/kanban/boards",
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
)
assert r.status_code == 200, r.text
board = r.json()["board"]
assert board["project_id"] == project["id"]
assert board["project_name"] == "Widget"
assert board["default_workdir"] == project["primary_path"]
def test_create_board_rejects_unknown_project(client):
r = client.post("/api/plugins/kanban/boards", json={"slug": "bad", "project_id": "p_nope"})
assert r.status_code == 400
def test_patch_board_set_and_clear_project(client, project):
client.post("/api/plugins/kanban/boards", json={"slug": "widget", "name": "Widget"})
r = client.patch("/api/plugins/kanban/boards/widget", json={"project_id": project["id"]})
assert r.status_code == 200, r.text
assert r.json()["board"]["project_id"] == project["id"]
r = client.patch("/api/plugins/kanban/boards/widget", json={"project_id": ""})
assert r.status_code == 200
assert r.json()["board"]["project_id"] is None
def test_boards_list_surfaces_project(client, project):
client.post(
"/api/plugins/kanban/boards",
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
)
widget = next(b for b in client.get("/api/plugins/kanban/boards").json()["boards"] if b["slug"] == "widget")
assert widget["project_id"] == project["id"]
assert widget["project_name"] == "Widget"
def test_task_on_scoped_board_inherits_project(client, project):
client.post(
"/api/plugins/kanban/boards",
json={"slug": "widget", "name": "Widget", "project_id": project["id"]},
)
r = client.post("/api/plugins/kanban/tasks?board=widget", json={"title": "do the thing"})
assert r.status_code == 200, r.text
task_id = r.json()["task"]["id"]
conn = kb.connect(board="widget")
try:
assert kb.get_task(conn, task_id).project_id == project["id"]
finally:
conn.close()

View file

@ -0,0 +1,102 @@
"""Kanban dashboard plugin: task effort estimate.
The estimate endpoints call the auto-routed auxiliary model and parse a
compact JSON reply (tokens + complexity + rationale). Tests monkeypatch
``call_llm`` so no network is touched.
"""
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
def _load_plugin_router():
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_est_test", plugin_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
def _fake_resp(content: str, model: str = "aux-mini"):
msg = types.SimpleNamespace(content=content)
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)], model=model)
def test_estimate_parses_model_json(client, monkeypatch):
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "big refactor"}).json()["task"]["id"]
import agent.auxiliary_client as aux
def fake_call_llm(**kwargs):
assert kwargs.get("task") == "kanban_estimator"
return _fake_resp('{"est_tokens": 42000, "complexity": "M", "rationale": "multi-file edit"}')
monkeypatch.setattr(aux, "call_llm", fake_call_llm)
body = client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()
assert body["ok"] is True
assert body["est_tokens"] == 42000
assert body["complexity"] == "M"
assert body["rationale"] == "multi-file edit"
assert body["model"] == "aux-mini"
def test_estimate_tolerates_unparseable_reply(client, monkeypatch):
task_id = client.post("/api/plugins/kanban/tasks", json={"title": "vague"}).json()["task"]["id"]
import agent.auxiliary_client as aux
monkeypatch.setattr(aux, "call_llm", lambda **kw: _fake_resp("I cannot estimate this, sorry."))
assert client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()["ok"] is False
def test_estimate_unknown_task_404(client):
assert client.post("/api/plugins/kanban/tasks/t_missing/estimate").status_code == 404
def test_estimate_from_text_no_task(client, monkeypatch):
"""The create dialog estimates from typed title/body before a task exists."""
import agent.auxiliary_client as aux
monkeypatch.setattr(
aux, "call_llm",
lambda **kw: _fake_resp('{"est_tokens": 8000, "complexity": "S", "rationale": "localized"}'),
)
body = client.post(
"/api/plugins/kanban/estimate", json={"title": "tweak a label", "body": "in settings"}
).json()
assert body["ok"] is True
assert body["est_tokens"] == 8000
assert body["complexity"] == "S"
def test_estimate_from_text_requires_title(client):
assert client.post("/api/plugins/kanban/estimate", json={"title": " ", "body": "x"}).json()["ok"] is False

View file

@ -0,0 +1,124 @@
"""Live operator-note injection into a running kanban worker.
``tools.kanban_tools.inject_new_comments_from_env`` polls the worker's task
for comments added *after* the run started and folds them into the live turn
via the agent's OUT-OF-BAND steer channel — so a user can talk to a running
task without the blockcommentunblock dance or a restart.
Verifies: no-op off a worker, watermark seeding (history isn't re-injected),
new comments steer, and own-authored comments are skipped.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
import tools.kanban_tools as kt
class FakeAgent:
def __init__(self):
self.steers: list[str] = []
def steer(self, text: str) -> bool:
self.steers.append(text)
return True
@pytest.fixture
def worker_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for var in ("HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_HOME", "HERMES_KANBAN_BOARD"):
monkeypatch.delenv(var, raising=False)
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
kb._INITIALIZED_PATHS.clear()
# Reset module-level poll state so tests don't leak into each other.
kt._comment_watermark.clear()
kt._comment_poll_last_attempt = 0.0
return home
def _unthrottle():
"""Bypass the inter-poll rate limit for deterministic tests."""
kt._comment_poll_last_attempt = 0.0
def test_noop_without_worker_env(worker_home, monkeypatch):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
agent = FakeAgent()
assert kt.inject_new_comments_from_env(agent) is False
assert agent.steers == []
def test_seed_then_inject_new_comment(worker_home, monkeypatch):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="live task")
kb.add_comment(conn, tid, author="desktop", body="pre-existing note")
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
monkeypatch.setenv("HERMES_PROFILE", "worker-bot")
agent = FakeAgent()
# First poll seeds the watermark past the existing thread — no injection.
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is False
assert agent.steers == []
conn = kb.connect()
try:
kb.add_comment(conn, tid, author="desktop", body="actually use the v2 API")
finally:
conn.close()
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is True
assert len(agent.steers) == 1
assert "v2 API" in agent.steers[0]
# Watermark advanced — a re-poll with no new comments injects nothing.
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is False
assert len(agent.steers) == 1
def test_skips_own_authored_comments(worker_home, monkeypatch):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="echo guard")
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
monkeypatch.setenv("HERMES_PROFILE", "worker-bot")
agent = FakeAgent()
_unthrottle()
kt.inject_new_comments_from_env(agent) # seed
conn = kb.connect()
try:
kb.add_comment(conn, tid, author="worker-bot", body="i did a thing")
finally:
conn.close()
_unthrottle()
assert kt.inject_new_comments_from_env(agent) is False
assert agent.steers == []

View file

@ -320,6 +320,85 @@ def heartbeat_current_worker_from_env() -> bool:
return False
# Live operator-note injection: poll the worker's task for new comments and
# fold them into the running agent via the OUT-OF-BAND steer channel, so a user
# can "talk to" a running kanban task without the block → comment → unblock
# dance (or a restart). Rate-limited on its own (tighter than the 60s heartbeat
# so notes land within a few seconds), watermarked per task id.
_COMMENT_POLL_MIN_INTERVAL_SECONDS = 6.0
_comment_poll_last_attempt: float = 0.0
# task_id -> highest comment id already seen (seeded on first poll so history
# already present in build_worker_context isn't re-injected).
_comment_watermark: dict[str, int] = {}
def inject_new_comments_from_env(agent: Any) -> bool:
"""Fold new operator comments on the current worker's task into ``agent``.
Best-effort and self-gating: no-op unless this process is a kanban worker
(``HERMES_KANBAN_TASK`` set) and ``agent`` exposes ``steer``. Returns True
if a steer was injected, else False. Never raises into the agent loop.
The first poll only *seeds* the watermark to the newest existing comment
those are already in the worker's context — so only comments added after
the run started are injected. The worker's own authored comments (matched
by ``HERMES_PROFILE``) are skipped to avoid echoing itself.
"""
tid = os.environ.get("HERMES_KANBAN_TASK")
if not tid or agent is None or not hasattr(agent, "steer"):
return False
global _comment_poll_last_attempt
import time as _time
now = _time.monotonic()
if (now - _comment_poll_last_attempt) < _COMMENT_POLL_MIN_INTERVAL_SECONDS:
return False
_comment_poll_last_attempt = now
seen = _comment_watermark.get(tid)
try:
kb, conn = _connect()
try:
rows = kb.list_comments_after(conn, tid, after_id=seen or 0)
finally:
try:
conn.close()
except Exception:
pass
except Exception:
logger.debug("comment-inject: bridge failed", exc_info=True)
return False
if seen is None:
# First poll for this task: seed past the existing thread, inject nothing.
_comment_watermark[tid] = max((c.id for c in rows), default=0)
return False
if not rows:
return False
# Advance the watermark past everything we just read (including our own
# notes) so nothing is re-injected next poll.
_comment_watermark[tid] = max(c.id for c in rows)
own = (os.environ.get("HERMES_PROFILE") or "").strip()
fresh = [c for c in rows if (c.author or "").strip() != own and (c.body or "").strip()]
if not fresh:
return False
lines = [f"- {c.author or 'operator'}: {c.body.strip()}" for c in fresh]
note = (
"New note"
+ ("s" if len(fresh) > 1 else "")
+ " on your kanban task from the operator (delivered mid-run). "
+ "Take it into account for the work you're doing right now:\n"
+ "\n".join(lines)
)
try:
return bool(agent.steer(note))
except Exception:
logger.debug("comment-inject: steer failed", exc_info=True)
return False
def _ok(**fields: Any) -> str:
return json.dumps({"ok": True, **fields})