Skip to content

feat(plugins): add public subagent lifecycle API - #63359

Closed
asimons81 wants to merge 2 commits into
NousResearch:mainfrom
asimons81:codex/feat-public-subagent-lifecycle-api
Closed

feat(plugins): add public subagent lifecycle API#63359
asimons81 wants to merge 2 commits into
NousResearch:mainfrom
asimons81:codex/feat-public-subagent-lifecycle-api

Conversation

@asimons81

@asimons81 asimons81 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Expose a typed, plugin-safe subagent lifecycle service through PluginContext.subagent_lifecycle. Plugins can launch, monitor, cancel, and retrieve results from fresh Hermes child sessions without importing private delegation internals, gateway state, or TUI components.

  • Add agent/subagent_lifecycle.py -- SubagentLifecycleService with launch, status, bounded wait, cooperative cancellation, immutable terminal-result retrieval, and reconnect diagnostics
  • Add PluginContext.subagent_lifecycle property (hermes_cli/plugins.py, +17 lines) with lazy initialization and a parent-agent resolver lambda
  • Add tests/agent/test_subagent_lifecycle.py -- 98 lines of contract/security tests
  • Add website/docs/developer-guide/subagent-lifecycle-api.md -- public API documentation

Public API

The service is obtained from PluginContext:

service = ctx.subagent_lifecycle
handle = service.launch(SubagentLaunchRequest(goal="..."))
status = service.status(handle)
terminal = service.wait(handle, timeout_seconds=30)
result = service.result(handle)
cancel_result = service.cancel(handle, reason="...")
reconnect_result = service.reconnect(handle)

SubagentHandle is fully serializable (to_dict() / from_dict()) and carries a versioned, opaque HMAC capability. Malformed or forged handles return UNKNOWN / UNKNOWN_HANDLE and cannot access a child.

Key request fields:

Field Type Limit
goal str 16 000 chars
context Optional[str] 32 000 chars
metadata Mapping[str, Any] 8192 bytes JSON
allowed_toolsets Optional[tuple[str, ...]] Must be subset of parent toolsets
correlation_id Optional[str] Unique per parent session

Lifecycle semantics

States: PENDING -> STARTING -> RUNNING -> SUCCEEDED / FAILED / INTERRUPTED, with CANCEL_REQUESTED as a transient during cooperative cancellation. UNKNOWN is returned for unrecognized or forged handles.

  • cancel() is cooperative: it calls agent.interrupt() and returns CANCEL_REQUESTED. The state only transitions to CANCELLED after terminal confirmation from the child loop.
  • wait() blocks on the child's Future with an optional timeout and returns SubagentTerminalState.
  • result() returns an immutable SubagentResult with a result_hash (SHA-256 of serialized payload). Repeated calls return the same instance.
  • reconnect() returns connected=True for in-process handles that are still in the registry. After a process restart it returns connected=False, diagnostic="RECONNECT_UNAVAILABLE".

Plugin usage

from agent.subagent_lifecycle import SubagentLaunchRequest

def register(ctx):
    svc = ctx.subagent_lifecycle
    handle = svc.launch(SubagentLaunchRequest(
        goal="Audit this config for security regressions.",
        context="Only inspect the supplied file tree.",
        role="leaf",
        correlation_id="audit-7",
        allowed_toolsets=("file",),
    ))
    svc.wait(handle, timeout_seconds=60)
    return svc.result(handle)

Compatibility

Existing delegate_task, batch delegation, child tool restrictions, and TUI/gateway active-child display are unchanged. The service shares the internal child construction and execution path. It does not expose live agent objects, transcripts, or hidden reasoning.

Security

  • All requests are fail-closed. Malformed inputs, oversized metadata, unknown toolsets, parent-broadening toolsets, forged handles, and cross-parent access are all rejected with SubagentLifecycleError or UNKNOWN state.
  • Each handle carries an HMAC-SHA256 capability derived from a per-process secret, the subagent ID, parent session, and creation timestamp. Forged capabilities produce UNKNOWN state.
  • Cross-parent access is blocked by comparing the caller's resolved parent session ID against the handle's parent_session_id.
  • Terminal results are bounded to 32 000 characters and exclude transcripts, prompts, and hidden reasoning.
  • Per-tool blocking, working-directory override, and per-launch timeout are explicitly rejected until Hermes can support them without weakening isolation. Use allowed_toolsets to narrow a child.

Validation

