Skip to content

feat(kanban): surface-agnostic notification substrate + orchestrator supervision loop - #1

Draft
aeberts wants to merge 1497 commits into
mainfrom
feat/kanban-event-substrate
Draft

feat(kanban): surface-agnostic notification substrate + orchestrator supervision loop#1
aeberts wants to merge 1497 commits into
mainfrom
feat/kanban-event-substrate

Conversation

@aeberts

@aeberts aeberts commented Jun 30, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Implements NousResearch#49190. Today, Kanban can only send task notifications to chat apps (Telegram/Discord/Slack). This PR lets those notifications reach any surface — CLI, TUI, web, or an AI orchestrator — and uses that to let an orchestrator wait for its tasks to finish and react, instead of constantly polling.

For example, an orchestrator can sign up for updates on its tasks, hand control back, and be woken again later — either when all of its sub-tasks have finished (so it can review the combined result), or when one gets stuck (so it can step in). It no longer has to repeatedly poll the board with kanban_list to find out what happened. The change is purely additive: it doesn't alter how tasks are scheduled or recorded (the task_events log is untouched) — it only watches the existing system and delivers the notifications.

For reviewers — this PR can be split. It's structured as clean, per-feature commits: the reusable substrate (F01–F06) is self-contained, and the supervision loop (F07–F15, M01) builds on top of it. They're submitted together because the loop is what exercises the substrate end to end, but I'm happy to split this into a substrate-first PR plus a supervision follow-up if that's easier to review — just say the word.

The gap. Kanban already has a durable task-event log and a "claim-once" notification loop, but both are tied to the gateway messaging Platform. A subscription is the tuple (task_id, platform, chat_id, thread_id), and the only runtime consumer is the gateway watcher, which can only deliver to a connected messaging adapter. So every non-gateway surface (CLI, TUI, WebUI, or an orchestrator run) has to reinvent its own partial notifier, and subscriber identity is inferred from whoever calls kanban_create (a worker that creates a child subscribes itself, not the user).

The recommended way to run a dependency graph today, per the kanban tutorial, Story 1, is for a human to script the whole DAG up front (--parent-at-create). The injected KANBAN_GUIDANCE protocol is "decompose, then complete your own task", with no "subscribe, wait for fan-in, then judge or triage" step. A supervising agent's only option is to busy-poll, which burns turns, is unreliable, and ends with its turn so it never sees the result.

This is a recurring gap in practice. The common workarounds are a messaging-platform subscription (kanban notify-subscribe --platform telegram) or a separate CLI watcher (kanban watch --kinds completed), both of which are the gateway-bound paths this substrate generalizes.

The primary use case for this PR is to enable LLM-driven kanban use, reusing as much of the existing kanban tools and code as possible while staying close to the Nous Research Kanban tutorial examples.

Related Issue

Closes NousResearch#49190.

Also references NousResearch#53234, a separate chat -q / openai-codex provider bug that gates the fully-automated live loop (see Limitations). It is not introduced or fixed here.

Type of Change

  • New feature (non-breaking change that adds functionality)

Changes Made

Note: F01-F15 and M01 are internal tracking IDs that group the commits. Issue details (rationale and trade-offs considered) are available on request.

Substrate (reusable; F01-F06).

  • F01 generalize kanban_notify_subs: surrogate id plus subscriber_kind plus target (additive _add_column_if_missing migration; subscriber_kind="gateway" keeps the old 4-tuple as its target, so existing rows are unchanged). hermes_cli/kanban_db.py
  • F02 re-key the claim/cursor (exactly-once) API on the surrogate id; a gateway-tuple shim keeps callers unchanged. hermes_cli/kanban_db.py
  • F03 delivery-adapter registry keyed by subscriber_kind; the gateway watcher becomes the first registered adapter rather than the only path. gateway/kanban_delivery.py, gateway/kanban_watchers.py
  • F04 explicit kanban_subscribe primitive (declare subscriber_kind and target), which fixes the inferred-identity problem at the contract level. hermes_cli/kanban_db.py, hermes_cli/kanban.py
  • F05 / F06 CLI and TUI delivery adapters on a shared, surface-agnostic notice store. gateway/kanban_delivery.py

Supervision loop (built on the substrate; F07, F09-F15, M01).

  • F07 orchestrator supervision subscription: subscribe to a whole subtree, emit a curated fan-in snapshot on child completion. gateway/kanban_delivery.py, hermes_cli/kanban_db.py
  • F09 / F10 the two minimal re-engagement triggers: fan-in ("done, then judge", writes a [kanban:reengage] root handoff) and blocked ("stuck, then triage", a [kanban:triage] handoff). They are mutually exclusive per snapshot. hermes_cli/kanban_db.py, hermes_cli/kanban.py
  • F11 / F12 live engine: the dispatcher auto-wakes an orchestrator-mode supervisor on a blocked child; re-engagement runs in the dispatch tick; subtree subs are delivered live via a non-advancing peek. hermes_cli/kanban_db.py, gateway/kanban_watchers.py
  • F13 agent-facing kanban_subscribe tool plus subscribe-and-yield guidance (parents-at-create). tools/kanban_tools.py, toolsets.py, agent/prompt_builder.py
  • F14 the notifier tick delivers surface-agnostic subs with no messaging platform connected. gateway/kanban_watchers.py
  • M01 / F15 live-wake delivery: supervision notices surface into an idle interactive session (CLI and TUI) at turn boundaries, never mid-turn (see Critical: Background notifications repeatedly overwrite user input, TUI becomes unresponsive NousResearch/hermes-agent#42173), with debounce and a completion-wake. cli.py, tui_gateway/server.py, hermes_cli/kanban_db.py

Reuses upstream's existing past-tense event vocabulary (completed, blocked, and so on); there is no parallel task.done namespace. 25 files changed (9 source, 16 test), about +6.7k/-222 lines. No dependency changes.

Architecture & data flow

These diagrams make two things visual: (1) we extend the existing event/claim/deliver mechanism rather than replace it, and (2) we don't break the one delivery path that exists today (gateway → messaging) — every non-gateway surface is purely additive, gated behind a new registry seam. In the architecture diagrams, nodes shown in green are new in this PR; everything else is unchanged from upstream.

1. Overall architecture

Everything upstream of the delivery step is untouched: the worker/dispatcher, the task_events log, recompute_ready scheduling, the kanban_notify_subs table (additively extended, old rows unchanged), and the watcher's claim/cursor + failure-accounting + multi-board fan-out. The only new seam is the delivery registry keyed by subscriber_kind. The gateway branch through it is the old inlined send, moved verbatim; CLI/TUI/orchestrator are new branches that register without touching the watcher.

Today (upstream)

flowchart LR
  Wk["Worker / dispatcher"] --> TE[("task_events log")]
  TE --> RR["recompute_ready scheduler"]
  NS[("kanban_notify_subs<br/>task_id, platform, chat_id, thread_id")] --> CL
  TE --> CL
  subgraph GW["Gateway notifier watcher"]
    CL["claim_unseen_events_for_sub"] --> SND["inlined per-event send"]
  end
  SND --> MSG["Messaging surface<br/>Telegram / Discord / Slack"]
  Other["CLI / TUI / orchestrator"] -. "no delivery path:<br/>busy-poll kanban_list" .-> TE
Loading

With this PR

flowchart LR
  Wk["Worker / dispatcher"] --> TE[("task_events log<br/>UNCHANGED")]
  TE --> RR["recompute_ready scheduler<br/>UNCHANGED"]
  NS[("kanban_notify_subs<br/>+ surrogate id, subscriber_kind, target<br/>old rows unchanged")] --> CL
  TE --> CL
  subgraph GW["Gateway notifier watcher — same claim/cursor, failure accounting, fan-out"]
    CL["claim"] --> REG{"delivery registry<br/>by subscriber_kind"}
  end
  REG -->|gateway| GA["GatewayDeliveryAdapter<br/>(old send path, verbatim)"]
  GA --> MSG["Messaging surface<br/>Telegram / Discord / Slack"]
  REG -->|cli| CA["CLIDeliveryAdapter"]
  REG -->|tui| TA["TUIDeliveryAdapter"]
  REG -->|orchestrator| OA["OrchestratorDeliveryAdapter<br/>subtree claim + fan-in snapshot"]
  CA --> NOT[("kanban_notices store")]
  TA --> NOT
  OA --> NOT
  NOT --> LW["live-wake / hermes kanban notices<br/>idle CLI + TUI re-engage"]

  classDef new fill:#e6ffed,stroke:#22863a,color:#22863a;
  class REG,CA,TA,OA,NOT,LW new;
Loading

2. A kanban event reaching the CLI / orchestrator

There is no CLI delivery path today — a CLI orchestrator can only busy-poll kanban_list, and because it polls inside a turn it ends the turn before the result lands, so it never sees fan-in. After this PR the same terminal event is claimed by the registered adapter, persisted as one notice, and surfaced into the idle REPL at a turn boundary (never mid-turn), which re-engages the orchestrator.

Today (upstream)

sequenceDiagram
  participant U as User (CLI)
  participant O as Orchestrator turn
  participant B as task_events
  participant Wk as Worker
  Note over U,Wk: no CLI delivery path
  O->>B: kanban_list (poll)
  O-->>U: turn ends (no result yet)
  Wk->>B: child completed / blocked
  Note over O: turn already ended — never sees fan-in<br/>must busy-poll to notice
Loading

With this PR

sequenceDiagram
  participant U as User (idle CLI REPL)
  participant O as Orchestrator
  participant Sub as kanban_notify_subs
  participant B as task_events
  participant Wk as Worker
  participant NW as Gateway notifier tick
  participant OA as OrchestratorDeliveryAdapter
  participant N as kanban_notices
  O->>Sub: kanban_subscribe(root, kind=orchestrator, scope=subtree)
  O-->>U: yield turn (idle)
  Wk->>B: child completed / blocked
  NW->>B: tick: subtree has unseen events? (non-advancing peek)
  NW->>OA: route by subscriber_kind
  OA->>B: claim_unseen_subtree_events_for_sub (claim-once)
  OA->>N: persist ONE supervision notice (fan-in snapshot)
  U->>N: turn-boundary drain (_drain_kanban_live_notices)
  N-->>O: re-engage queued on _pending_input (next turn)
  O->>O: judge fan-in / triage block / goal complete
Loading

3. A kanban event reaching a gateway-mediated surface (Discord)

This is the path that already works, and the point of the diagram is that it does not change. The watcher still polls subs, still claims with the same cursor, still runs the same failure accounting and artifact upload, and still calls self.adapters[Discord].send(...) with a byte-for-byte identical message. The only difference is one indirection hop — the send now resolves through get_delivery_adapter('gateway') instead of being inlined.

Discord is just the example here: the gateway adapter resolves the messaging platform generically (self.adapters[platform]), so every chat platform Hermes supports today (Telegram, Slack, and the rest) keeps working through this same path with no new per-platform adapters — the only "adapters" this PR adds are the four subscriber_kind delivery adapters (gateway/cli/tui/orchestrator), all included here.

Today (upstream)

sequenceDiagram
  participant Wk as Worker
  participant B as task_events
  participant NW as Gateway notifier watcher
  participant Sub as kanban_notify_subs
  participant D as Discord adapter
  Wk->>B: completed
  NW->>Sub: poll subs
  NW->>B: claim_unseen_events_for_sub
  NW->>D: self.adapters[Discord].send(chat_id, msg)
  D-->>Wk: ✔ Kanban {id} done — {title}
Loading

With this PR

sequenceDiagram
  participant Wk as Worker
  participant B as task_events
  participant NW as Gateway notifier watcher
  participant Sub as kanban_notify_subs
  participant REG as delivery registry
  participant GA as GatewayDeliveryAdapter
  participant D as Discord adapter
  Wk->>B: completed
  NW->>Sub: poll subs (subscriber_kind='gateway')
  NW->>B: claim_unseen_events_for_sub (same claim/cursor)
  NW->>REG: get_delivery_adapter('gateway')
  REG->>GA: deliver(batch)
  GA->>D: self.adapters[Discord].send(chat_id, msg)
  D-->>Wk: ✔ Kanban {id} done — {title} (identical message)
Loading

How to Test

Automated gate. Run the substrate test files explicitly (-k kanban pulls in about 19 pre-existing upstream cross-file isolation failures that pass per-file):

pytest \
  tests/gateway/test_kanban_orchestrator_delivery.py \
  tests/gateway/test_kanban_cli_delivery.py \
  tests/gateway/test_kanban_tui_delivery.py \
  tests/gateway/test_kanban_delivery_registry.py \
  tests/gateway/test_kanban_watchers_orchestrator_gate.py \
  tests/hermes_cli/test_kanban_live_notices.py \
  tests/hermes_cli/test_kanban_reengage.py \
  tests/hermes_cli/test_kanban_notify.py \
  tests/hermes_cli/test_kanban_notify_id_rekey.py \
  tests/hermes_cli/test_kanban_subtree_closure.py \
  tests/hermes_cli/test_kanban_db_init.py \
  tests/hermes_cli/test_kanban_dispatch_supervisor.py \
  tests/tools/test_kanban_subscribe_tool.py \
  tests/tools/test_kanban_tools.py \
  tests/tui_gateway/test_kanban_live_wake.py -q
# 224 passed

Manual live testing. Set up a small board the dependency-safe way and supervise it from an idle session:

  1. In hermes chat (orchestrator mode): create 2-3 prerequisite cards, then the root in one kanban_create call with the subtasks as parents (so it is born todo); kanban_subscribe(root); then yield.
  2. Let the dispatcher work the board. A worker blocks on a decision, the supervisor is woken into your idle session to surface it, you answer in chat, it unblocks, fan-in happens, then a goal-complete wake arrives.
  3. The full loop (subscribe, yield, block-wake, decide, fan-in, goal-complete) runs hands-off beyond the one human decision, on both CLI and TUI surfaces.

The two live transcripts and the gate output can be attached to this PR on request.

Demonstrated behaviour change (A/B)

The patch changes orchestrator behaviour, not just plumbing:

  • Fan-in / quorum (F09). Re-engaged on fan-in: without the curated handoff a manager redundantly rebuilds the deliverable from scratch; with it, the manager verifies the child's existing artifact and finishes. Redo, then verify-and-finish.
  • Human-in-the-loop / blocked (F10/F11). The deadlock gap (a blocked child means the root stays todo and the supervisor never wakes) versus a woken supervisor triaging directly off the handoff. Shown end-to-end live on the TUI: block surfaced, human decides, supervisor records and unblocks, then goal-complete.

Relationship to existing features

  • kanban swarm. The swarm command builds a fixed topology up front (a blackboard root, N parallel workers, a verifier gated on all workers, then a synthesizer). This PR is the general subscribe / deliver / re-engage event mechanism that such a shape sits on, not a competing topology. The re-engagement handoff deliberately mirrors the kanban_swarm blackboard comment shape, so the two share a convention rather than fork one.
  • auto_subscribe_on_create. The existing config gate that auto-subscribes the origin session on create still works unchanged. The explicit kanban_subscribe primitive (F04) and tool (F13) are complementary: they let a caller declare the target deliberately, which is what fixes the worker-subscribes-itself case, without removing the auto-subscribe default.
  • Typed block reasons and the unblock breaker (feat(kanban): typed block reasons + unblock-loop breaker NousResearch/hermes-agent#52848). That change types each block (dependency, needs_input, capability, transient), auto-resumes dependency blocks, and after a recurrence limit routes a repeatedly re-blocked task to triage. The re-engagement here sits on top of it. A supervisor wakes only on the blocked event feat(kanban): typed block reasons + unblock-loop breaker NousResearch/hermes-agent#52848 reserves for the human-needing kinds; a dependency block emits dependency_wait and auto-resumes, so it never wakes a supervisor. F11's own brake counts a different signal (consecutive supervisor turns that did not resolve a block) and mirrors the existing dispatcher consecutive-failure machinery, so it does not duplicate feat(kanban): typed block reasons + unblock-loop breaker NousResearch/hermes-agent#52848's per-task recurrence counter.

Scope / non-goals

  • No new primitives; additive schema migration; the task_events log is untouched.
  • The re-engagement code only observes scheduling. It supplies the missing summary or handoff; it never promotes, reschedules, or writes task status (it relies on the existing recompute_ready).
  • Re-engage with a fresh turn, never mid-turn. Waking a running session by injecting a message mid-thought is a known hazard (see Critical: Background notifications repeatedly overwrite user input, TUI becomes unresponsive NousResearch/hermes-agent#42173) and is out of scope; live-wake delivers only at idle or turn boundaries.
  • Explicit subscribe, not auto-enroll-everything. This avoids notification spam and keeps the manager in control of what it watches.
  • One coherent change. This is deliberately part substrate, part MVP / proof-of-concept: the re-engagement loop is what exercises the substrate end to end, so the two are submitted together. The branch is clean per-feature commits, so it can be split into substrate-first (F01-F06) plus a supervision follow-up (F07-F15) if you prefer smaller PRs.

Limitations

The substrate mechanisms (subscribe, wake, fan-in, triage, live-wake delivery) are all demonstrated live. The fully hands-off auto-loop on the stock worker-spawn path is not claimed here: it is gated on a separate provider bug, NousResearch#53234 (chat -q quiet mode does not execute tool calls with openai-codex; tools execute under -z but not under chat -q). The live runs used a local -z worker workaround that is not part of this PR. Once NousResearch#53234 is fixed, the same loop runs hands-off on the stock path.

No bundled agent skill, by design. This PR ships the plumbing — subscribe, deliver, re-engage — not an opinionated workflow layered on top of it. The injected KANBAN_GUIDANCE gains only the minimal subscribe-and-yield step; we deliberately stop short of a packaged "supervisor" skill or preset so adopters can compose the substrate however they like (orchestrator, CLI watcher, TUI, or their own). An opinionated skill can follow once the primitives have settled in use.

Future work

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(kanban):, fix(kanban):, test(kanban):)
  • I searched for existing PRs to make sure this isn't a duplicate (no competing substrate PR; [Feature]: Generalize Kanban notifications into an event substrate — any-surface subscribers + delivery-adapter registry NousResearch/hermes-agent#49190 is open and unassigned)
  • My PR contains only changes related to this feature (no unrelated commits)
  • I've run the test suite (scripts/run_tests.sh, CI-parity). The focused substrate gate (the 15 files listed under How to Test) passes 224/224. The full suite shows some failures on my local macOS dev box, but they are pre-existing and unrelated: all in other subsystems (acp, anthropic-adapter, wecom, gateway-service, state-db, file-tools, browser), and the one kanban-named failure (test_signal_handler_kanban_worker, a SIGTERM-timing test) reproduces identically on a clean upstream/main checkout with none of these commits. None of this PR's 25 files touch those paths.
  • I've added tests for my changes (16 test files: 14 new, 2 modified; about 5k lines)
  • I've tested on my platform: macOS Apple-Silicon (ARM, Python 3.12.8) — substrate gate 224/224, and the full CI-parity suite (scripts/run_tests.sh) triaged clean (every failure pre-existing/environmental, reproduced on a clean upstream/main checkout). Previously also verified on macOS Intel (Python 3.11.15). Not run directly on Windows or Linux; scripts/check-windows-footguns.py is clean across all 25 changed files.

Documentation & Housekeeping

  • I've updated relevant documentation (the in-prompt KANBAN_GUIDANCE subscribe-and-yield guidance) or N/A
  • N/A: no config keys added or changed (cli-config.yaml.example untouched)
  • N/A: no architecture or workflow change to CONTRIBUTING.md / AGENTS.md
  • I've considered cross-platform impact (footgun scan clean; touches subprocess spawn and file paths, with no POSIX-only assumptions added)
  • I've updated tool descriptions/schemas (new kanban_subscribe tool schema and guidance)

teknium1 and others added 30 commits June 29, 2026 04:25
…NSERT OR IGNORE

The gateway's get_or_create_session() creates a bare session row (source +
user_id) before the agent exists. The agent's later create_session() carries
the real model/model_config/system_prompt, but _insert_session_row used
INSERT OR IGNORE — silently dropping that enrichment. Gateway sessions were
left with NULL model and NULL billing metadata.

Switch to INSERT ... ON CONFLICT(id) DO UPDATE with COALESCE so NULL columns
get backfilled while values an earlier writer already set are never
overwritten (a later bare write with source='unknown' can't clobber a real
source/model). Credit: original report and fix direction by @LucidPaths (NousResearch#5048).
…dge path guard

Salvages the two still-valid hardenings from NousResearch#5381 onto the relocated
plugin adapters (the discord/feishu/whatsapp adapters moved to
plugins/platforms/ since the PR was opened, and 4 of its 6 hunks are
already on main or superseded).

- feishu: rate limiter now denies untracked keys when the tracking table
  is at capacity after pruning stale entries (was: allow through without
  tracking). At-capacity-with-all-fresh-entries only happens under abuse,
  so allowing untracked requests let an attacker who flooded the table
  bypass the limiter entirely. Already-tracked keys and post-prune room
  are unaffected.
- whatsapp: absolute file paths handed back by the Baileys bridge are now
  validated to resolve inside a known media cache dir before being
  attached. A compromised/buggy bridge could otherwise return an
  arbitrary path (e.g. /etc/passwd) that would be sent verbatim to the
  model. Guard resolves symlinks and accepts both the canonical
  cache/<kind> and legacy <kind>_cache layouts.
reset_had_activity gated on entry.total_tokens, which is never written
(token counts migrated to agent-direct persistence) so it was always 0.
That suppressed session-reset notifications for sessions that genuinely
had activity. Switch to last_prompt_tokens, which is updated on every
turn.
The reset-had-activity tests set total_tokens (dead state) to simulate
activity; production records activity via last_prompt_tokens. Update
the fixtures to match the field the fix and runtime actually use.
…ersal

Session IDs can originate from untrusted input (e.g. the
X-Hermes-Session-Id API header) and are interpolated raw into on-disk
artifact filenames under ~/.hermes/sessions/. A traversal-shaped ID
(../../../../etc/pwned) would let a caller write the session snapshot
or request dump outside the sessions directory.

_safe_session_filename_component() collapses every non [A-Za-z0-9_-]
character to _, caps the length, and appends a short content hash when
sanitization changed the string, always yielding a single traversal-free
path segment.

Closes NousResearch#5958.
…undary

Defense-in-depth on top of _safe_session_filename_component (NousResearch#5958):

Sink (makes the bad write impossible regardless of entry point):
- run_agent._save_session_log: sanitize session_id before building the
  session_{sid}.json snapshot path.
- agent_runtime_helpers.dump_api_request_debug: sanitize before building
  the request_dump_{sid}_{ts}.json path.

Boundary (clean 400 instead of a silently-hashed filename):
- api_server rejects path-traversal-shaped X-Hermes-Session-Id on the
  session-continuation path and the explicit /api/sessions create path,
  reusing gateway.session._is_path_unsafe (mirrors the native gateway's
  entry-boundary guard). Also enforces the session-header length cap on
  the continuation path.

Tests: traversal session_id stays contained at the write site; sanitizer
always yields a traversal-free segment; the API header rejects
../, absolute, and Windows-traversal IDs with 400.
Widen NousResearch#5961's _format_untrusted_prompt_value coverage to the Matrix
room display name (**Matrix Room:**), a sibling attacker-controllable
field the original fix missed. chat_name is user-settable, so an
injected room name could render as literal markdown in the system
prompt. Adds a regression test.
NousResearch#54834)

The register path builds each profile-gateway slot in a sibling staging
dir under /run/service (the scandir s6-svscan watches), then atomically
renames it to the live gateway-<profile> name. The staging dir was named
gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a`
rescan (fired by the cont-init reconciler registering gateway-default, or
by a sibling register) would supervise the half-built slot the moment it
had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/
root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns
on the now-existing supervise/ and the next `mkdir supervise/event` hits
PermissionError.

That is the arm64-only CI flake on
test_s6_unregister_removes_service_dir_in_live_container
(PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) —
arm64-only because the native-arm runner's wider scheduling jitter lets
the rescan land inside the ~ms seed window; amd64 ran 30/30 clean.

Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register
paths (S6ServiceManager.register_profile_gateway and
container_boot._register_service). s6-svscan skips any scandir entry whose
name begins with '.', so the half-built slot can never be supervised
mid-build. The atomic rename to the dotless live name is unchanged.

Verified on a real s6 image (amd64): a non-dotted staging dir is picked up
by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one
is ignored (NOT-SUPERVISED). Added a docker-harness regression test that
asserts both, plus a unit test that the staging dir is dot-prefixed.
NVIDIA integrate.api.nvidia.com models such as minimaxai/minimax-m3 can
return HTTP 200 with empty choices when max_tokens is omitted. Keep the
output cap on auxiliary chat-completions routes, matching the main NVIDIA
provider profile behavior.
Let users click the status bar context indicator to see how tokens are
split across system prompt, tools, rules, skills, MCP, and conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ontext-usage-popover

feat(desktop): add context usage breakdown popover
…sResearch#7779) (NousResearch#54862)

A manually-installed venv inside the cloned repo can be destroyed by the
agent running a relative-path command against its own checkout (rm -rf venv,
uv venv venv, etc.), silently wiping the running runtime mid-session. Moving
the canonical manual-install venv to ~/.hermes/venvs/hermes-dev means no
relative path from the agent's workspace resolves to its own runtime, making
the bug class impossible without any command-detection code.

Closes the root cause of NousResearch#7779. The managed install.sh layout is unchanged.
…ousResearch#54843)

* feat(web_extract): truncate-and-store instead of LLM summarization

web_extract no longer runs an auxiliary LLM over scraped pages. The extract
backends (Firecrawl/Tavily/Exa/Parallel) already return clean, boilerplate-
stripped markdown, so we return it directly: pages within a char budget
(default 15000, web.extract_char_limit) come back whole; larger pages get a
head+tail window plus an explicit footer giving the stored full-text path and
the read_file call to page through the omitted middle. The full clean text is
written to cache/web (mounted read-only into remote backends like the other
cache dirs), so nothing is lost.

Inline base64 images are converted to [IMAGE: alt] placeholders (token bombs
dropped) while real http(s) image URLs are preserved as links so the agent can
still web_extract/vision_analyze them.

Removes process_content_with_llm + the chunked summarizer + check_auxiliary_model
+ _resolve_web_extract_auxiliary. context_references._default_url_fetcher is
updated to the truncate path and its stale data.documents shape read is fixed
to results (it was silently returning empty).

Live before/after eval (firecrawl, 4 URLs): 11.7x faster overall (176.6s ->
15.1s); 10-60x on large pages. Quality identical; findability 4/4 (answer
recoverable from stored full text on every truncated page). web_search is
unchanged.

No own scraper added; no changes to web_search.

* fix(web_extract): add char_limit to execute_code web_extract stub

The new web_extract char_limit param must appear in the code_execution_tool
_TOOL_STUBS signature (and doc line) or test_stubs_cover_all_schema_params
fails — the stub schema must cover every real schema param.
Subagent session pop-outs (`watch=1`) spectate a run driven elsewhere, so
editing/steering the transcript from there makes no sense. Gate the composer
and the user-bubble mutations on `isWatchWindow()`:

- hide the composer (folds into `showChatBar`)
- user prompts become a read-only button that toggles the 2-line clamp so long
  prompts stay fully readable, instead of opening the edit composer
- drop the stop/restore actions and the checkpoint branch-picker

Keyed off the narrow `isWatchWindow()` (not `isSecondaryWindow()`), so the
new-session and cmd-click pop-outs are unaffected.
…atch-readonly

feat(desktop): read-only spectator transcript for subagent watch windows
The Gateway item is the only statusbar entry with variant === 'menu'.
Since da73223 wrapped every render branch in `Tip`, the menu branch
nested `<DropdownMenu>` (a Radix Root that renders no DOM node) inside
`Tip`'s `<TooltipTrigger asChild>`. With no element to attach to, Radix
could never wire hover listeners, so the tooltip silently never showed.

`Tip` also can't be moved inside `DropdownMenuTrigger asChild` (the shape
proposed in NousResearch#54859): it's a plain component, not a Slot-forwarding one, so
the trigger's injected ref/handlers would land on `TooltipContent` instead
of the button and break the menu's click + popper anchoring.

Fix by composing both trigger Slots directly onto a single <button>
(`TooltipTrigger asChild` over `DropdownMenuTrigger asChild`), the pattern
already used in profile-switcher.tsx, and skip the tooltip wrapper entirely
when the item has no title.

Supersedes NousResearch#54859.

Co-authored-by: wnuuee1 <wnuuee1@users.noreply.github.com>
…tatusbar-tooltip

fix(desktop): show Gateway statusbar tooltip via composed trigger Slots
…ains (NousResearch#54824)

Add a generic suppress_notification flag to the drain-request marker. When a
drain that ends in process exit (e.g. a NAS auto-update image migration on the
always-on Hermes Cloud fleet) is flagged, the gateway skips ONLY the
home-channel 'gateway shutting down' broadcast — the operator-flavoured ping
that would otherwise fire on every routine auto-update, dozens of times a day.

The per-active-session interrupt ping is ALWAYS kept: on a drained shutdown
it's empty by construction, and in the force-interrupt (deadline-exceeded) case
it carries the user-valuable 'your task was cut off, message me to resume' hint.

The gateway stays agnostic about WHY a drain is quiet (generic boolean, not a
kind enum); the policy of which drain causes set the flag lives in the caller
(NAS). Default-false so legacy/operator drains behave exactly as before. The
reader reuses the NS-570 epoch-staleness check so an orphaned marker on the
durable volume can never silence a fresh gateway's legitimate broadcast.

- drain_control.py: write_drain_request gains suppress_notification; new
  drain_notification_suppressed() reader (current-epoch + truthy flag).
- web_server.py: /api/gateway/drain reads + echoes the flag.
- run.py: _notify_active_sessions_of_shutdown skips the home-channel loop only.

Tests prove: flag round-trips; home-channel suppressed when set, kept when
unset; active-session ping always fires; stale/legacy/corrupt markers never
suppress.
Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity.
roamWalkRow() prefers running-left/running-right rows, falling back to the generic running row with a mirror for pets that lack them.
usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render).
Tag both bars with data-slots; the roam loop stands on the status bar's top edge (not over it) and treats the profile rail as a climbable ledge.
Drop the CSS lens overlay (blend modes, noise, inversion) and backdrop-blur
from the ops dashboard so compositing no longer competes with xterm on /chat.
Use flat theme backgrounds and direct Nous Blue palette colors instead of
FG-inversion authoring.

Co-authored-by: Cursor <cursoragent@cursor.com>
Make Nous Blue terminal text readable without the inversion layer, re-mount
the backdrop plugin slot, and drop unused backdrop CSS vars from theme apply.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use momentum easing for sidebar transitions, switch sidebar typography to
sans-serif, replace the profile native select with the DS Select, and stop
clipping the Models page Use-as dropdown inside model cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
kshitijk4poor and others added 28 commits June 30, 2026 19:11
…w precedence

The cherry-picked fix added explicit-kwarg and top-level image_gen.model
resolution but left _resolve_model / _resolve_model_chain docstrings stating
the old 'env override -> config -> DEFAULT_MODEL' order. Document the full
precedence (explicit kwarg -> env -> scoped -> top-level -> default chain) to
match the sibling krea/openai providers.
…t memory retry loops (NousResearch#42405)

- replace() and remove() now return entry previews and current_entries
  when no entry matches old_text, matching the multi-match and add-limit
  error behavior
- add() limit error also now returns previews for consistency
- Agent can self-correct after a failed replace/remove instead of looping
  blindly until turn budget is exhausted with no user response
…ion failures (NousResearch#42405)

Builds on the zero-match feedback fix (previous commit) to close the silent-hang
symptom: when memory is at capacity, a failed `add`/`replace`/`remove`
consolidation could loop the whole turn to iteration-budget exhaustion and
deliver no user-facing reply.

NousResearch#41755 turned the at-capacity overflow error into a *commanded* in-turn retry
("...then retry this add — all in this turn"); combined with the fragile
substring-only `replace`/`remove` matching (LLMs can't reliably re-quote a long
entry verbatim), the model loops add↔replace on inexact guesses until the turn
dies. The existing tool_guardrails halt would catch this, but hard_stop_enabled
is opt-in (off by default), so a default install still hangs.

This fixes it at the memory layer without changing global guardrail behavior:
- MemoryStore tracks per-turn consolidation failures; after a cap (3) it drops
  the "retry in this turn" instruction and returns a terminal "leave memory
  unchanged, continue your reply" result, so a failed memory side effect can
  never block the turn's reply.
- The counter resets on any successful write (progress) and at each turn
  boundary (turn_context.reset_consolidation_failures, guarded via getattr so
  plugin memory stores without the method are a no-op).

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
…g chokepoint

A single 'hermes update' / 'hermes -p' could rewrite a hand-curated config.yaml
into a near-full DEFAULT_CONFIG dump (the 'you blow up my profile config on one
tweak' reports). Root cause: migrate_config() had ~16 independent save_config()
call sites, each author deciding ad hoc whether to materialise a value, and many
persisted pure schema defaults with strip_defaults=False. Defaults already merge
transparently at read time via load_config(), so writing them is pure bloat that
also shadows future default changes (see save_config's docstring).

Architectural fix (not a per-site patch): introduce a single _persist_migration()
chokepoint that enforces one invariant — a migration may persist only values that
DIFFER from the current schema default, plus explicit removals/renames of user
data; pure defaults are never written. Every migration write (all 17 sites incl.
the version-bump finalizer) now routes through it. The invariant is mechanically
correct for all cases and verified empirically:
  - pure-default seeds (timezone='', curator/auxiliary.curator blocks, interim
    flag, curator.consolidate=False, empty plugins.enabled) are stripped → merged
    in at read time;
  - non-default values (write_approval=True, model_catalog.ttl_hours=1) preserved
    via explicit-raw-path preservation;
  - behaviour flips (agent.verify_on_stop=False, schema default still 'auto')
    preserved because False != 'auto';
  - data transforms (custom_providers->providers, stt.model relocation,
    write_mode->write_approval, compression.summary_* removal, MCP-disable)
    persist their removals/renames.

An explicitly user-set non-default value (e.g. matrix.require_mention: false) is
preserved across the bump.

Guard tests lock the architecture: an AST check asserts migrate_config() makes no
direct save_config() call (all writes go through _persist_migration), and a
full-range v1->latest test asserts a lean config is never dumped. Two existing
change-detector tests that froze the on-disk representation of default-valued
keys are rewritten to assert the effective value via load_config() (behaviour
contract, not snapshot).

Validation: lean v1->latest migration drops from ~567 bytes to ~196 bytes;
148 config+setup and 196 profile/curator/migrate tests pass on scripts/run_tests.sh.
…igration-no-default-expansion

fix(config): route every migration write through one default-stripping chokepoint
…ubscriber_kind + target (event-hub F01)

Additive columns (id, subscriber_kind, target, scope, delivery_policy) on
kanban_notify_subs with in-place migration + backfill of existing rows as
subscriber_kind='gateway' (target = JSON of platform/chat/thread/user). PK and
the claim/cursor API are unchanged; the unique id index is built in the migration
pass to avoid the legacy-board parse-abort. Foundation for the generalized Kanban
event substrate (upstream NousResearch#49190).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…with gateway-tuple shim (event-hub F02)

Generalize add_notify_sub / remove_notify_sub / unseen_events_for_sub /
claim_unseen_events_for_sub / advance_notify_cursor / rewind_notify_cursor to
operate by the F01 surrogate id, via a _resolve_notify_sub_id(sub_id | tuple)
shim so legacy gateway callers (kanban_watchers, kanban_tools) keep working
unchanged. Cursor-CAS single-owner dedup preserved (WHERE id = ? AND
last_event_id = <old> inside BEGIN IMMEDIATE). add_notify_sub now returns the
surrogate id. Foundation for the delivery-adapter registry (upstream NousResearch#49190).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bscriber_kind (event-hub F03)

Extract the gateway notifier's per-event send loop into
gateway/kanban_delivery.py as GatewayDeliveryAdapter (registered for
subscriber_kind='gateway'). The watcher now resolves the adapter for each
subscription's subscriber_kind and dispatches the claimed batch through it,
keeping all safety machinery in the watcher (claim/cursor, MAX_SEND_FAILURES
dead-channel drop, rewind-on-transient-failure, keep-sub-until-final-status,
multi-board fan-out, notifier_profile gating). Gateway delivery is byte-for-byte
identical; unknown subscriber_kinds are skipped. Foundation for CLI/TUI adapters
(event-hub F05/F06, upstream NousResearch#49190).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ target (event-hub F04)

Generalize add_notify_sub with subscriber_kind (default 'gateway') and target
kwargs; the gateway path is byte-for-byte unchanged when both default. Non-gateway
subscriptions satisfy the NOT NULL PK via the convention platform=subscriber_kind,
chat_id=<target id>, thread_id='' — with the structured identity carried in the
subscriber_kind column + target JSON. Extend 'kanban notify-subscribe' with
--subscriber-kind / --target-id / --target, and surface the fields in notify-list;
gateway flags + confirmation message unchanged. _maybe_auto_subscribe (gateway
auto-subscribe) untouched. Contract-level fix for the inferred-identity defect
(kanban-e2e F0C); the write side for non-gateway delivery (CLI adapter is F05).
Upstream NousResearch#49190.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + drain (event-hub F05)

First non-gateway delivery adapter. A subscriber_kind='cli' subscription has no
live push channel, so delivery persists a plain-text terminal-event notice in a
new kanban_cli_notices table keyed by the CLI target id; 'hermes kanban notices'
drains it (notice-first, one-shot read). Dedup is owned by the shared claim
cursor, so a re-claim writes no duplicate.

The watcher gates the live-Platform resolution (and its disconnect->rewind
safety) on subscriber_kind=='gateway' in both _collect and the dispatch loop, so
non-gateway kinds bypass _Platform(...) and reach their registered adapter.
Gateway delivery semantics (fan-out, failure accounting, keep-sub-until-final,
profile gating) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…notice store (event-hub F06)

Second non-gateway delivery adapter, proving the F03 registry generalizes to a
new surface with zero Kanban-core / watcher changes: registering
DELIVERY_ADAPTERS['tui'] is sufficient because F05 already made the watcher's
gateway gating generic, so a tui sub bypasses _Platform(...) and reaches its
adapter automatically (watcher diff is empty).

Generalizes F05's cli-only notice store into one surface-agnostic kanban_notices
table keyed by (subscriber_kind, target_id): add_cli_notice/drain_cli_notices ->
add_notice/drain_notices; CLI + TUI share a _NoticeDeliveryAdapter base differing
only by _kind. 'hermes kanban notices' gains a --kind {cli,tui} filter. The table
is brand-new/unmerged so no migration is needed. Notice-first like CLI (RFC 5.3
'session notice'); live-turn wakeup/WS push stays deferred to M01.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e (event-hub F07)

Third non-gateway delivery adapter (subscriber_kind='orchestrator'). A parent
subscribes with --scope subtree --delivery-policy supervise; on its subtasks'
terminal/blocker events it gets one claim-once structured supervision notice
(aggregate snapshot + fan_in_ready) in the shared kanban_notices store, drained
via 'kanban supervise'. Observational only — no task creation, no scheduling
change; re-engagement (F09) and live wake (M01) are out of scope.

The subtree claim observes the subscribed root's task_links PARENTS (= its
subtasks): decompose_triage_task links the root as the child of every subtask,
so the subtasks are the root's dependency parents. Fan-in is computed in code
(no new event kind); dedup is the shared claim cursor. Watcher unchanged
(adapter self-claims); gateway/cli/tui adapters byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…doff comment (event-hub F09)

Close-the-loop heartbeat that consumes F07's supervision notices. The
orchestrator root already stays alive after decompose and is auto-re-spawned
by recompute_ready + the dispatcher when its subtasks finish; the only gap was
the curated fan-in handoff the re-spawned turn should read to judge the goal.

F09 supplies exactly that and nothing more:
- kanban_db.reengage_orchestrator(target_id): drains F07 orchestrator notices
  one-shot, groups by root (parent_id, latest wins), and for each root whose
  latest snapshot is fan_in_ready appends ONE structured [kanban:reengage]
  comment carrying the aggregate snapshot. build_worker_context already
  surfaces the comment thread, so the handoff reaches the fresh turn in-context
  with no new read path (the kanban_swarm blackboard convention).
- `hermes kanban reengage --target-id X [--json]` CLI entrypoint.

Re-engages ONLY on fan_in_ready=true; partial notices are passive visibility
(consumed, no comment — no wake-per-partial-batch). Idempotency is the one-shot
drain: a re-run with no new notice is a no-op, and a second decompose round's
fan-in notice correctly re-engages again (no permanent marker that would break
the multi-round loop). Observational toward scheduling — the only mutation is a
comment (+ its commented event); no promotion/status write/task creation.

F07's delivery adapter + payload contract are untouched. The live call-site
wiring (dispatcher tick + ordering before re-spawn) and the real
orchestrator-wakes-and-judges proof are deferred to M07 on the Mini.

Tests: tests/hermes_cli/test_kanban_reengage.py (10) — fan-in true/false,
idempotent re-run, multi-round, at-most-one-per-root (latest wins),
observational, CLI plain + --json, and an end-to-end real-decompose → F07
delivery → reengage → build_worker_context close-the-loop proof (no dispatcher).
Gate: 117 (107 baseline + 10) + 238 green; ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ked handoff (event-hub F10)

Extend F09's single F07-notice consumer (`reengage_orchestrator`) to branch on
the drained supervision snapshot, closing the second half of the orchestration
loop: fan-in says "work is done → judge", blocked says "work is stuck → triage".

- `fan_in_ready=true` → `[kanban:reengage]` (F09, body byte-for-byte unchanged).
- a blocked child (`kind=="blocked"`; implies `fan_in_ready` false, since a
  blocked child is never terminal) → a new `[kanban:triage]` handoff carrying
  `{trigger:"blocked", child id, reason}` + the snapshot, surfaced to the
  re-spawned orchestrator turn via `build_worker_context`.
- neither (a partial with no blocked child) → passive; no comment.

The two arms are mutually exclusive per snapshot by construction (a blocked
child masks fan-in), so no double-fire. Idempotency stays the F07 one-shot drain
— a re-block after unblock is a new claimed event → a new triage handoff, no
separate marker. Enqueue-only: writes the handoff; does not spawn/wake the
supervisor (that is F11). `kanban reengage` now reports the `trigger` per root
(human `triaged`/`re-engaged`; `--json` adds `trigger` beside root_id/comment_id).

Resolves event-hub F10 (OQ-1 `[kanban:triage]`; OQ-2 reuse F07 cursor;
OQ-3 composes by construction; OQ-4 orchestrator-mode wake split to F11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tor-mode supervisor on a blocked child (event-hub F11-core)

The live "drive" side of the closed loop (F11 increment 1). A blocked child
does NOT re-promote its root, so stock ready-dispatch never spawns anything to
triage it — the silent deadlock. This adds a supervisor-dispatch sub-loop to the
dispatcher tick that, for each supervised root (one with a
subscriber_kind='orchestrator' notify-sub) carrying a `blocked` child, wakes a
fresh ORCHESTRATOR-MODE turn to answer+unblock or escalate to the human.

- `_supervisor_spawn(root_task, *, board)`: a thin `_default_spawn` variant that
  OMITS `HERMES_KANBAN_TASK` (its absence is the one line that grants the
  orchestrator-only kanban_unblock/kanban_list tools), board-scoped, the root's
  profile, prompt "supervise root <id>". Uses top-level `-z` one-shot (not
  `chat -q`): the right shape for a single fresh supervisor turn AND it sidesteps
  the orthogonal `chat -q`×Codex tool-wiring bug (R03) until upstream fixes it.
- Supervisor-dispatch loop in `_dispatch_once_locked` (mirrors the review loop;
  injectable `supervisor_spawn_fn`, threaded through `dispatch_once`): scans
  orchestrator subs → blocked children via `parent_ids` → guards → spawn →
  record a `task_runs` row (step_key='supervisor'); roots woken this tick land
  in the new `DispatchResult.supervised`.
- OQ-5 guards (no schema change; reuse task_runs + `_pid_alive`): concurrency
  (a live supervisor PID), cooldown (spaced retries), and a breaker that parks
  the block "escalated/awaiting human" after K no-resolution turns and RESETS on
  re-block (counter keyed to the latest `blocked` event = the block episode).

Trigger keys on blocked-child STATUS, not a pending handoff (OQ-2), so it also
catches dispatcher breaker auto-blocks (quota/auth) that carry no F07 notice.
Fan-in stays on the stock task-worker re-spawn (OQ-4) — untouched. Enqueue/spawn
side, unit-testable with stub spawn fns. F09/F10 (`reengage_orchestrator`) and
`_default_spawn` unchanged. Increment 2 (reengage handoff-enrichment in the tick)
+ live Mini validation are follow-ons.

Tests: tests/hermes_cli/test_kanban_dispatch_supervisor.py (8) — trigger,
not-subscribed, no-blocked-child, concurrency/cooldown/breaker guards (+reset on
re-block), orchestrator-mode env (no HERMES_KANBAN_TASK, board set, -z not chat),
fan-in regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-writes fan-in/triage handoffs (event-hub F11 Increment 2)

Completes F11's live drive side. The dispatch tick now runs reengage_orchestrator
for each orchestrator target, BEFORE recompute_ready re-promotes the root — so the
[kanban:reengage] (fan-in) / [kanban:triage] (blocked) handoff comment exists
before the re-promoted root is claimed, spawned, and reads it via
build_worker_context (the F09/M07 ordering requirement). Previously reengage ran
only via the manual `kanban reengage` CLI, so handoffs were never auto-written.

- DispatchResult.reengaged: root ids handed off this tick.
- _dispatch_once_locked: enumerate distinct orchestrator targets
  (list_notify_subs filtered subscriber_kind='orchestrator') → reengage each,
  per-target try/except so a bad target can't crash the tick; skipped under dry_run.
- _resolve_sub_target_id: inline of gateway/kanban_delivery._target_id (no gateway
  import in the DB layer) — subscription rows have no target_id column; the target
  lives in the `target` JSON / chat_id. Without this the drain target wouldn't match
  the kanban_notices.target_id the F07 watcher writes, and reengage-in-tick would be
  a silent no-op live. Caught in QA.

F07 supervision notices are produced by the gateway watcher (kanban_watchers.py);
this is their in-tick consumer — the live loop runs watcher + dispatcher together
(M07). Purely additive (0 deletions); reengage_orchestrator / F10 / F11-core
_supervisor_spawn unchanged. +5 tests (fan-in + triage handoff in-tick, idempotent,
no-subs no-op, dry_run skip). Gate: supervisor 14, official 123+238, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (event-hub F12)

The notifier watcher gated delivery on the subscribed task's OWN terminal events
(claim_unseen_events_for_sub) and continue'd when empty. An orchestrator subtree
subscription's root never emits blocked/completed — a child does — so the gate
was always empty and OrchestratorDeliveryAdapter.deliver() was never invoked
live: no F07 supervision notice, so reengage-in-tick (F11) had nothing to drain
and no [kanban:reengage]/[kanban:triage] handoff was ever written.

Fix (Option A, peek-gate): for subscriber_kind=='orchestrator', gate on a new
non-advancing peek of the children's unseen subtree events
(subtree_has_unseen_events_for_sub) instead of the root-only claim. The
OrchestratorDeliveryAdapter stays the SOLE owner of the subtree cursor (its
internal claim_unseen_subtree_events_for_sub is the one authoritative claimer);
the watcher only peeks, and the post-deliver cursor advance is skipped for
orchestrator subs so the watcher can't clobber the adapter's claim.

Adds tests/gateway/test_kanban_watchers_orchestrator_gate.py — the watcher-level
coverage the direct-deliver() F07 tests never had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vent-hub F13)

claim_unseen_subtree_events_for_sub (and its F12 read-only peek companion
subtree_has_unseen_events_for_sub) observed only the subscribed root's
SUBTASKS, returning empty for a childless node — so a single-card subscription
("a subtree of one") never fired on its own terminal event.

Make both observe the node's CLOSURE = {node} u {direct subtasks} via a shared
_subtree_closure_ids() helper (one source of truth, so the authoritative claim
and the watcher-gate peek cannot diverge). For a decompose root (alive/todo
through supervision, emits no terminal event until it completes) this is a
no-op — n>1 fan-in/blocker behavior is unchanged; the F07 + F12 suites stay
green. Transitive/deep-DAG closure remains deferred (F08).

Enabling change for the agent-facing kanban_subscribe tool: "assign one card and
be told when it's done" now uses the same primitive as supervising a goal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vent-hub F13)

Expose subscription as an LLM tool so a chat orchestrator can enroll a goal in
the supervision substrate and YIELD instead of busy-polling kanban_list — the
gap a live interactive test surfaced: the subscribe->notice->re-engage machinery
existed but no agent-callable subscribe did.

- kanban_subscribe(task_id): orchestrator-only (check_fn=_check_kanban_orchestrator_mode,
  like kanban_list/unblock), no scope arg — subscribes to the node's closure
  (n>=1; single card or decomposed root alike). Writes the orchestrator/subtree/
  supervise sub shape the F07 adapter + F11 reengage-in-tick already key off;
  idempotent; target = the session chat_id, or a deterministic orch:<task_id>
  fallback for CLI so the row stays reengage-pickup-able. Registered in toolsets.py
  beside kanban_unblock so it surfaces in the schema.
- KANBAN_GUIDANCE: a minimal subscribe-and-yield protocol in the orchestrator-mode
  section (create root, link subtasks as deps, kanban_subscribe(root), end the
  turn -- don't poll; single-card case too) with the link-direction warning. The
  rich pattern catalog stays in the non-shipped P9 cookbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…saging platform (event-hub F14)

The kanban notifier watcher bailed its entire `_collect` tick when no messaging
platform adapter was connected (`self.adapters == {}`), so it never evaluated
non-gateway subscriptions (orchestrator/cli/tui) — which need no Platform. On any
host with no Discord/Telegram/Slack (e.g. the Mini, Telegram deferred), no
notifier-produced notice was ever delivered live: F07 supervision notices, F05 cli
notices, F06 tui notices, and the F13 n=1 single-card notice all silently no-op'd.
Sibling of F12 (the per-sub gate); this is the tick-level gate one frame up.

Remove the tick-level platform early-return. The per-sub gateway gate
(`subscriber_kind == 'gateway' and platform not in active_platforms`) and the
adapter-resolution rewind are already the authoritative connectivity filters, so
gateway subs with no connected adapter are still skipped — only the surface-agnostic
kinds are unstranded. Keep the `active_platforms` set (the per-sub gate uses it).

Add watcher-level tests driving the real notifier tick with `self.adapters == {}`:
orchestrator subtree sub + cli sub both deliver; a gateway sub with no adapter is
still skipped (cursor stays 0). Verified the exact Mini scenario (n=1 single card)
produces the supervision notice and the sub self-cleans on terminal delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… create-then-link (event-hub F13 follow-up)

The shipped KANBAN_GUIDANCE told a supervising orchestrator to create the root
task first and then link each subtask as a dependency. Live testing showed that
races the embedded dispatcher: the root is born `ready` with no parents, a worker
claims it before the links exist, self-blocks "waiting for dependencies" (a sticky
block), and the supervise loop wedges (root never completes on fan-in).

Amend the guidance to the `parents`-at-create idiom (the same one the upstream
kanban tutorial uses by hand): FIRST create the subtask cards, THEN create the
root in one `kanban_create` with `parents=[<subtask ids>]` so it is born `todo`
and auto-promotes to `ready` only once every subtask is done — no race. Keeps the
link-direction note (each subtask is a `parent_id`; the root is the `child_id`).

Guidance-presence + length tests stay green (5919 chars); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…LI REPL (event-hub M01)

The supervise loop closes end-to-end EXCEPT for the last hop: when a child blocks
or the goal fans in, the gateway notifier writes a supervision notice (event-hub
F07/F14), but a yielded interactive orchestrator never sees it — the human sits at
the prompt and is never pinged. Closing that hop by hand works; M01 makes it
automatic, without the mid-turn-injection hazard upstream NousResearch#42173 warns about.

Approach (the safe one the exploration found): reuse the EXACT boundary the
background-process notifications already use. The CLI `process_loop` now drains
`kanban_notices` at two points that are never mid-turn — the idle tick (gated on
`not self._agent_running`, throttled) and the post-turn `finally` (forced once per
turn end) — and queues a re-engage message onto `_pending_input`. The orchestrator
wakes on the next turn to surface the decision / triage / report done; real user
input rides the same FIFO so nothing is overwritten. No edit to `run_conversation`
or the turn machinery.

- `kanban_db.drain_session_notices()`: one-shot, multi-board drain of
  `subscriber_kind='orchestrator'` notices (single-session-per-process assumption,
  same as the process-notification queue / TUI poller; enumerates boards like the
  notifier watcher so an ad-hoc supervised board is still found). Read-once.
- `HermesCLI._drain_kanban_live_notices(force=)`: throttle + synth + queue.
- Tests: one-shot + cross-kind isolation + board tagging; the CLI helper queues a
  re-engage message; throttle holds and `force=True` bypasses it.

Scope: CLI surface (the live-validated one). TUI poller parity is a small noted
follow-up (mirror the drain in `_notification_poller_loop`, gated on
`session["running"]`) — deferred to keep this final commit low-risk against the
freshly-refactored tui_gateway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tionable supervision notices (event-hub M01)

The F07 adapter writes one supervision notice per subtree event, so a
multi-subtask goal woke the supervising orchestrator on every intermediate
completion — ~6 turns for a 3-subtask goal in live testing, most carrying
nothing to act on.

The idle/post-turn drain (_drain_kanban_live_notices) now:
- coalesces to the freshest notice per supervised root (earlier snapshots
  are superseded within a drain window), and
- surfaces a re-engage only for an *actionable* state — a blocked child,
  fan-in ready, or all subtasks done — and only when that state differs from
  the last one surfaced for that root (kills duplicate re-wakes). Pure-progress
  snapshots leave the orchestrator idle, since there is no next step. A notice
  whose payload can't be classified is always surfaced (never silently dropped).

New _kanban_notice_signature classifier reads the F07 aggregate snapshot.
Also fix the cosmetic mid-word truncation in _truncate_line (word-boundary
break + ellipsis) so a notice no longer ends like "…SMS can be mob".

3 new tests (classifier, progress/duplicate suppression, per-root coalesce).
Full kanban sweep: 805 passed (was 802); the 19 cross-file isolation failures
are pre-existing and identical with/without this change (all green in isolation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot_status in the supervision snapshot (event-hub M01)

Live finding (Mini board m01_1, root t_5ea6bf3d): the root completed but the
supervisor was never re-engaged. Root cause was a regression in the M01 debounce,
not a substrate gap.

The subtree closure includes the root node, so the root's own `completed` event
correctly fires one final supervision notice (and the watcher then unsubscribes on
the done status). But _build_snapshot computes children + fan_in_ready purely over
the SUBTASKS, which were already all-done at fan-in — so the completion notice's
signature was byte-identical to the earlier fan-in-ready notice, and the debounce
dedup suppressed it.

Fix: the F07 snapshot now carries the root's own `root_status`, and the M01
signature includes a `root_terminal` field, so "goal complete" is a distinct
actionable state from "fan-in ready" and surfaces exactly once. _build_message
says "goal complete" (instead of the children-only "fan-in ready" phrasing) when
the root is terminal. root_status is additive + optional, so older payloads
degrade to prior behavior.

Tests: M01 regression (fan-in then completion BOTH surface; duplicate completion
deduped) + delivery test (root completion snapshot carries root_status=done and
"goal complete" message). 338 green across the delivery/notify suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… an idle TUI session (event-hub F15)

M01 gave the CLI a hands-off supervise→yield→re-engage loop; F15 gives the TUI
the same, the second interactive surface. The TUI's notification poller already
does idle-gated turn injection via _run_prompt_submit (for background-process
completions); F15 feeds kanban supervision notices into that same machinery
without touching it (Approach B — no process_registry coupling, cleanly poppable).

- kanban_db: drain_notices/drain_session_notices gain an additive `task_ids`
  filter so a TUI session drains ONLY the roots it subscribed to (the TUI
  multiplexes sessions, breaking M01's single-session-per-process assumption);
  None keeps the CLI behavior. Factor the M01 debounce classifier into a shared
  `supervision_notice_signature` (CLI delegates to it — one copy, both surfaces).
- tui_gateway/server.py: `_record_kanban_subscription` (OQ-3 i — track this
  session's subscribed roots via the kanban_subscribe tool-complete hook),
  `_drain_kanban_tui_notices` (throttled, idle-only drain → shared debounce →
  status.update chip + _run_prompt_submit turn), called on the poller tick.

Decisions (issue F15 OQ-1..4, ratified): B / piggyback@4s / (i) originating-
session ownership via in-memory root map / status.update chip.

Tests: kanban_db task_ids filter; TUI ownership hook, idle-gate, debounce parity
(progress suppressed, completion wakes after fan-in), owned-roots scoping.
585 passed (2 pre-existing unrelated fails: MoA preset, missing Chromium).
Unit-complete; live TUI validation on the Mini pending (human).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tector (event-hub F11)

The 123-commit rebase onto current upstream surfaced a latent break in the F11
supervisor-breaker test: upstream's block-loop detector (BLOCK_RECURRENCE_LIMIT=2)
routes a SAME-kind re-block-after-unblock to `triage` (writing `block_loop_detected`,
not `blocked`), so the test's rapid same-cause re-block no longer produced the fresh
`blocked` event the breaker resets on — the supervisor was never re-engaged and the
assertion failed.

The F11 breaker code is correct; the test's scenario collided with an orthogonal new
upstream guard. Fix: the re-block now uses a DIFFERENT block kind (a genuinely new
blocker) — `same_cause` is false, the recurrence counter resets, a fresh `blocked`
event is written, and the breaker resets as designed. This is also the only scenario
that still reaches the reset (a same-cause re-block legitimately goes to triage now).

Substrate gate: 222 passed (was 221 + this 1 fail). Test-only change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename tests/hermes_cli/test_kanban_m01_live_notices.py to
test_kanban_live_notices.py and remove the internal tracking-id tokens
(M01/F07/F15) from its docstrings, so the test name and prose read on their
own without reference to a private tracker. Test bodies unchanged; substrate
gate still 222 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d docstrings

Strip the private issue-tracker tokens (F01-F15, M01/M07, event-hub, OQ refs,
'Increment N', and the internal R03 label) from code comments and docstrings
across the changed files, rewording the surrounding prose so each comment reads
on its own. The one internal bug reference now points to the public issue
(NousResearch#53234). No code/behaviour change; substrate gate
still 222 passed, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Generalize Kanban notifications into an event substrate — any-surface subscribers + delivery-adapter registry