Check Result
42 focused tests (lifecycle contract, concurrency, interrupt, guardrails) Passed
Ruff (enforcement + ty diff) Passed
Python compilation (compileall) Passed
Python test slices 1/8 through 8/8 Passed
E2E tests Passed
Windows footgun check Passed
OSV scan Passed
Supply-chain risk scan Passed
All required checks (GitHub branch protection) Passed
Docker amd64 build Passed
Docker arm64 build In progress (non-blocking)

Known limitations

  1. Terminal results are retained in-process for one hour, then evicted.
  2. Reconnect is in-process only. After a process restart, reconnect() returns RECONNECT_UNAVAILABLE and never starts a replacement child.
  3. Cancellation is cooperative. The state remains CANCEL_REQUESTED until the child thread confirms the interrupt at its next safe boundary.
  4. The direct local Windows full test suite could not be collected because an existing test (tests/tools/test_search_hidden_dirs.py) invokes the Unix command which rg.
  5. The canonical local shell runner expects the POSIX path venv/bin/python.
  6. These local Windows runner limitations are not introduced by this PR.
  7. Remote CI results should be treated as authoritative where available.

What reviewers should focus on

  • agent/subagent_lifecycle.py -- the entire new module. Check that the public contract is complete, the HMAC capability scheme is correct, the _validate_request gates cover all injection surfaces, and the thread safety of _Registry is sound.
  • hermes_cli/plugins.py -- the subagent_lifecycle property. Confirm the lazy-init pattern and parent-agent resolver lambda are correct for the plugin lifecycle.
  • tests/agent/test_subagent_lifecycle.py -- verify the contract tests cover launch, wait, result, cancel (cooperative), forged-handle rejection, cross-parent isolation, duplicate correlation IDs, toolset-broadening rejection, and in-process reconnect.
  • website/docs/developer-guide/subagent-lifecycle-api.md -- confirm the documentation accurately reflects the implementation.

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have labels Jul 12, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for proposing a typed plugin-facing lifecycle boundary. The API is additive, but its current execution path bypasses delegation invariants that main already centralizes.

Problems

  • agent/subagent_lifecycle.py:196 calls _build_child_agent() without the parent tool-name save/restore required by tools/delegate_tool.py:2499-2541; _run_single_child() later restores the child’s saved value (tools/delegate_tool.py:2314-2320). This can leave process-global tool resolution on the child toolset.
  • agent/subagent_lifecycle.py:230 directly runs _run_single_child(), skipping the parent-side memory notification, serialized subagent_stop hooks, and cost rollup in tools/delegate_tool.py:2687-2779.
  • hermes_cli/plugins.py:375 resolves only through _cli_ref, but website/docs/developer-guide/plugins/index.md:859-862 documents that _cli_ref is absent in gateway, non-interactive, and kanban-worker contexts.

Suggested changes

  • Route both public lifecycle calls and delegate_task through one host-owned lifecycle executor that preserves current construction, concurrency, cleanup, aggregation, and hook contracts.
  • Define a session-scoped resolver and lifecycle ownership model before exposing this surface across plugin contexts.
  • Validate malformed deserialized handle fields before HMAC comparison; current tests cover a forged string capability but not malformed typed fields.

Automated hermes-sweeper review.

from tools.delegate_tool import _build_child_agent, DEFAULT_MAX_ITERATIONS

child = _build_child_agent(
task_index=0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_build_child_agent() mutates model_tools._last_resolved_tool_names. The existing delegate_task() path saves the parent value before construction, stores it on the child, and restores it afterward (tools/delegate_tool.py:2499-2541). Preserve that invariant here or route through a shared constructor/executor; otherwise child construction can corrupt the parent process's resolved tool set.

if request.correlation_id:
_REGISTRY.correlations[correlation_key] = subagent_id
record.future = _EXECUTOR.submit(self._run, record, request.goal, parent)
return handle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Submitting _run_single_child() directly bypasses the parent-side aggregation in delegate_task() that fires subagent_stop, notifies the memory provider, and rolls child cost into the parent (tools/delegate_tool.py:2687-2779). Please use a shared host-owned lifecycle path rather than reproducing only the worker portion.

):
return None
if not hmac.compare_digest(
handle.capability,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only verifies the dataclass type/version. SubagentHandle.from_dict() accepts arbitrary field types, so malformed capability, subagent_id, or created_at values can raise during HMAC computation/comparison instead of producing the documented UNKNOWN response. Validate all capability-input fields before calling _capability().

Comment thread hermes_cli/plugins.py Outdated
from agent.subagent_lifecycle import SubagentLifecycleService
self._subagent_lifecycle = SubagentLifecycleService(
lambda: getattr(self._manager._cli_ref, "agent", None)
if self._manager._cli_ref is not None else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resolver is CLI-only: _cli_ref is populated by cli.py:13487-13489, while plugin docs explicitly say it is absent in gateway, non-interactive, and kanban-worker contexts (website/docs/developer-guide/plugins/index.md:859-862). Define a session-scoped resolver for those surfaces or document and enforce a deliberately CLI-only API.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 16, 2026
@asimons81

Copy link
Copy Markdown
Contributor Author

Implemented the review fixes in 6cb4bb80a:

  • Routed public lifecycle construction and completion through shared host-owned delegation helpers. Parent tool resolution is saved/restored, and both delegate_task and the plugin API now share summary budgeting, memory notification, serialized subagent_stop hooks, child-cost rollup, and existing worker cleanup.
  • Replaced the CLI-only _cli_ref resolver with a turn-scoped ContextVar bound by AIAgent.run_conversation, covering CLI, gateway, non-interactive, and kanban-worker agent turns while failing closed outside an active turn.
  • Added strict type validation for every deserialized handle field before capability/HMAC computation, returning UNKNOWN/UNKNOWN_HANDLE for malformed values.
  • Added regression coverage for tool-state restoration, aggregation side effects, non-CLI resolution, malformed handles, and turn-scope cleanup. The tests also exposed and fixed an immediate-cancellation race.

Validation:

  • 184 focused lifecycle/delegation/plugin-hook tests passed
  • Ruff passed on all changed Python files
  • git diff --check passed

I have left the review threads unresolved for reviewer confirmation.

@asimons81
asimons81 force-pushed the codex/feat-public-subagent-lifecycle-api branch from 6cb4bb8 to d3db019 Compare July 18, 2026 20:50
@asimons81

Copy link
Copy Markdown
Contributor Author

Rebased onto latest upstream/main (0 behind). All 3 hermes-sweeper concerns were already addressed by the follow-up commit 6cb4bb80a:

  1. Tool-name save/restore -- _build_child_preserving_parent_tools() saves/restores model_tools._last_resolved_tool_names around _build_child_agent()
  2. Delegation pipeline hooks -- _run_child_lifecycle() routes through _finalize_child_results() which handles memory notification, subagent_stop hook, and cost rollup -- same path delegate_task uses
  3. CLI-only resolver -- uses get_active_subagent_parent() via contextvars.ContextVar, set in run_agent.py's run_conversation(), which works across CLI, gateway, and TUI

Additional: malformed handle field validation (type checks on every SubagentHandle field before HMAC comparison) was also added in the same pass.

18/18 lifecycle contract tests pass on the rebased branch. Ready for human review.

@2001Y

2001Y commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@asimons81 I'm evaluating #65447 for a concrete use case: a Luna parent selectively launching a full Sol child. The current #63359 head appears to provide the missing plugin lifecycle surface, and its CI is green.

Is this still the intended salvage path? If useful, I can validate model selection and shared delegation limits against the current upstream base. I do not want to open a duplicate PR.

@asimons81

Copy link
Copy Markdown
Contributor Author

@2001Y -- yes, this is still the intended path for #65447. The branch was rebased onto upstream/main 4 days ago and the review feedback was addressed in a follow-up commit. CI is green and the change is mergable cleanly.

Your Luna/Sol use case is exactly the kind of real validation this needs. The shared delegation limits path (acceptance criterion 3 from #65447) is the obvious thing to validate -- both delegate_task and the plugin API route through the same host-owned lifecycle executor now, so the concurrency cap and depth limit should hit the same counters. Having someone verify that against a profile-parented launch would close the gap between contract tests and real behavior.

Want me to give you push access to the branch, or would you rather work off the current head and open a sibling PR against #65447? Happy to coordinate either way.

teknium1 added a commit that referenced this pull request Jul 27, 2026
…mon pool

Follow-ups on the salvaged #63359:
- _finalize_child_results carries tool_call_history on subagent_stop
  (the #62011/#72403 field landed after the PR branched; the shared
  pipeline must emit it for both delegate_task and plugin-launched
  children). Lifecycle test updated for the new payload field.
- The lifecycle executor uses DaemonThreadPoolExecutor — a wedged or
  abandoned child must never block interpreter exit at atexit-join time
  (same rationale as _run_single_child's timeout executor and the
  async-delegation pool).
- delegate_task's batch path keeps live-transcript wiring while routing
  child construction through the shared
  _build_child_preserving_parent_tools helper.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #72501 — thank you for this substantial piece of work! Both your commits were cherry-picked onto current main with your authorship preserved in git history (1865fb5, f60abd6).

During the salvage we resolved conflicts against the delegation work that landed since your branch (live transcripts, progress-based stall detection #72227, inline child API calls #72412) and added two integration follow-ups: the shared _finalize_child_results pipeline now carries the tool_call_history hook field (#72403, which postdated your branch), and the lifecycle executor uses the daemon pool so a wedged child can't block interpreter exit. Your ownership-invariants design — the HMAC handles, tree scoping, and the shared build/finalize helpers — survived intact and is exactly what made the two-doors-one-engine shape safe to land.

Plugins can now launch subagents via ctx.subagent_lifecycle. Docs are live under developer-guide/subagent-lifecycle-api.

@teknium1 teknium1 closed this Jul 27, 2026
jhjaggars-hermes added a commit to jhjaggars/hermes-agent that referenced this pull request Jul 27, 2026
* perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins

Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:

  style recalc 2736ms | script 1027ms | layout 89ms
  top callsite: pin @ fallback.tsx — 927ms

Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).

Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.

Measured on the live app, same drag, before -> after:
  fps      11.5 -> 59-60
  p95      101ms -> 18ms
  slow>33  60/60 -> 1/60

Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
  boundaries + row identity) and WEIGHT (part counts — budget only), and
  memoize groups + row JSX. A streamed part-append re-rendered every
  turn's boundary via its resetKey prop; explain() measured 540-865
  wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
  ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
  measured cost of the last one (33ms floor, 250ms cap), so multi-stream
  load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
  wrappers, committing the store ONCE on release (fixed-zone sides get
  flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
  with changed-hook indices, diag-real-loop/key-latency/switch-trace
  probes that drive the real app over CDP.

Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.

* perf(desktop): don't backfill the transcript while its thread streams

Switching to a STREAMING session took ~1.4s to settle while an idle
session settled in ~50ms. The autopsy probe named it: the
FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill runs as a transition, an
interrupted transition restarts from scratch, and stream flushes land
every 33-250ms — so the 300-part backfill re-rendered over and over
(measured: 1374ms settle, 30 commits, Primitive.div x2237 for one switch).

Gate the backfill on the thread being idle. The user lands on the live
tail immediately either way; older turns backfill the moment the run
ends, and 'Show earlier' remains the manual path meanwhile.

Measured on the live app (diag-switch-autopsy, real sessions):
  switch to idle session        ~35-55ms settled (unchanged)
  switch to streaming session   1374ms -> backfill deferred; lands at
                                the live tail like any other switch

Adds diag-switch-autopsy.mjs (per-switch settle/commits/top-renders) and
live-drive.mjs (status/fps/drag one-liners against the running app).

* fix(cli): scope -c/--resume to the current workspace

`hermes -c`/`--resume` (continue last session) resolved the globally
most-recently-used session, then cd'd into *its* recorded cwd. So running
`hermes -c` from repo A could land you in repo B's session — the session
you last touched anywhere, not the last one *here*.

Now `_resolve_last_session` scopes to the current workspace first: the git
repo root when CWD is inside a repo (so all sessions across its
subdirs/worktrees group together), else the CWD itself — matching the
`workspace_key` identity `hermes sessions list --workspace` already groups
on. It falls back to the unscoped global MRU when no session matches the
current workspace, preserving the old behaviour for fresh directories.

Adds `workspace_key` param to `SessionDB.search_sessions` and a
`_workspace_key_clause` SQL helper that mirrors `workspace_key()`: a row
matches when its `git_repo_root` equals the key, or (legacy rows without
git metadata) when its `cwd` is at or under it.

* feat(plugins): add public subagent lifecycle API

* fix subagent lifecycle ownership invariants

* fix(delegation): integrate lifecycle refactor with tool-history + daemon pool

Follow-ups on the salvaged NousResearch#63359:
- _finalize_child_results carries tool_call_history on subagent_stop
  (the NousResearch#62011/NousResearch#72403 field landed after the PR branched; the shared
  pipeline must emit it for both delegate_task and plugin-launched
  children). Lifecycle test updated for the new payload field.
- The lifecycle executor uses DaemonThreadPoolExecutor — a wedged or
  abandoned child must never block interpreter exit at atexit-join time
  (same rationale as _run_single_child's timeout executor and the
  async-delegation pool).
- delegate_task's batch path keeps live-transcript wiring while routing
  child construction through the shared
  _build_child_preserving_parent_tools helper.

* Revert the streaming-backfill gate — it broke a real E2E invariant

Deferring the FIRST_PAINT_BUDGET -> RENDER_BUDGET backfill while a thread
streams cut a 1374ms streaming-session switch to instant, but it also
means a streaming transcript stays clipped to 60 parts for the duration
of the run. `large-session-resume` asserts the resumed transcript shows
every seeded reply exactly once, and that count is short while the budget
is held down — a genuine behavior change, not a flaky test.

The switch cost is real and still worth fixing, but the fix has to keep
the full transcript mounted (raise the budget in idle callbacks, or
virtualize) rather than withhold it. Session-switch work is happening in
a parallel effort; leaving the invariant intact for them.

Everything else in this branch is untouched: the reflow-gated RO pins
(11.5 -> 59fps drag), the structural/weight signature split, the adaptive
stream flush, the tree-split preview, and the tool-row memo boundaries.

* fix(sessions): verify fully reconstructed recovery

* test(desktop): wait for committed compress directive

* test(desktop): assert compress argument stage

* test(desktop): isolate compression from slash completion

* fix(desktop): keep pinned sidebar rows in user order

flattenSessionsWithBranches always re-sorted roots by last_active, so a
turn finishing floated background tasks over the hand-picked Pinned list
even though $pinnedSessionIds already stored drag order. preserveOrder
skips that sort for pins (and other non-date-grouped manual lists); default
recents stay recency-sorted for truthful date buckets.

* refactor(fallback): single owner for backend identity and failure-scoped skips

Every fallback/dedup/skip decision asks one question — 'is this candidate
the same backend as the one that failed, along the axis that failure
invalidated?' — but it was re-implemented inline at six sites across four
subsystems, each comparing whatever string was locally convenient. Each
incident fixed one site while the others kept the bug: NousResearch#22548, NousResearch#70893,
NousResearch#59561, NousResearch#72468, NousResearch#62984/NousResearch#54250/NousResearch#57584.

agent/backend_identity.py now owns the concept: BackendIdentity (provider /
model / base_url axes), FailureScope (MODEL / CREDENTIAL / ENDPOINT — each
failure class invalidates a different axis), and should_skip_candidate().
Unknown axes never manufacture a skip (over-skipping strands failover; a
wrong try costs one RTT).

Migrated sites:
- chat_completion_helpers.try_activate_fallback: replaces the provider+model
  early-exit (the NousResearch#62984 bug: ignored base_url, stranding multi-endpoint
  pools) AND _fallback_entry_is_same_backend_by_base_url (deleted)
- auxiliary_client._try_configured_fallback_chain +
  _try_main_agent_model_fallback: replace label/model comparisons; auth and
  payment map to CREDENTIAL scope, keeping the NousResearch#59561 carve-out
- hermes_cli/fallback_cmd add: primary-match + duplicate checks now identity-
  aware (NousResearch#54250/NousResearch#57584): same provider+model on a different explicit
  base_url is a pool entry, not a duplicate

_mark_provider_unhealthy stays label-keyed deliberately: its only triggers
are confirmed 402s, which ARE credential-scoped.

Owner-level tests pin each incident's semantics by number; sabotage-verified
(removing the base_url axis fails the NousResearch#62984 test).

* test(desktop): wait for backfill before the duplicate-count baseline

The large-session-resume E2E captured initialMockReplyCount immediately
after openSeededSession, which returns once the NEWEST turn is in the
viewport. With FIRST_PAINT_BUDGET=20 (lowered from 60 in this branch),
only the newest ~10 turns mount at first paint; the older turns
backfill in a rAF. The baseline was reading 10 instead of 27, so once
the backfill mounted the full 28 (27 seeded + 1 new), the test saw
"28 ≠ 11" and reported duplicates that were never there.

Wait for the oldest seeded turn to mount before taking the baseline.
This makes the count reflect the fully-mounted transcript regardless
of FIRST_PAINT_BUDGET, so the perf win (smaller first paint) and the
no-duplicate invariant both hold.

Refs NousResearch#72504

* perf(desktop): keep thread message component types stable across a session switch

* perf(desktop): bail the transcript out of router-driven re-renders on session switch

* fix(tui): paint the OSC-10 default foreground on quantizing terminals

A skin that authors a background paints both terminal defaults: OSC-11
for the backdrop, OSC-10 to re-base every default-fg token (markdown
body, borders, anything rendered without an explicit color) onto the
theme's text tone.

The OSC-10 half never fired on a limited-palette terminal.
`normalizeThemeForAnsiLightTerminal` rewrites the foreground tones to
`ansi256(N)`, and `setTerminalForeground` only accepts `#rrggbb` — so
the argument failed the hex test and the write was silently skipped.
The background moved to the skin while default-fg text stayed on the
host profile's foreground.

That split is the reported symptom: prose renders in the terminal's own
near-black while every themed token beside it renders the skin's gray,
so the base text color appears to change between adjacent words. A
resize repaints the affected cells from the screen buffer, which is why
the text "goes black" on resize and why the mix looks scattered rather
than uniform.

Resolve the tone through a new `themeToneHex` before handing it to
OSC-10: `ansi256(N)` maps through the xterm grayscale ramp and 6x6x6
cube, an authored hex passes through, and anything with no paintable
color yields '' (which correctly clears back to the terminal default).

Verified on Terminal.app + the `brooklyn` skin: `theme.color.text` is
`ansi256(238)`, previously dropped, now emitted as
`ESC]10;#444444 BEL` alongside the existing `ESC]11;#f6f9fd BEL`.

* fmt(js): `npm run fix` on merge (NousResearch#72522)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (NousResearch#72532)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* ci: retrigger checks (GitHub Actions failed to resolve workflow file)

* chore: sync homelab branch with upstream main

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Tony Simons <asimons81@gmail.com>
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: b <b@b>
Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Jul 28, 2026
…tration

Follow-up on the salvaged #71610 commits:
- acquire/release a scoped lock on relay_url+pubkey in connect/disconnect
  (IRC pattern) so two profiles can't drive one Buzz identity — duplicate
  replies and split de-dupe state; +2 tests
- negative-cache _resolve_user_name failures so a profile-less pubkey
  doesn't re-hit 'users get' every poll sweep (flagged by @jethac on the PR)
- register user-guide/messaging/buzz in website/sidebars.ts (page was
  unreachable — the #63359 trap)
teknium1 added a commit that referenced this pull request Jul 29, 2026
…tration

Follow-up on the salvaged #71610 commits:
- acquire/release a scoped lock on relay_url+pubkey in connect/disconnect
  (IRC pattern) so two profiles can't drive one Buzz identity — duplicate
  replies and split de-dupe state; +2 tests
- negative-cache _resolve_user_name failures so a profile-less pubkey
  doesn't re-hit 'users get' every poll sweep (flagged by @jethac on the PR)
- register user-guide/messaging/buzz in website/sidebars.ts (page was
  unreachable — the #63359 trap)
@asimons81
asimons81 deleted the codex/feat-public-subagent-lifecycle-api branch July 30, 2026 00:46
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…mon pool

Follow-ups on the salvaged NousResearch#63359:
- _finalize_child_results carries tool_call_history on subagent_stop
  (the NousResearch#62011/NousResearch#72403 field landed after the PR branched; the shared
  pipeline must emit it for both delegate_task and plugin-launched
  children). Lifecycle test updated for the new payload field.
- The lifecycle executor uses DaemonThreadPoolExecutor — a wedged or
  abandoned child must never block interpreter exit at atexit-join time
  (same rationale as _run_single_child's timeout executor and the
  async-delegation pool).
- delegate_task's batch path keeps live-transcript wiring while routing
  child construction through the shared
  _build_child_preserving_parent_tools helper.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…tration

Follow-up on the salvaged NousResearch#71610 commits:
- acquire/release a scoped lock on relay_url+pubkey in connect/disconnect
  (IRC pattern) so two profiles can't drive one Buzz identity — duplicate
  replies and split de-dupe state; +2 tests
- negative-cache _resolve_user_name failures so a profile-less pubkey
  doesn't re-hit 'users get' every poll sweep (flagged by @jethac on the PR)
- register user-guide/messaging/buzz in website/sidebars.ts (page was
  unreachable — the NousResearch#63359 trap)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants