Compare commits

..

385 Commits

Author SHA1 Message Date
alt-glitch f7aa8b1545 ci: add path filters to Docker and test workflows, remove supply chain audit
- Docker build only triggers on main push (code/config changes) and
  releases, no longer on every PR
- Tests skip markdown-only and docs-only changes
- Remove supply-chain-audit workflow
2026-04-20 01:39:39 +05:30
konsisumer 1d1e1277e4 fix(gateway): flush undelivered tail before segment reset to preserve streamed text (#8124)
When a streaming edit fails mid-stream (flood control, transport error)
and a tool boundary arrives before the fallback threshold is reached,
the pre-boundary tail in `_accumulated` was silently discarded by
`_reset_segment_state`. The user saw a frozen partial message and
missing words on the other side of the tool call.

Flush the undelivered tail as a continuation message before the reset,
computed relative to the last successfully-delivered prefix so we don't
duplicate content the user already saw.
2026-04-19 01:43:04 -07:00
Teknium e017131403 feat(cron): add wakeAgent gate — scripts can skip the agent entirely
Extends the existing cron script hook with a wake gate ported from
nanoclaw #1232. When a cron job's pre-check Python script (already
sandboxed to HERMES_HOME/scripts/) writes a JSON line like
```json
{"wakeAgent": false}
```
on its last stdout line, `run_job()` returns the SILENT marker and
skips the agent entirely — no LLM call, no delivery, no tokens spent.
Useful for frequent polls (every 1-5 min) that only need to wake the
agent when something has genuinely changed.

Any other script output (non-JSON, missing key, non-dict, `wakeAgent: true`,
truthy/falsy non-False values) behaves as before: stdout is injected
as context and the agent runs normally. Strict `False` is required
to skip — avoids accidental gating from arbitrary JSON.

Refactor:
- New pure helper `_parse_wake_gate(script_output)` in cron/scheduler.py
- `_build_job_prompt` accepts optional `prerun_script` tuple so the
  script runs exactly once per job (run_job runs it for the gate check,
  reuses the output for prompt injection)
- `run_job` short-circuits with SILENT_MARKER when gate fires

Script failures (success=False) still cannot trigger the gate — the
failure is reported as context to the agent as before.

This replaces the approach in closed PR #3837, which inlined bash
scripts via tempfile and lost the path-traversal/scripts-dir sandbox
that main's impl has. The wake-gate idea (the one net-new capability)
is ported on top of the existing sandboxed Python-script model.

Tests:
- 11 pure unit tests for _parse_wake_gate (empty, whitespace, non-JSON,
  non-dict JSON, missing key, truthy/falsy non-False, multi-line,
  trailing blanks, non-last-line JSON)
- 5 integration tests for run_job wake-gate (skip returns SILENT,
  wake-true passes through, script-runs-only-once, script failure
  doesn't gate, no-script regression)
- Full tests/cron/ suite: 194/194 pass
2026-04-19 01:42:35 -07:00
helix4u c94d26c69b fix(cli): sanitize interactive command output 2026-04-19 01:16:34 -07:00
kshitijk4poor 175cf7e6bb fix: tighten quiet-mode salvage follow-ups
Follow-up for the helix4u easy-fix salvage batch:
- route remaining context-engine quiet-mode output through
  _should_emit_quiet_tool_messages() so non-CLI/library callers stay
  silent consistently
- drop the extra senderAliases computation from WhatsApp allowlist-drop
  logging and remove the now-unused import

This keeps the batch scoped to the intended fixes while avoiding
leaked quiet-mode output and unnecessary duplicate work in the bridge.
2026-04-19 00:28:25 -07:00
helix4u cd59af17cc fix(agent): silence quiet_mode in python library use 2026-04-19 00:28:25 -07:00
helix4u 361675018f fix(setup): stop hardcoding max-iterations copy 2026-04-19 00:28:25 -07:00
helix4u 3ade655999 fix(whatsapp): log allowlist drops in bridge 2026-04-19 00:28:25 -07:00
Teknium 7c10761dd2 fix(discord): shield text-batch flush from follow-up cancel (#12444)
When Discord splits a long message at 2000 chars, _enqueue_text_event
buffers each chunk and schedules a _flush_text_batch task with a
short delay.  If another chunk lands while the prior flush task is
already inside handle_message, _enqueue_text_event calls
prior_task.cancel() — and without asyncio.shield, CancelledError
propagates from the flush task into handle_message → the agent's
streaming request, aborting the response the user was waiting on.

Reproducer: user sends a 3000-char prompt (split by Discord into 2
messages).  Chunk 1 lands, flush delay starts, chunk 2 lands during
the brief window when chunk 1's flush has already committed to
handle_message.  Agent's current streaming response is cancelled
with CancelledError, user sees a truncated or missing reply.

Fix (gateway/platforms/discord.py):
- Wrap the handle_message call in asyncio.shield so the inner
  dispatch is protected from the outer task's cancel.
- Add an except asyncio.CancelledError clause so the outer task
  still exits cleanly when cancel lands during the sleep window
  (before the pop) — semantics for that path are unchanged.

The new flush task spawned by the follow-up chunk still handles its
own batch via the normal pending-message / active-session machinery
in base.py, so follow-ups are not lost.

Tests: tests/gateway/test_text_batching.py —
test_shield_protects_handle_message_from_cancel.  Tracks a distinct
first_handle_cancelled event so the assertion fails cleanly when the
shield is missing (verified by stashing the fix and re-running).

Live E2E on the live-loaded DiscordAdapter:
  first_handle_cancelled: False  (shield worked)
  first_handle_completed: True   (handle_message ran to completion)
2026-04-19 00:09:38 -07:00
Teknium dca439fe92 fix(tui): scope session.interrupt pending-prompt release to the calling session (#12441)
session.interrupt on session A was blast-resolving pending
clarify/sudo/secret prompts on ALL sessions sharing the same
tui_gateway process.  Other sessions' agent threads unblocked with
empty-string answers as if the user had cancelled — silent
cross-session corruption.

Root cause: _pending and _answers were globals keyed by random rid
with no record of the owning session.  _clear_pending() iterated
every entry, so the session.interrupt handler had no way to limit
the release to its own sid.

Fix:
- tui_gateway/server.py: _pending now maps rid to (sid, Event)
  tuples.  _clear_pending takes an optional sid argument and filters
  by owner_sid when provided.  session.interrupt passes the calling
  sid so unrelated sessions are untouched.  _clear_pending(None)
  remains the shutdown path for completeness.
- _block and _respond updated to pack/unpack the new tuple format.

Tests (tests/test_tui_gateway_server.py): 4 new cases.
- test_interrupt_only_clears_own_session_pending: two sessions with
  pending prompts, interrupting one must not release the other.
- test_interrupt_clears_multiple_own_pending: same-sid multi-prompt
  release works.
- test_clear_pending_without_sid_clears_all: shutdown path preserved.
- test_respond_unpacks_sid_tuple_correctly: _respond handles the
  tuple format.

Also updated tests/tui_gateway/test_protocol.py to use the new tuple
format for test_block_and_respond and test_clear_pending.

Live E2E against the live Python environment confirmed cross-session
isolation: interrupting sid_a released its own pending prompt without
touching sid_b's.  All 78 related tests pass.
2026-04-19 00:03:58 -07:00
Teknium ce410521b3 feat(browser): add browser_cdp raw DevTools Protocol passthrough (#12369)
Agents can now send arbitrary CDP commands to the browser. The tool is
gated on a reachable CDP endpoint at session start — it only appears in
the toolset when BROWSER_CDP_URL is set (from '/browser connect') or
'browser.cdp_url' is configured in config.yaml. Backends that don't
currently expose CDP to the Python side (Camofox, default local
agent-browser, cloud providers whose per-session cdp_url is not yet
surfaced) do not see the tool at all.

Tool schema description links to the CDP method reference at
https://chromedevtools.github.io/devtools-protocol/ so the agent can
web_extract specific method docs on demand.

Stateless per call. Browser-level methods (Target.*, Browser.*,
Storage.*) omit target_id. Page-level methods attach to the target
with flatten=true and dispatch the method on the returned sessionId.
Clean errors when the endpoint becomes unreachable mid-session or
the URL isn't a WebSocket.

Tests: 19 unit (mock CDP server + gate checks) + E2E against real
headless Chrome (Target.getTargets, Browser.getVersion,
Runtime.evaluate with target_id, Page.navigate + re-eval, bogus
method, bogus target_id, missing endpoint) + E2E of the check_fn
gate (tool hidden without CDP URL, visible with it, hidden again
after unset).
2026-04-19 00:03:10 -07:00
helix4u d66414a844 docs(custom-providers): use key_env in examples 2026-04-18 23:07:59 -07:00
helix4u 7b1a11b971 fix(memory): keep Honcho provider opt-in 2026-04-18 22:50:55 -07:00
kshitijk4poor 0a8d48809f chore: add LeonSGP43 numeric noreply email to AUTHOR_MAP
The cherry-picked commit from #11434 uses the 154585401+ prefixed
noreply format. Add it alongside the existing bare entry so the
contributor audit passes.
2026-04-18 22:50:55 -07:00
Erosika 21d5ef2f17 feat(honcho): wizard cadence default 2, surface reasoning level, backwards-compat fallback
Setup wizard now always writes dialecticCadence=2 on new configs and
surfaces the reasoning level as an explicit step with all five options
(minimal / low / medium / high / max), always writing
dialecticReasoningLevel.

Code keeps a backwards-compat fallback of 1 when dialecticCadence is
unset so existing honcho.json configs that predate the setting keep
firing every turn on upgrade. New setups via the wizard get 2
explicitly; docs show 2 as the default.

Also scrubs editorial lines from code and docs ("max is reserved for
explicit tool-path selection", "Unset → every turn; wizard pre-fills 2",
and similar process-exposing phrasing) and adds an inline link to
app.honcho.dev where the server-side observation sync is mentioned in
honcho.md. Recommended cadence range updated to 1-5 across docs and
wizard copy.
2026-04-18 22:50:55 -07:00
LeonSGP43 5b6792f04d fix(honcho): scope gateway sessions by runtime user id 2026-04-18 22:50:55 -07:00
Erosika ba7da73ca9 test(honcho): drop two first-turn tests subsumed by prewarm + smoke coverage
- TestDialecticDepth::test_first_turn_runs_dialectic_synchronously:
  covered by TestSessionStartDialecticPrewarm::test_turn1_falls_back_to_sync_when_prewarm_missing
  (more realistic — exercises the empty-prewarm → sync-fallback path)
- TestDialecticDepth::test_first_turn_dialectic_does_not_double_fire:
  covered by TestDialecticLifecycleSmoke (turn 1 flow) and
  TestDialecticCadenceAdvancesOnSuccess::test_empty_dialectic_result_does_not_advance_cadence

Both predate the prewarm refactor and test paths that are now
fallback behaviors already covered elsewhere.
2026-04-18 22:50:55 -07:00
Erosika c630dfcdac feat(honcho): dialectic liveness — stale-thread watchdog, stale-result discard, empty-streak backoff
Hardens the dialectic lifecycle against three failure modes that could
leave the prefetch pipeline stuck or injecting stale content:

- Stale-thread watchdog: _thread_is_live() treats any prefetch thread
  older than timeout × 2.0 as dead. A hung Honcho call can no longer
  block subsequent fires indefinitely.

- Stale-result discard: pending _prefetch_result is tagged with its
  fire turn. prefetch() discards the result if more than cadence × 2
  turns passed before a consumer read it (e.g. a run of trivial-prompt
  turns between fire and read).

- Empty-streak backoff: consecutive empty dialectic returns widen the
  effective cadence (dialectic_cadence + streak, capped at cadence × 8).
  A healthy fire resets the streak. Prevents the plugin from hammering
  the backend every turn when the peer graph is cold.

- liveness_snapshot() on the provider exposes current turn, last fire,
  pending fire-at, empty streak, effective cadence, and thread status
  for in-process diagnostics.

- system_prompt_block: nudge the model that honcho_reasoning accepts
  reasoning_level minimal/low/medium/high/max per call.

- hermes honcho status: surface base reasoning level, cap, and heuristic
  toggle so config drift is visible at a glance.

Tests: 550 passed.
- TestDialecticLiveness (8 tests): stale-thread recovery, stale-result
  discard, fresh-result retention, backoff widening, backoff ceiling,
  streak reset on success, streak increment on empty, snapshot shape.
- Existing TestDialecticCadenceAdvancesOnSuccess::test_in_flight_thread_is_not_stacked
  updated to set _prefetch_thread_started_at so it tests the
  fresh-thread-blocks branch (stale path covered separately).
- test_cli TestCmdStatus fake updated with the new config attrs surfaced
  in the status block.
2026-04-18 22:50:55 -07:00
Erosika 098efde848 docs(honcho): wizard cadence default 2, prewarm/depth + observation + multi-peer
- cli: setup wizard pre-fills dialecticCadence=2 (code default stays 1
  so unset → every turn)
- honcho.md: fix stale dialecticCadence default in tables, add
  Session-Start Prewarm subsection (depth runs at init), add
  Query-Adaptive Reasoning Level subsection, expand Observation
  section with directional vs unified semantics and per-peer patterns
- memory-providers.md: fix stale default, rename Multi-agent/Profiles
  to Multi-peer setup, add concrete walkthrough for new profiles and
  sync, document observation toggles + presets, link to honcho.md
- SKILL.md: fix stale defaults, add Depth at session start callout
2026-04-18 22:50:55 -07:00
Erosika 5f9907c116 chore(honcho): drop docs from PR scope, scrub commentary
- Revert website/docs and SKILL.md changes; docs unification handled separately
- Scrub commit/PR refs and process narration from code comments and test
  docstrings (no behavior change)
2026-04-18 22:50:55 -07:00
Erosika 78586ce036 fix(honcho): dialectic lifecycle — defaults, retry, prewarm consumption
Several correctness and cost-safety fixes to the Honcho dialectic path
after a multi-turn investigation surfaced a chain of silent failures:

- dialecticCadence default flipped 3 → 1. PR #10619 changed this from 1 to
  3 for cost, but existing installs with no explicit config silently went
  from per-turn dialectic to every-3-turns on upgrade. Restores pre-#10619
  behavior; 3+ remains available for cost-conscious setups. Docs + wizard
  + status output updated to match.

- Session-start prewarm now consumed. Previously fired a .chat() on init
  whose result landed in HonchoSessionManager._dialectic_cache and was
  never read — pop_dialectic_result had zero call sites. Turn 1 paid for
  a duplicate synchronous dialectic. Prewarm now writes directly to the
  plugin's _prefetch_result via _prefetch_lock so turn 1 consumes it with
  no extra call.

- Prewarm is now dialecticDepth-aware. A single-pass prewarm can return
  weak output on cold peers; the multi-pass audit/reconcile cycle is
  exactly the case dialecticDepth was built for. Prewarm now runs the
  full configured depth in the background.

- Silent dialectic failure no longer burns the cadence window.
  _last_dialectic_turn now advances only when the result is non-empty.
  Empty result → next eligible turn retries immediately instead of
  waiting the full cadence gap.

- Thread pile-up guard. queue_prefetch skips when a prior dialectic
  thread is still in-flight, preventing stacked races on _prefetch_result.

- First-turn sync timeout is recoverable. Previously on timeout the
  background thread's result was stored in a dead local list. Now the
  thread writes into _prefetch_result under lock so the next turn
  picks it up.

- Cadence gate applies uniformly. At cadence=1 the old "cadence > 1"
  guard let first-turn sync + same-turn queue_prefetch both fire.
  Gate now always applies.

- Restored query-length reasoning-level scaling, dropped in 9a0ab34c.
  Scales dialecticReasoningLevel up on longer queries (+1 at ≥120 chars,
  +2 at ≥400), clamped at reasoningLevelCap. Two new config keys:
  `reasoningHeuristic` (bool, default true) and `reasoningLevelCap`
  (string, default "high"; previously parsed but never enforced).
  Respects dialecticDepthLevels and proportional lighter-early passes.

- Restored short-prompt skip, dropped in ef7f3156. One-word
  acknowledgements ("ok", "y", "thanks") and slash commands bypass
  both injection and dialectic fire.

- Purged dead code in session.py: prefetch_dialectic, _dialectic_cache,
  set_dialectic_result, pop_dialectic_result — all unused after prewarm
  refactor.

Tests: 542 passed across honcho_plugin/, agent/test_memory_provider.py,
and run_agent/test_run_agent.py. New coverage:
- TestTrivialPromptHeuristic (classifier + prefetch/queue skip)
- TestDialecticCadenceAdvancesOnSuccess (empty-result retry, pile-up guard)
- TestSessionStartDialecticPrewarm (prewarm consumed, sync fallback)
- TestReasoningHeuristic (length bumps, cap clamp, interaction with depth)
- TestDialecticLifecycleSmoke (end-to-end 8-turn session walk)
2026-04-18 22:50:55 -07:00
Teknium bf5d7462ba fix(tui): reject history-mutating commands while session is running (#12416)
Fixes silent data loss in the TUI when /undo, /compress, /retry, or
rollback.restore runs during an in-flight agent turn.  The version-
guard at prompt.submit:1449 would fail the version check and silently
skip writing the agent's result — UI showed the assistant reply but
DB / backend history never received it, causing UI↔backend desync
that persisted across session resume.

Changes (tui_gateway/server.py):
- session.undo, session.compress, /retry, rollback.restore (full-history
  only — file-scoped rollbacks still allowed): reject with 4009 when
  session.running is True.  Users can /interrupt first.
- prompt.submit: on history_version mismatch (defensive backstop),
  attach a 'warning' field to message.complete and log to stderr
  instead of silently dropping the agent's output.  The UI can surface
  the warning to the user; the operator can spot it in logs.

Tests (tests/test_tui_gateway_server.py): 6 new cases.
- test_session_undo_rejects_while_running
- test_session_undo_allowed_when_idle (regression guard)
- test_session_compress_rejects_while_running
- test_rollback_restore_rejects_full_history_while_running
- test_prompt_submit_history_version_mismatch_surfaces_warning
- test_prompt_submit_history_version_match_persists_normally (regression)

Validated: against unpatched server.py the three 'rejects_while_running'
tests fail and the version-mismatch test fails (no 'warning' field).
With the fix, all 6 pass, all 33 tests in the file pass, 74 TUI tests
in total pass.  Live E2E against the live Python environment confirmed
all 5 patches present and guards enforce 4009 exactly as designed.
2026-04-18 22:30:10 -07:00
Teknium 3a6351454b fix(gateway): close pending-drain and late-arrival races in base adapter (#12371)
Two related race conditions in gateway/platforms/base.py that could
produce duplicate agent runs or silently drop messages. Neither is
specific to any one platform — all adapters inherit this logic.

R5 (HIGH) — duplicate agent spawn on turn chain
  In _process_message_background, the pending-drain path deleted
  _active_sessions[session_key] before awaiting typing_task.cancel()
  and then recursively awaiting _process_message_background for the
  queued event. During the typing_task await, a fresh inbound message
  M3 could pass the Level-1 guard (entry now missing), set its own
  Event, and spawn a second _process_message_background for the same
  session_key — two agents running simultaneously, duplicate responses,
  duplicate tool calls.

  Fix: keep the _active_sessions entry populated and only clear() the
  Event. The guard stays live, so any concurrent inbound message takes
  the busy-handler path (queue + interrupt) as intended.

R6 (MED-HIGH) — message dropped during finally cleanup
  The finally block has two await points (typing_task, stop_typing)
  before it deletes _active_sessions. A message arriving in that
  window passes the guard (entry still live), lands in
  _pending_messages via the busy-handler — and then the unconditional
  del removes the guard with that message still queued. Nothing
  drains it; the user never gets a reply.

  Fix: before deleting _active_sessions in finally, pop any late
  pending_messages entry and spawn a drain task for it. Only delete
  _active_sessions when no pending is waiting.

Tests: tests/gateway/test_pending_drain_race.py — three regression
cases. Validated: without the fix, two of the three fail exactly
where the races manifest (duplicate-spawn guard loses identity,
late-arrival 'LATE' message not in processed list).
2026-04-18 19:32:26 -07:00
Teknium 762f7e9796 feat: configurable approval mode for cron jobs (approvals.cron_mode)
Add approvals.cron_mode config option that controls how cron jobs handle
dangerous commands. Previously, cron jobs silently auto-approved all
dangerous commands because there was no user present to approve them.

Now the behavior is configurable:
  - deny (default): block dangerous commands and return a message telling
    the agent to find an alternative approach. The agent loop continues —
    it just can't use that specific command.
  - approve: auto-approve all dangerous commands (previous behavior).

When a command is blocked, the agent receives the same response format as
a user denial in the CLI — exit_code=-1, status=blocked, with a message
explaining why and pointing to the config option. This keeps the agent
loop running and encourages it to adapt.

Implementation:
  - config.py: add approvals.cron_mode to DEFAULT_CONFIG
  - scheduler.py: set HERMES_CRON_SESSION=1 env var before agent runs
  - approval.py: both check_command_approval() and check_all_command_guards()
    now check for cron sessions and apply the configured mode
  - 21 new tests covering config parsing, deny/approve behavior, and
    interaction with other bypass mechanisms (yolo, containers)
2026-04-18 19:24:35 -07:00
Teknium b02833f32d fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json (#12360)
Codex OAuth refresh tokens are single-use and rotate on every refresh.
Sharing them with the Codex CLI / VS Code via ~/.codex/auth.json made
concurrent use of both tools a race: whoever refreshed last invalidated
the other side's refresh_token.  On top of that, the silent auto-import
path picked up placeholder / aborted-auth data from ~/.codex/auth.json
(e.g. literal {"access_token":"access-new","refresh_token":"refresh-new"})
and seeded it into the Hermes pool as an entry the selector could
eventually pick.

Hermes now owns its own Codex auth state end-to-end:

Removed
- agent/credential_pool.py: _sync_codex_entry_from_cli() method,
  its pre-refresh + retry + _available_entries call sites, and the
  post-refresh write-back to ~/.codex/auth.json.
- agent/credential_pool.py: auto-import from ~/.codex/auth.json in
  _seed_from_singletons() — users now run `hermes auth openai-codex`
  explicitly.
- hermes_cli/auth.py: silent runtime migration in
  resolve_codex_runtime_credentials() — now surfaces
  `codex_auth_missing` directly (message already points to `hermes auth`).
- hermes_cli/auth.py: post-refresh write-back in
  _refresh_codex_auth_tokens().
- hermes_cli/auth.py: dead helper _write_codex_cli_tokens() and its 4
  tests in test_auth_codex_provider.py.

Kept
- hermes_cli/auth.py: _import_codex_cli_tokens() — still used by the
  interactive `hermes auth openai-codex` setup flow for a user-gated
  one-time import (with "a separate login is recommended" messaging).

User-visible impact
- On existing installs with Hermes auth already present: no change.
- On a fresh install where the user has only logged in via Codex CLI:
  `hermes chat --provider openai-codex` now fails with "No Codex
  credentials stored. Run `hermes auth` to authenticate." The
  interactive setup flow then detects ~/.codex/auth.json and offers a
  one-time import.
- On an install where Codex CLI later refreshes its token: Hermes is
  unaffected (we no longer read from that file at runtime).

Tests
- tests/hermes_cli/test_auth_codex_provider.py: 15/15 pass.
- tests/hermes_cli/test_auth_commands.py: 20/20 pass.
- tests/agent/test_credential_pool.py: 31/31 pass.
- Live E2E on openai-codex/gpt-5.4: 1 API call, 1.7s latency,
  3 log lines, no refresh events, no auth drama.

The related 14:52 refresh-loop bug (hundreds of rotations/minute on a
single entry) is a separate issue — that requires a refresh-attempt
cap on the auth-recovery path in run_agent.py, which remains open.
2026-04-18 19:19:46 -07:00
yeyitech bd01ec7885 fix(cli): strip all reasoning tag variants from /resume recap
HermesCLI._display_resumed_history() calls the module-level _strip_reasoning_tags() to clean assistant content before rendering the recap panel.  The tag list was missing <thought> (Gemma 4) and there was no pass for stray orphan </tag> closes, so those variants leaked internal reasoning into the recap display (#11316).

- Add <thought> to _REASONING_TAGS.
- Add a third regex pass that strips orphan close tags (e.g. 'stuff</think>answer' → 'stuffanswer').
- Apply IGNORECASE to closed-pair and unclosed-pair passes so mixed-case variants (<THINK>, <Thinking>) are handled uniformly — previously both 'THINKING' and 'thinking' had to be listed explicitly as distinct tuple entries, which missed <Thinking>.

7 new regression tests in tests/cli/test_resume_display.py covering: <think>, <thinking>, <reasoning>, <thought>, unclosed <think>, multiple interleaved blocks, and orphan </think> close.

Resolves #11316.

Originally proposed as PR #11366.
2026-04-18 19:19:24 -07:00
Tranquil-Flow ec48ec5530 fix(agent): strip <think> blocks from stored assistant content
Inline reasoning tags in an assistant message's content field leak to every downstream consumer: messaging platforms (#8878, #9568), API replay of prior turns, session transcript, CLI recap, generated session titles, and context compression.  _extract_reasoning() already captures the reasoning text into msg['reasoning'] separately, so the raw tags in content are redundant.

Stripping once at the storage boundary in _build_assistant_message() cleans the content for every downstream path in one place — no per-platform or per-path stripper needed.  Measured impact on a real MiniMax M2.7-highspeed session (per @luoyejiaoe-source, #9306): 55% of assistant messages started with <think> blocks, 51/100 session titles were polluted, 16% content-size reduction.

3 new regression tests in TestBuildAssistantMessage: closed-pair strip with reasoning capture, no-think-tag passthrough, and unterminated-block strip.

Resolves #8878 and #9568.

Originally proposed as PR #9250.
2026-04-18 19:19:24 -07:00
Teknium 9489d1577d fix(agent): strip unterminated <think> blocks from visible content
Providers served via NIM (MiniMax M2.7, some Moonshot/DeepSeek proxies) sometimes drop the closing </think> tag, leaving raw reasoning in the assistant's content field.  _strip_think_blocks()'s closed-pair regex is non-greedy so it only matches complete blocks — any orphan <think>...EOF survived the stripper and leaked to users (#8878, #9568, #10408).

Adds an unterminated-tag pass that fires when an open reasoning tag sits at a block boundary (start of text or after a newline) with no matching close.  Everything from that tag to end of string is stripped.  The block-boundary check mirrors gateway/stream_consumer.py's filter so models that mention <think> in prose are not over-stripped.

Also makes the closed-pair regexes consistently case-insensitive so <THINK>...</THINK> and <Thinking>...</Thinking> are handled uniformly — previously the mixed-case open tag would bypass the closed-pair pass and be caught by the unterminated-tag pass, taking trailing visible content with it.

6 new regression tests in TestStripThinkBlocks covering: unterminated <think>, unterminated <thought>, multi-line unterminated, line-start orphan with preserved prefix, prose-mention non-regression, mixed-case closed pairs.

The implementation is inspired by @luinbytes's PR #10408 report of the NIM/MiniMax symptom.  This commit does not include the 💭/🧠 emoji regexes from that PR — those glyphs are Hermes CLI display decorations, not model content markers.
2026-04-18 19:19:24 -07:00
Teknium 79c5a381c5 feat(uninstall): offer to remove named profiles when uninstalling from default
When `hermes uninstall` runs from the default HERMES_HOME (~/.hermes)
and other named profiles exist under ~/.hermes/profiles/, show them in
the installation overview and prompt:

    Also stop and remove these N profile(s)? [y/N]

If confirmed, for each named profile we:
  1. Shell out to `python -m hermes_cli.main -p <name> gateway stop/uninstall`
     to stop the gateway and remove its systemd unit or launchd plist
     (service names + unit paths are derived from HERMES_HOME, so we
     can't cleanly switch in-process)
  2. Remove the ~/.local/bin/<name> alias wrapper (outside HERMES_HOME)
  3. Wipe the profile's HERMES_HOME dir

Previously `hermes uninstall` was silently profile-scoped, leaving
zombie systemd units at ~/.config/systemd/user/hermes-gateway-<profile>.service
and zombie HERMES_HOMEs under ~/.hermes/profiles/ whenever a user
uninstalled from default with other profiles configured.

Prompt only appears when uninstalling from the default root. Uninstalling
from within a named profile stays profile-scoped as before.
2026-04-18 19:18:13 -07:00
Teknium 3fe0d503b6 fix(uninstall): properly stop and destroy gateway on hermes uninstall
The uninstaller's gateway cleanup was incomplete:
- Linux only (ignored macOS launchd)
- Only checked user systemd scope (missed system services)
- Didn't kill standalone gateway processes (hermes gateway run)
- Missing DBUS env setup for headless servers

Now delegates to gateway.py's existing machinery:
1. Kill any standalone gateway processes (all platforms)
2. Linux: stop + disable + remove both user AND system systemd services
3. macOS: unload + remove launchd plist
4. Warns (instead of silently failing) when system service needs sudo
2026-04-18 19:18:13 -07:00
Teknium 1e5f0439d9 docs: update Anthropic console URLs to platform.claude.com
Anthropic migrated their developer console from console.anthropic.com
to platform.claude.com. Two user-facing display URLs were still pointing
to the old domain:

- hermes_cli/main.py — API key prompt in the Anthropic model flow
- run_agent.py — 401 troubleshooting output

The OAuth token refresh endpoint was already migrated in PR #3246
(with fallback).

Spotted by @LucidPaths in PR #3237.

(Salvage of #3758 — dropped the setup.py hunk since that section was
refactored away and no longer contains the stale URL.)
2026-04-18 18:55:58 -07:00
Teknium 2a2e5c0fed fix: force relogin on 401/403 Codex token refresh failures
When the OAuth token endpoint returns 401/403 but the JSON body
doesn't contain a known error code (invalid_grant, etc.),
relogin_required stayed False. Users saw a bare error message
without guidance to re-authenticate.

Now any 401/403 from the token endpoint forces relogin_required=True,
since these status codes always indicate invalid credentials on a
refresh endpoint. 500+ errors remain as transient (no relogin).
2026-04-18 18:54:34 -07:00
Teknium beabbd87ef fix(gateway): close adapter resources when connect() fails or raises (#12339)
Gateway startup leaks aiohttp.ClientSession (and other partial-init
resources) when an adapter's connect() returns False or raises. The
adapter is never added to self.adapters, so the shutdown path at
gateway/run.py:2426 never calls disconnect() on it — Python GC later
logs 'Unclosed client session' at process exit.

Seen on 2026-04-18 18:08:16 during a double --replace takeover cycle:
one of the partial-init sessions survived past shutdown and emitted
the warning right before status=75/TEMPFAIL.

Fix:
- New GatewayRunner._safe_adapter_disconnect() helper — calls
  adapter.disconnect() and swallows any exception. Used on error paths.
- Connect loop calls it in both failure branches: success=False and
  except Exception.
- Adapter disconnect() implementations are already expected to be
  idempotent and tolerate partial-init state (they all guard on
  self._http_session / self._bridge_process before touching them).

Tests: tests/gateway/test_safe_adapter_disconnect.py — 3 cases verify
the helper forwards to disconnect, swallows exceptions, and tolerates
platform=None.
2026-04-18 18:53:31 -07:00
Teknium 632a807a3e fix(gateway): slash commands never interrupt a running agent (#12334)
Any recognized slash command now bypasses the Level-1 active-session
guard instead of queueing + interrupting. A mid-run /model (or
/reasoning, /voice, /insights, /title, /resume, /retry, /undo,
/compress, /usage, /provider, /reload-mcp, /sethome, /reset) used to
interrupt the agent AND get silently discarded by the slash-command
safety net — zero-char response, dropped tool calls.

Root cause:
- Discord registers 41 native slash commands via tree.command().
- Only 14 were in ACTIVE_SESSION_BYPASS_COMMANDS.
- The other ~15 user-facing ones fell through base.py:handle_message
  to the busy-session handler, which calls running_agent.interrupt()
  AND queues the text.
- After the aborted run, gateway/run.py:9912 correctly identifies the
  queued text as a slash command and discards it — but the damage
  (interrupt + zero-char response) already happened.

Fix:
- should_bypass_active_session() now returns True for any resolvable
  slash command. ACTIVE_SESSION_BYPASS_COMMANDS stays as the subset
  with dedicated Level-2 handlers (documentation + tests).
- gateway/run.py adds a catch-all after the dedicated handlers that
  returns a user-visible "agent busy — wait or /stop first" response
  for any other resolvable command.
- Unknown text / file-path-like messages are unchanged — they still
  queue.

Also:
- gateway/platforms/discord.py logs the invoker identity on every
  slash command (user id + name + channel + guild) so future
  ghost-command reports can be triaged without guessing.

Tests:
- 15 new parametrized cases in test_command_bypass_active_session.py
  cover every previously-broken Discord slash command.
- Existing tests for /stop, /new, /approve, /deny, /help, /status,
  /agents, /background, /steer, /update, /queue still pass.
- test_steer.py's ACTIVE_SESSION_BYPASS_COMMANDS check still passes.

Fixes #5057. Related: #6252, #10370, #4665.
2026-04-18 18:53:22 -07:00
Teknium 41560192c4 chore(attribution): add AUTHOR_MAP entry for nish3451
Adds the nish3451 noreply email to the AUTHOR_MAP so CI attribution checks
pass for the #6100 Telegram DM fallback fix merged in 1a9a2d7f.
2026-04-18 18:52:41 -07:00
Teknium aa5f89d3ea test: add coverage for from_user=None DM fallback
Tests the three cases:
- DM with from_user=None: user_id falls back to chat.id
- Group with from_user=None: user_id stays None (safe default)
- DM with from_user present: user_id uses from_user.id (no regression)
2026-04-18 18:18:01 -07:00
Nish 1a9a2d7fe8 fix(gateway/telegram): fall back to chat.id when from_user is None in DMs
When `message.from_user` is None — which can happen for forwarded messages,
anonymous admin mode in groups, or certain Telegram client edge cases —
`_build_message_event` set `source.user_id` to None. This caused:

1. `_is_user_authorized()` to early-return False (`if not user_id: return False`)
2. The access check never compared against `TELEGRAM_ALLOWED_USERS` even when
   the user actually was in the allowlist
3. The pairing flow fired and generated a code for `user_id=None`
4. The pairing approval saved an entry under the literal string key "null"
5. The user was effectively locked out because their real user_id never
   matched the "null" key on subsequent messages

For DMs (`chat_type == "dm"`), Telegram guarantees `chat.id == user.id` —
they are the same numeric ID for private chats. Falling back to `chat.id`
when `from_user` is None for DMs restores the expected access-control
behavior without weakening it (group/channel chats correctly stay None).

Also adds a parallel `user_name` fallback to `chat.full_name` so the
display name still works in the same edge case.
2026-04-18 18:18:01 -07:00
Teknium 139a6da67c fix(skills): touchdesigner-mcp setup.sh — correct pgrep match + suppress stray yaml output
Discovered while dogfooding the skill end-to-end:

- pgrep -if "TouchDesigner" matched any shell whose command line
  contained the substring (including the setup script's own invocation
  under certain wrappers), falsely reporting TD running on machines
  where it isn't. Switch to pgrep -x (exact process name match,
  supported on both macOS and Linux) and also check TouchDesignerFTE
  (the non-commercial variant).
- The embedded python3 yaml-writer printed 'added' / 'exists' to
  stdout as status, which leaked a stray word into the setup output
  right before the ✔ line. Drop the print()s — the bash-level ✔/✘ is
  the status indicator.
2026-04-18 17:43:42 -07:00
Teknium 6b31e20894 chore(skills): touchdesigner-mcp follow-ups
- Remove orphan skills/creative/touchdesigner/references/pitfalls.md
  left over from the rename commit (git add-then-edit instead of git mv
  meant the old file never got deleted).
- Honour $HERMES_HOME in setup.sh and SKILL.md setup invocation so
  profile-aware installs work correctly.
- Fix troubleshooting.md config path to use $HERMES_HOME instead of
  hardcoding ~/.hermes/.
- Add touchdesigner-mcp entries to skills-catalog.md and
  optional-skills-catalog.md for parity with blender-mcp/meme-generation.
2026-04-18 17:43:42 -07:00
Teknium 11ee87e605 chore(attribution): add AUTHOR_MAP entry for kshitijk4poor@gmail.com
Covers the non-noreply email used on commit dd3e6424 (rename of the
TouchDesigner skill to touchdesigner-mcp).
2026-04-18 17:43:42 -07:00
kshitijk4poor 6d2fe1d624 feat: rename touchdesigner -> touchdesigner-mcp, move to optional-skills/
- Rename skill to touchdesigner-mcp (matches blender-mcp convention)
- Move from skills/creative/ to optional-skills/creative/
- Fix duplicate pitfall numbering (#3 appeared twice)
- Update SKILL.md cross-references for renumbered pitfalls
- Update setup.sh path for new directory location
2026-04-18 17:43:42 -07:00
kshitijk4poor 6f27390fae feat: rewrite TouchDesigner skill for twozero MCP (v2.0.0)
Major rewrite of the TouchDesigner skill:
- Replace custom API handler with twozero MCP (36 native tools)
- Add audio-reactive GLSL proven recipe (spectrum chain, pitfalls)
- Add recording checklist (FPS>0, non-black, audio cueing)
- Expand pitfalls: 38 entries from real sessions (was 20)
- Update network-patterns with MCP-native build scripts
- Rewrite mcp-tools reference for twozero v2.774+
- Update troubleshooting for MCP-based workflow
- Remove obsolete custom_api_handler.py
- Generalize Environment section for all users
- Remove session-specific Paired Skills section
- Bump version to 2.0.0
2026-04-18 17:43:42 -07:00
kshitijk4poor 7a5371b20d feat: add TouchDesigner integration skill
New skill: creative/touchdesigner — control a running TouchDesigner
instance via REST API. Build real-time visual networks programmatically.

Architecture:
  Hermes Agent -> HTTP REST (curl) -> TD WebServer DAT -> TD Python env

Key features:
- Custom API handler (scripts/custom_api_handler.py) that creates a
  self-contained WebServer DAT + callback in TD. More reliable than the
  official mcp_webserver_base.tox which frequently fails module imports.
- Discovery-first workflow: never hardcode TD parameter names. Always
  probe the running instance first since names change across versions.
- Persistent setup: save the TD project once with the API handler baked
  in. TD auto-opens the last project on launch, so port 9981 is live
  with zero manual steps after first-time setup.
- Works via curl in execute_code (no MCP dependency required).
- Optional MCP server config for touchdesigner-mcp-server npm package.

Skill structure (2823 lines total):
  SKILL.md (209 lines) — setup, workflow, key rules, operator reference
  references/pitfalls.md (276 lines) — 24 hard-won lessons
  references/operators.md (239 lines) — all 6 operator families
  references/network-patterns.md (589 lines) — audio-reactive, generative,
    video processing, GLSL, instancing, live performance recipes
  references/mcp-tools.md (501 lines) — 13 MCP tool schemas
  references/python-api.md (443 lines) — TD Python scripting patterns
  references/troubleshooting.md (274 lines) — connection diagnostics
  scripts/custom_api_handler.py (140 lines) — REST API handler for TD
  scripts/setup.sh (152 lines) — prerequisite checker

Tested on TouchDesigner 099 Non-Commercial (macOS/darwin).
2026-04-18 17:43:42 -07:00
Teknium c49a58a6d0 fix(gateway): mark only still-running sessions resume_pending on drain timeout (#12332)
Follow-up to #12301.

The drain-timeout branch of _stop_impl() was iterating the drain-start
snapshot (active_agents) when marking sessions resume_pending. That
snapshot can include sessions that finished gracefully during the drain
window — marking them would give their next turn a stray
'your previous turn was interrupted by a gateway restart' system note
even though the prior turn actually completed cleanly.

Iterate self._running_agents at timeout time instead, mirroring
_interrupt_running_agents() exactly:
- only sessions still blocking the shutdown get marked
- pending sentinels (AIAgent construction not yet complete) are skipped

Changes:
- gateway/run.py: swap active_agents.keys() for filtered
  self._running_agents.items() iteration in the drain-timeout mark loop.
- tests/gateway/test_restart_resume_pending.py: two regression tests —
  finisher-during-drain not marked, pending sentinel not marked.
2026-04-18 17:40:34 -07:00
Teknium cb4addacab fix(gateway): auto-resume sessions after drain-timeout restart (#11852) (#12301)
The shutdown banner promised "send any message after restart to resume
where you left off" but the code did the opposite: a drain-timeout
restart skipped the .clean_shutdown marker, which made the next startup
call suspend_recently_active(), which marked the session suspended,
which made get_or_create_session() spawn a fresh session_id with a
'Session automatically reset. Use /resume...' notice — contradicting
the banner.

Introduce a resume_pending state on SessionEntry that is distinct from
suspended. Drain-timeout shutdown flags active sessions resume_pending
instead of letting startup-wide suspension destroy them. The next
message on the same session_key preserves the session_id, reloads the
transcript, and the agent receives a reason-aware restart-resume
system note that subsumes the existing tool-tail auto-continue note
(PR #9934).

Terminal escalation still flows through the existing
.restart_failure_counts stuck-loop counter (PR #7536, threshold 3) —
no parallel counter on SessionEntry. suspended still wins over
resume_pending in get_or_create_session() so genuinely stuck sessions
converge to a clean slate.

Spec: PR #11852 (BrennerSpear). Implementation follows the spec with
the approved correction (reuse .restart_failure_counts rather than
adding a resume_attempts field).

Changes:
- gateway/session.py: SessionEntry.resume_pending/resume_reason/
  last_resume_marked_at + to_dict/from_dict; SessionStore
  .mark_resume_pending()/clear_resume_pending(); get_or_create_session()
  returns existing entry when resume_pending (suspended still wins);
  suspend_recently_active() skips resume_pending entries.
- gateway/run.py: _stop_impl() drain-timeout branch marks active
  sessions resume_pending before _interrupt_running_agents();
  _run_agent() injects reason-aware restart-resume system note that
  subsumes the tool-tail case; successful-turn cleanup also clears
  resume_pending next to _clear_restart_failure_count();
  _notify_active_sessions_of_shutdown() softens the restart banner to
  'I'll try to resume where you left off' (honest about stuck-loop
  escalation).
- tests/gateway/test_restart_resume_pending.py: 29 new tests covering
  SessionEntry roundtrip, mark/clear helpers, get_or_create_session
  precedence (suspended > resume_pending), suspend_recently_active
  skip, drain-timeout mark reason (restart vs shutdown), system-note
  injection decision tree (including tool-tail subsumption), banner
  wording, and stuck-loop escalation override.
2026-04-18 17:32:17 -07:00
brooklyn! ad99e32371 Merge pull request #12312 from NousResearch/bb/tui-ux-pack
feat(tui): UX pack — stable picker keys, /clear confirm, light-theme preset
2026-04-18 18:13:06 -05:00
Brooklyn Nicholson df5ca5065f feat(tui): replace /clear double-press gate with a proper confirm overlay
The time-window gate felt wrong — users would hit /clear, read the
prompt, retype, and consistently blow past the window. Swapping to a
real yes/no overlay that blocks input like the existing Approval and
Clarify prompts.

- add ConfirmReq type + OverlayState.confirm + $isBlocked coverage
- ConfirmPrompt component (prompts.tsx): cancel row on top as the
  default, danger-coloured confirm row on the bottom, Y/N hotkeys,
  Enter on default = cancel, Esc/Ctrl+C cancel
- wire into PromptZone (appOverlays.tsx)
- /clear + /new now push onto the overlay instead of arming a timer
- HERMES_TUI_NO_CONFIRM=1 still skips the prompt for scripting
- drop the destructiveGate + createSlashHandler reset wiring
  (destructive.ts and its tests removed)

Refs #4069.
2026-04-18 18:04:08 -05:00
Brooklyn Nicholson 75377feb07 fix(tui): make /clear confirm window humane (3s → 30s, reset on other slash)
The 3s gate was too tight — users reading the prompt and retyping
consistently blow past it and get stuck in a loop ("press /clear
again within 3s" forever). Fixes:

- bump CONFIRM_WINDOW_MS 3_000 → 30_000
- drop the time number from the confirmation message to remove the
  pressure vibe: "press /clear again to confirm — starts a new session"
- reset the gate from createSlashHandler whenever any non-destructive
  slash command runs, so stale arming from 20s ago can't silently
  turn the next /clear into an unintended confirm
- export the gate + isDestructiveCommand helper for that wiring
- add armed() introspection method

Follow-up to #4069 / 3366714b.
2026-04-18 17:55:53 -05:00
Brooklyn Nicholson 20eab355e7 feat(tui): add LIGHT_THEME preset for white/light terminal backgrounds
Splits the existing palette into DARK_THEME (current yellow-heavy
default) and LIGHT_THEME (darker browns + proper contrast on white).
DEFAULT_THEME aliases DARK_THEME, and flips to LIGHT_THEME when
HERMES_TUI_LIGHT=1 is set at launch.

Skin system (fromSkin) still layers on top of whichever preset is
active, so users can keep customizing on top of either palette.

Refs #11300.
2026-04-18 17:49:40 -05:00
Brooklyn Nicholson 3366714ba4 feat(tui): double-press confirm on /clear and /new
Prevents accidental session loss: the first press prints
"press /clear again within 3s to confirm"; a second press inside
the window actually starts a new session. Outside the window the
gate re-arms.

Opt out with HERMES_TUI_NO_CONFIRM=1 for scripted / muscle-memory
workflows.

Refs #4069.
2026-04-18 17:48:34 -05:00
Brooklyn Nicholson 52124384de fix(tui): stable React keys in /model picker rows
Use provider.slug (and a composite key for model rows) instead of the
rendered string, so dupes in the backend response can't collapse two
rows into one or trigger key-collision warnings.
2026-04-18 17:47:26 -05:00
brooklyn! db59c190c1 Merge pull request #12305 from NousResearch/bb/tui-status-git-branch
feat(tui): append git branch to cwd label in status bar
2026-04-18 17:27:40 -05:00
brooklyn! c0edcf2d53 Merge pull request #12306 from NousResearch/bb/tui-model-picker-dedupe-names
fix(tui): disambiguate /model picker rows when provider display names collide
2026-04-18 17:27:31 -05:00
Brooklyn Nicholson 4aa52590d8 fix(tui): disambiguate /model picker rows when provider display names collide
If the gateway returns two providers that resolve to the same display name
(e.g. `kimi-coding` and `kimi-coding-cn` both → "Kimi For Coding"), the
picker now appends the slug so users can tell them apart, in both the
provider list and the selected-provider header. No-op when names are
already unique.

Refs #10526 — the Python backend dedupe from #10599 skips one alias, but
user-defined providers, canonical overlays, and future regressions can
still surface as indistinguishable rows in the picker. This is a
client-side safety net on top of that.
2026-04-18 17:22:23 -05:00
Brooklyn Nicholson ff2aa7ccd7 feat(tui): append git branch to cwd label in status bar
Adds useGitBranch hook (async, cached, 15s TTL) and fmtCwdBranch
helper so the footer shows `~/repo (main)` instead of just `~/repo`.
Degrades silently when git is unavailable or cwd is outside a repo.

Partial fix for #12267 (TUI portion; #12277 covers the Python side).
2026-04-18 17:17:05 -05:00
Teknium 0175ff7516 feat(skills): replace xitter with xurl — the official X API CLI (#12303)
Swap the social-media/xitter skill (third-party wrapper around
Infatoshi/x-cli) for a new social-media/xurl skill wrapping
xdevplatform/xurl — the official X API CLI from the X developer
platform team.

Why:
- xurl is officially maintained by the X dev platform team
- OAuth 2.0 PKCE with auto-refresh + multi-app / multi-user support
  (vs. xitter's 5-env-var OAuth 1.0a + single account)
- Credentials stored in ~/.xurl managed by xurl itself — no manual
  env var juggling for users
- Substantially larger API surface: DMs, follows, blocks, mutes,
  media upload, streaming, and raw v2 endpoint access
- Ships stronger agent-safety guardrails (forbidden-flag list,
  no --verbose in agent mode, never-read-~/.xurl rule)

Adaptation:
- Ported the openclaw SKILL.md (which the xdevplatform team seeded)
  to Hermes frontmatter conventions (prerequisites.commands, platforms,
  metadata.hermes.tags/homepage) — dropped openclaw-specific metadata
- Added a Hermes-oriented one-time user setup section so the agent
  knows to direct the user to run auth commands themselves, never
  execute them with inline secrets
- Preserved the mandatory secret-safety rules verbatim
- Attribution block credits xdevplatform, openclaw, and the Hermes
  port

Docs: updated website/docs/reference/skills-catalog.md to replace
the xitter row with xurl.
2026-04-18 15:11:32 -07:00
Teknium 6a3a6a0fb6 Merge pull request #12263 from NousResearch/bb/tui-audit-followup
fix(tui): TUI v2 audit follow-up — registry, overlays, paste, reasoning, hyperlinks
2026-04-18 14:40:16 -07:00
helix4u 4e8f60fd11 fix(cli): use display width for wrapped spinner height 2026-04-18 14:34:05 -07:00
Brooklyn Nicholson fb06bc67de fix(tui): Ctrl+C with input selection actually preserves input (lift handler to app level)
Previous fix in 9dbf1ec6 handled Ctrl+C inside textInput but the APP-level
useInputHandlers fires the same keypress in a separate React hook and ran
clearIn() regardless. Net effect: the OSC 52 copy succeeded but the input
wiped right after, so Brooklyn only noticed the wipe.

Lift the selection-aware Ctrl+C to a single place by threading input
selection state through a new nanostore (src/app/inputSelectionStore.ts).
textInput syncs its derived `selected` range + a clear() callback to the
store on every selection change, and the app-level Ctrl+C handler reads
the store before its clear/interrupt/die chain:

  - terminal-level selection (scrollback) → copy, existing behavior
  - in-input selection present → copy + clear selection, preserve input
  - input has text, no selection → clearIn(), existing behavior
  - empty + busy → interrupt turn
  - empty + idle → die

textInput no longer has its own Ctrl+C block; keypress falls through to
app-level like it did before 9dbf1ec6.
2026-04-18 16:28:51 -05:00
Brooklyn Nicholson bfac5d039d Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/tui-audit-followup 2026-04-18 15:27:40 -05:00
Brooklyn Nicholson 17e95a26b7 fix(tui): render /skills browse as a formatted Panel instead of raw JSON
Previous handler dumped the raw skills.manage response into a pager, which
was unreadable and hid the pagination metadata. Also silently accepted
non-numeric page args.

Now:
- validates page arg (rejects NaN / <1 with a usage message)
- shows "fetching community skills (scans 6 sources, may take ~15s)…" up
  front so the 10-30s hub fetch isn't a silent hang
- renders items as {name · trust, description (truncated 160 chars)} rows
  in the existing Panel component
- footer shows "page X of Y · N skills total · /skills browse N+1 for more"
  when the server returned pagination metadata

Skills hub's remote fetch latency is a separate upstream issue
(browse_skills hits 6 sources sequentially) — client-side we just stop
misrepresenting it.
2026-04-18 15:22:43 -05:00
Brooklyn Nicholson 7e9a098574 chore: uptick 2026-04-18 15:17:42 -05:00
Brooklyn Nicholson 450ded98db chore(tui): prettier whitespace on files touched in this branch 2026-04-18 15:13:31 -05:00
Brooklyn Nicholson 93b4080b78 Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/tui-audit-followup
# Conflicts:
#	ui-tui/src/components/markdown.tsx
#	ui-tui/src/types/hermes-ink.d.ts
2026-04-18 14:52:54 -05:00
helix4u ca32a2a60b fix(gemini): restore bearer auth on openai route 2026-04-18 12:52:01 -07:00
helix4u a7dd6a3449 fix(gemini): hide stale and low-TPM Google models 2026-04-18 12:52:01 -07:00
helix4u 2eab7ee15f fix(gemini): hide low-TPM Gemma models from exposed lists 2026-04-18 12:52:01 -07:00
LVT382009 f7af90e2da fix: wire _ephemeral_max_output_tokens into chat_completions and add NVIDIA NIM default
Based on #12152 by @LVT382009.

Two fixes to run_agent.py:

1. _ephemeral_max_output_tokens consumption in chat_completions path:
   The error-recovery ephemeral override was only consumed in the
   anthropic_messages branch of _build_api_kwargs.  All chat_completions
   providers (OpenRouter, NVIDIA NIM, Qwen, Alibaba, custom, etc.)
   silently ignored it.  Now consumed at highest priority, matching the
   anthropic pattern.

2. NVIDIA NIM max_tokens default (16384):
   NVIDIA NIM falls back to a very low internal default when max_tokens
   is omitted, causing models like GLM-4.7 to truncate immediately
   (thinking tokens exhaust the budget before the response starts).

3. Progressive length-continuation boost:
   When finish_reason='length' triggers a continuation retry, the output
   budget now grows progressively (2x base on retry 1, 3x on retry 2,
   capped at 32768) via _ephemeral_max_output_tokens.  Previously the
   retry loop just re-sent the same token limit on all 3 attempts.
2026-04-18 12:51:30 -07:00
jarvischer 0f778f7768 fix: prevent tool name duplication in streaming accumulator (MiniMax/NVIDIA NIM)
Based on #11984 by @maxchernin.  Fixes #8259.

Some providers (MiniMax M2.7 via NVIDIA NIM) resend the full function
name in every streaming chunk instead of only the first.  The old
accumulator used += which concatenated them into 'read_fileread_file'.

Changed to simple assignment (=), matching the OpenAI Node SDK, LiteLLM,
and Vercel AI SDK patterns.  Function names are atomic identifiers
delivered complete — no provider splits them across chunks, so
concatenation was never correct semantics.
2026-04-18 12:50:32 -07:00
Brooklyn Nicholson 4caf6c23dd fix(tui): strip <think>…</think> tags from assistant content and route to reasoning panel
Models that emit reasoning inline as <think>/<reasoning>/<thinking>/<thought>/
<REASONING_SCRATCHPAD> tags in the content field (rather than a separate API
reasoning channel) had the raw tags + inner content shown twice: once as body
text with literal <think> markers, and again in the thinking panel when the
reasoning field was populated.

Port v1's tag set to lib/reasoning.ts with a splitReasoning(text) helper that
returns { reasoning, text }. Applied in three spots:

  - scheduleStreaming: strips tags from the live streaming view so the user
    never sees <think> mid-turn.
  - flushStreamingSegment: when a tool interrupts assistant output mid-turn,
    the saved segment is the stripped text; extracted reasoning promotes to
    reasoningText if the API channel hasn't already populated it.
  - recordMessageComplete: final message text is split, extracted reasoning
    merges with any existing reasoning (API channel wins on conflicts so we
    don't double-count when both are present).
2026-04-18 14:46:38 -05:00
Brooklyn Nicholson 37cba82bfc fix(tui): Ctrl+C on in-input selection copies to clipboard instead of clearing
Before: textInput explicitly ignored Ctrl+C so the app-level handler took
over — with no knowledge of the TextInput's own selection — and fell through
to clearIn() whenever input had text. Selecting part of the composer and
pressing Ctrl+C silently nuked everything you typed.

Now: Ctrl+C with an active in-input selection writes the selected substring
to the clipboard via OSC 52 and clears the selection. The original semantics
(Ctrl+C with no selection → app-level interrupt/clear/die chain) are
preserved by still returning early in that case.
2026-04-18 14:42:03 -05:00
Teknium 0bebf5b948 chore(attribution): add AUTHOR_MAP entry for Honghua Yang (honghua) 2026-04-18 12:40:56 -07:00
Honghua Yang 3128d9fcd2 fix(context_compressor): keep tool-call arguments JSON valid when shrinking
Pass 3 of `_prune_old_tool_results` previously shrunk long `function.arguments`
blobs by slicing the raw JSON string at byte 200 and appending the literal
text `...[truncated]`. That routinely produced payloads like::

    {"path": "/foo.md", "content": "# Long markdown
    ...[truncated]

— an unterminated string with no closing brace. Strict providers (observed
on MiniMax) reject this as `invalid function arguments json string` with a
non-retryable 400. Because the broken call survives in the session history,
every subsequent turn re-sends the same malformed payload and gets the same
400, locking the session into a re-send loop until the call falls out of
the window.

Fix: parse the arguments first, shrink long string leaves inside the parsed
structure, and re-serialise. Non-string values (paths, ints, booleans, lists)
pass through intact. Arguments that are not valid JSON to begin with (rare,
some backends use non-JSON tool args) are returned unchanged rather than
replaced with something neither we nor the provider can parse.

Observed in the wild: a `write_file` with ~800 chars of markdown `content`
triggered this on a real session against MiniMax-M2.7; every turn after
compression got rejected until the session was manually reset.

Tests:
- 7 direct tests of `_truncate_tool_call_args_json` covering valid-JSON
  output, non-JSON pass-through, nested structures, non-string leaves,
  scalar JSON, and Unicode preservation
- 1 end-to-end test through `_prune_old_tool_results` Pass 3 that
  reproduces the exact failure payload shape from the incident

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:40:56 -07:00
Brooklyn Nicholson 5c8b291607 fix(tui): wrap markdown links in Link so Ghostty/iTerm/kitty get real OSC 8 hyperlinks
renderLink was discarding the URL entirely — it rendered the label as amber
underlined text and dropped the href. Result: Cmd+Click / Ctrl+Click did
nothing in any terminal, including Ghostty.

Now both markdown links `[label](url)` and bare `https://…` URLs are wrapped
in @hermes/ink's Link component, which emits OSC 8 (\\x1b]8;;url\\x07label\\x1b]8;;\\x07)
when supportsHyperlinks() returns true. ADDITIONAL_HYPERLINK_TERMINALS already
includes ghostty, iTerm2, kitty, alacritty, Hyper.

Autolinks that look like bare emails (foo@bar.com) now prepend mailto: in the
href so they open the mail client correctly.

Also adds a typed declaration for Link in hermes-ink.d.ts.
2026-04-18 14:39:24 -05:00
Brooklyn Nicholson a7f4d756b7 fix(tui): cap approval prompt command preview at 10 lines
Large inline scripts (e.g. Python code_execution bodies) rendered as a single
unbounded <Text> block, pushing the Allow/Deny options below the visible
viewport. Users had to scroll the terminal to vote.

Preview now shows the first 10 lines with truncate-end wrap per line and a
dim "… +N more lines" indicator. Full text remains in the transcript above.
2026-04-18 14:36:34 -05:00
Teknium b73ebfee30 chore(attribution): add AUTHOR_MAP entry for Jim Liu (JimLiu)
Maps junminliu@gmail.com → JimLiu for the baoyu-infographic skill port
co-author attribution.
2026-04-18 12:32:16 -07:00
Teknium ade7958f1f docs: add PORT_NOTES.md for baoyu-infographic
Documents what changed from upstream and how to sync future updates.
2026-04-18 12:32:16 -07:00
Teknium 65c0a30a77 feat(skills): add baoyu-infographic skill — 21 layouts × 21 styles
Port of baoyu-infographic from JimLiu/baoyu-skills (v1.56.1) adapted
for Hermes Agent's tool ecosystem.

Adaptations from upstream:
- Frontmatter: openclaw metadata → hermes metadata
- Usage: slash command syntax → natural language triggers
- Removed EXTEND.md config system (not part of Hermes infrastructure)
- AskUserQuestion → clarify tool (one question at a time)
- Image generation → image_generate tool
- Removed Windows-specific paths
- Simplified file operations to use Hermes file tools
- All 45 reference files (layouts, styles, templates) preserved intact

Attribution preserved per agreement with 宝玉 (Jim Liu):
- author, version, GitHub homepage URL in frontmatter

Co-authored-by: Jim Liu 宝玉 <junminliu@gmail.com>
2026-04-18 12:32:16 -07:00
Siddharth Balyan a828daa7f8 perf(docker): layer-cache npm/Playwright and skip redundant web rebuild (#12225)
* perf(docker): layer-cache npm/Playwright and skip redundant web rebuild

Copy package manifests before source so npm install + Playwright only
re-run when lockfiles change. Use COPY --chown instead of chown -R,
set HERMES_WEB_DIST to skip runtime web rebuild, and drop the
USER root / chmod dance since entrypoint.sh is already executable in git.

* Update Dockerfile
2026-04-18 22:44:31 +05:30
bluefishs b0bde98b0f fix(docker): build web/ dashboard assets in image (#12180)
The Dockerfile installs root-level npm dependencies (for Playwright) and the
whatsapp-bridge bundle, but never builds the web/ Vite project. As a result,
'hermes dashboard' starts FastAPI on :9119 but serves a broken SPA because
hermes_cli/web_dist/ is empty and requests to /assets/index-<hash>.js 404.

Add a build step inside web/ so the Vite output is baked into the image.

Reproduce (before):
  docker build -t hermes-repro -f Dockerfile .
  docker run --rm -p 9119:9119 hermes-repro hermes dashboard
  curl -sI http://localhost:9119/assets/ | head -1   # -> 404

After: /assets/ returns the built asset path.
2026-04-18 22:20:24 +05:30
kshitij c14b3b5880 fix(kimi): force fixed temperature on kimi-k2.* models (k2.5, thinking, turbo) (#12144)
* fix(kimi): force fixed temperature on kimi-k2.* models (k2.5, thinking, turbo)

The prior override only matched the literal model name "kimi-for-coding",
but Moonshot's coding endpoint is hit with real model IDs such as
`kimi-k2.5`, `kimi-k2-turbo-preview`, `kimi-k2-thinking`, etc.  Those
requests bypassed the override and kept the caller's temperature, so
Moonshot returns HTTP 400 "invalid temperature: only 0.6 is allowed for
this model" (or 1.0 for thinking variants).

Match the whole kimi-k2.* family:
  * kimi-k2-thinking / kimi-k2-thinking-turbo -> 1.0 (thinking mode)
  * all other kimi-k2.* -> 0.6 (non-thinking / instant mode)

Also accept an optional vendor prefix (e.g. `moonshotai/kimi-k2.5`) so
aggregator routings are covered.

* refactor(kimi): whitelist-match kimi coding models instead of prefix

Addresses review feedback on PR #12144.

- Replace `startswith("kimi-k2")` with explicit frozensets sourced from
  Moonshot's kimi-for-coding model list.  The prefix match would have also
  clamped `kimi-k2-instruct` / `kimi-k2-instruct-0905`, which are the
  separate non-coding K2 family with variable temperature (recommended 0.6
  but not enforced — see huggingface.co/moonshotai/Kimi-K2-Instruct).
- Confirmed via platform.kimi.ai docs that all five coding models
  (k2.5, k2-turbo-preview, k2-0905-preview, k2-thinking, k2-thinking-turbo)
  share the fixed-temperature lock, so the preview-model mapping is no
  longer an assumption.
- Drop the fragile `"thinking" in bare` substring test for a set lookup.
- Log a debug line on each override so operators can see when Hermes
  silently rewrites temperature.
- Update class docstring.  Extend the negative test to parametrize over
  kimi-k2-instruct, Kimi-K2-Instruct-0905, and a hypothetical future
  kimi-k2-experimental name — all must keep the caller's temperature.
2026-04-18 09:35:51 -07:00
kshitijk4poor 656c375855 fix(tui): review follow-up — /retry, /plan, ANSI truncation, caching
- /retry: use session['history'] instead of non-existent
  agent.conversation_history; truncate history at last user message
  to match CLI retry_last() behavior; add history_lock safety
- /plan: pass user instruction (arg) to build_plan_path instead of
  session_key; add runtime_note so agent knows where to save the plan
- ANSI tool results: render full text via <Ansi wrap=truncate-end>
  instead of slicing raw ANSI through compactPreview (which cuts
  mid-escape-sequence producing garbled output)
- Move _PENDING_INPUT_COMMANDS frozenset to module level
- Use get_skill_commands() (cached) instead of scan_skill_commands()
  (rescans disk) in slash.exec skill interception
- Add 3 retry tests: happy path with history truncation verification,
  empty history error, multipart content extraction
- Update test mock target from scan_skill_commands to get_skill_commands
2026-04-18 09:30:48 -07:00
kshitijk4poor abc95338c2 fix(tui): slash.exec _pending_input commands, tool ANSI, terminal title
Additional TUI fixes discovered in the same audit:

1. /plan slash command was silently lost — process_command() queues the
   plan skill invocation onto _pending_input which nobody reads in the
   slash worker subprocess.  Now intercepted in slash.exec and routed
   through command.dispatch with a new 'send' dispatch type.

   Same interception added for /retry, /queue, /steer as safety nets
   (these already have correct TUI-local handlers in core.ts, but the
   server-side guard prevents regressions if the local handler is
   bypassed).

2. Tool results were stripping ANSI escape codes — the messageLine
   component used stripAnsi() + plain <Text> for tool role messages,
   losing all color/styling from terminal, search_files, etc.  Now
   uses <Ansi> component (already imported) when ANSI is detected.

3. Terminal tab title now shows model + busy status via useTerminalTitle
   hook from @hermes/ink (was never used).  Users can identify Hermes
   tabs and see at a glance whether the agent is busy or ready.

4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch
   parser + createSlashHandler handler for commands that need to inject
   a message into the conversation (plan, queue fallback, steer fallback).
2026-04-18 09:30:48 -07:00
kshitijk4poor 2da558ec36 fix(tui): clickable hyperlinks and skill slash command dispatch
Two TUI fixes:

1. Hyperlinks are now clickable (Cmd+Click / Ctrl+Click) in terminals
   that support OSC 8.  The markdown renderer was rendering links as
   plain colored text — now wraps them in the existing <Link> component
   from @hermes/ink which emits OSC 8 escape sequences.

2. Skill slash commands (e.g. /hermes-agent-dev) now work in the TUI.
   The slash.exec handler was delegating to the _SlashWorker subprocess
   which calls cli.process_command().  For skills, process_command()
   queues the invocation message onto _pending_input — a Queue that
   nobody reads in the worker subprocess.  The skill message was lost.
   Now slash.exec detects skill commands early and rejects them so
   the TUI falls through to command.dispatch, which correctly builds
   and returns the skill payload for the client to send().
2026-04-18 09:30:48 -07:00
Siddharth Balyan b0efdf37d7 fix(nix): upgrade Python 3.11 → 3.12, add cross-platform eval check (#12208) 2026-04-18 21:51:03 +05:30
Siddharth Balyan 8a0c774e9e Add web dashboard build to Nix flake (#12194)
The web dashboard (Vite/React frontend) is now built as a separate Nix
derivation and baked into the Hermes package. The build output is
installed to a standard location and exposed via the `HERMES_WEB_DIST`
environment variable, allowing the dashboard command to use pre-built
assets when available (e.g., in packaged releases) instead of rebuilding
on every invocation.
2026-04-18 20:55:39 +05:30
Brooklyn Nicholson f8becbfbea feat(tui): per-language syntax highlighting in markdown code fences
Adds a minimal hand-rolled highlighter for ts/js/jsx/tsx, py, sh/bash, go, rust,
json, yaml, sql. Recognizes whole-line comments, single/double/backtick strings,
numbers, and per-language keyword sets. Unknown langs fall through to the current
plain rendering; the existing diff-specific colorization is preserved.

Closes the §8 "Markdown syntax highlighting is missing (only diff gets colored)"
finding from the TUI v2 audit without pulling in a highlighter library.
2026-04-18 09:48:38 -05:00
Brooklyn Nicholson 5e148ca3d0 fix(tui): route /skills subcommands through skills.manage instead of curses slash.exec
/skills install, inspect, search, browse, list now call the typed skills.manage RPC
and render results via panel/page. Previously they fell through to slash.exec which
invokes v1's curses code path — that hangs or crashes inside the Ink worker per the
§2 parity-audit finding.

Also drop Enter-as-install from the Skills Hub action stage since the Hub lists
locally installed skills; primary action is inspect-and-close. x still triggers a
manual reinstall for power users.
2026-04-18 09:46:36 -05:00
Brooklyn Nicholson 949b8f5521 feat(tui): register /skills slash command to open Skills Hub
Intercept bare /skills locally and flip overlay.skillsHub, so the
overlay opens instantly without waiting on slash.exec. /skills <args>
still forwards to slash.exec and paginates any output. Tests cover
both branches.
2026-04-18 09:42:57 -05:00
Brooklyn Nicholson ef284e021a feat(tui): add two-step SkillsHub overlay component
New SkillsHub mirrors ModelPicker's category → item → actions flow with
paginated 12-line lists, 1-9/0 quick-pick, Esc-back navigation, and
lazy skills.manage inspect/install calls. Mount it from appOverlays
when overlay.skillsHub is true.
2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 6fbfae8f42 feat(tui): add skillsHub overlay state wiring
Extend OverlayState with a skillsHub flag, fold it into $isBlocked, and
teach Ctrl+C to close the overlay so later PRs can render the component
behind this slot.
2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 3821323029 feat(tui): render per-MCP-server status block in SessionPanel 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson b82ec6419d test(tui-gateway): cover mcp_servers field in _session_info output 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 202b78ec68 feat(tui-gateway): include per-MCP-server status in session.info payload 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson fd6ffc777f feat(tui): honor display.* flags in turn renderer, status bar, and event handler
- turnController gates scheduleStreaming / reasoning recorders on
  streaming + showReasoning so disabling them keeps the buffer silent
  until message.complete flushes
- createGatewayEventHandler only surfaces inline_diff previews when
  inlineDiffs is on
- StatusRule takes a showCost prop and renders `· $X.XXXX` with the
  same toFixed(4) formatting as /usage when usage.cost_usd is present
- Usage grows cost_usd?: number to match the gateway payload
- Existing handler tests flip showReasoning on in beforeEach so
  reasoning-flow assertions keep their meaning
2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 200c17433c feat(tui): read display.streaming / show_reasoning / show_cost / inline_diffs from config
Extends ConfigDisplayConfig and UiState so the four new display flags
flow from `config.get {key:"full"}` into the nanostore. applyDisplay is
exported to keep the fan-out testable without an Ink harness.

Defaults mirror v1 parity: streaming + inline_diffs default true
(opt-out via `=== false`), show_cost + show_reasoning default false
(opt-in via plain truthy check).
2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 586b2f2089 feat(tui): persist large pastes to ~/.hermes/pastes/ via paste.collapse 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson a397b0fd4d test(tui-gateway): assert quick_commands appear in commands.catalog output 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 5152e1ad86 feat(tui-gateway): surface config.quick_commands in commands.catalog 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson 4e1ea79edc feat(tui): accept raw Ctrl+V as clipboard image paste fallback 2026-04-18 09:42:57 -05:00
Brooklyn Nicholson f0638f3596 fix(tui): split /model picker from /provider wizard to resolve registry collision 2026-04-18 09:42:57 -05:00
Siddharth Balyan 6fb69229ca fix(nix): fix build failures, TUI Node.js crash, and upgrade container to Node 22 (#12159)
* Add setuptools build dep for legacy alibabacloud packages and updated
stale npm-deps hash

* Add HERMES_NODE env var to pin Node.js version

The TUI requires Node.js 20+ for regex `/v` flag support (used by
string-width). Instead of relying on PATH lookup, explicitly set
HERMES_NODE to the bundled Node 22 in the Nix wrapper, and add a
fallback check in the Python code to use HERMES_NODE if available.

Also upgrade container provisioning to Node 22 via NodeSource (Ubuntu
24.04 ships Node 18 which is EOL) and add a Nix check to verify the
wrapper and Node version at build time.
2026-04-18 19:21:28 +05:30
Teknium 2edebedc9e feat(steer): /steer <prompt> injects a mid-run note after the next tool call (#12116)
* feat(steer): /steer <prompt> injects a mid-run note after the next tool call

Adds a new slash command that sits between /queue (turn boundary) and
interrupt. /steer <text> stashes the message on the running agent and
the agent loop appends it to the LAST tool result's content once the
current tool batch finishes. The model sees it as part of the tool
output on its next iteration.

No interrupt is fired, no new user turn is inserted, and no prompt
cache invalidation happens beyond the normal per-turn tool-result
churn. Message-role alternation is preserved — we only modify an
existing role:"tool" message's content.

Wiring
------
- hermes_cli/commands.py: register /steer + add to ACTIVE_SESSION_BYPASS_COMMANDS.
- run_agent.py: add _pending_steer state, AIAgent.steer(), _drain_pending_steer(),
  _apply_pending_steer_to_tool_results(); drain at end of both parallel and
  sequential tool executors; clear on interrupt; return leftover as
  result['pending_steer'] if the agent exits before another tool batch.
- cli.py: /steer handler — route to agent.steer() when running, fall back to
  the regular queue otherwise; deliver result['pending_steer'] as next turn.
- gateway/run.py: running-agent intercept calls running_agent.steer(); idle-agent
  path strips the prefix and forwards as a regular user message.
- tui_gateway/server.py: new session.steer JSON-RPC method.
- ui-tui: SessionSteerResponse type + local /steer slash command that calls
  session.steer when ui.busy, otherwise enqueues for the next turn.

Fallbacks
---------
- Agent exits mid-steer → surfaces in run_conversation result as pending_steer
  so CLI/gateway deliver it as the next user turn instead of silently dropping it.
- All tools skipped after interrupt → re-stashes pending_steer for the caller.
- No active agent → /steer reduces to sending the text as a normal message.

Tests
-----
- tests/run_agent/test_steer.py — accept/reject, concatenation, drain,
  last-tool-result injection, multimodal list content, thread safety,
  cleared-on-interrupt, registry membership, bypass-set membership.
- tests/gateway/test_steer_command.py — running agent, pending sentinel,
  missing steer() method, rejected payload, empty payload.
- tests/gateway/test_command_bypass_active_session.py — /steer bypasses
  the Level-1 base adapter guard.
- tests/test_tui_gateway_server.py — session.steer RPC paths.

72/72 targeted tests pass under scripts/run_tests.sh.

* feat(steer): register /steer in Discord's native slash tree

Discord's app_commands tree is a curated subset of slash commands (not
derived from COMMAND_REGISTRY like Telegram/Slack). /steer already
works there as plain text (routes through handle_message → base
adapter bypass → runner), but registering it here adds Discord's
native autocomplete + argument hint UI so users can discover and
type it like any other first-class command.
2026-04-18 04:17:18 -07:00
Teknium f9667331e5 docs(browser): improve /browser connect setup guidance (#12123)
- Note that /browser connect is CLI-only and won't work in gateways (WebUI, Telegram, Discord).
- Update the Chrome launch command to use a dedicated --user-data-dir, so port 9222 actually comes up even when Chrome is already running with the user's regular profile.
- Add --no-first-run --no-default-browser-check to skip the fresh-profile wizard.
- Explain why the dedicated user-data-dir matters.

Community tip via Karamjit Singh.

Co-authored-by: teknium1 <teknium@noreply.github.com>
2026-04-18 04:14:05 -07:00
Teknium 9527707f80 fix(signal): back off sendTyping spam for unreachable recipients (#12118)
base.py's _keep_typing refresh loop calls send_typing every ~2s while
the agent is processing. If signal-cli returns NETWORK_FAILURE for the
recipient (offline, unroutable, group membership lost), the unmitigated
path was a WARNING log every 2 seconds for as long as the agent stayed
busy — a user report showed 1048 warnings in 41 minutes for one
offline contact, plus the matching volume of pointless RPC traffic to
signal-cli.

- _rpc() accepts log_failures=False so callers can route repeated
  expected failures (typing) to DEBUG while keeping send/receive at
  WARNING.
- send_typing() tracks consecutive failures per chat. First failure
  still logs WARNING so transport issues remain visible; subsequent
  failures log at DEBUG. After three consecutive failures we skip the
  RPC during an exponential cooldown (16s, 32s, 60s cap) so we stop
  hammering signal-cli for a recipient it can't deliver to. A
  successful sendTyping resets the counters.
- _stop_typing_indicator() clears the backoff state so the next agent
  turn starts fresh.

E2E simulation against the reported 41-minute window: RPCs drop from
1230 to 45 (-96%), log lines from 1048 WARNINGs to 1 WARNING + 44
DEBUGs.

Credits kshitijk4poor (#12056) for the _rpc log_failures kwarg idea;
the broader restructure in that PR (nested per-chat loop inside
send_typing) is avoided here in favour of stateful backoff that
preserves base.py's existing _keep_typing architecture.
2026-04-18 04:13:32 -07:00
Teknium cf012a05d8 docs(terminal): warn against stacking watch_patterns + notify_on_complete on end-of-run markers (#12113)
Stacking both features on the same event produces duplicate, delayed
notifications — delivery is async and continues firing after the process
exits, so matches on end-of-run markers (SUMMARY, DONE, PASS) arrive
after the agent has already polled/waited and moved on.

Updates both the terminal tool JSON schema description and the
terminal_tool() function docstring to make the split explicit:

- watch_patterns: mid-process signals only (errors, readiness markers,
  intermediate steps you want to react to before the process exits)
- notify_on_complete: end-of-run completion signal

No behavioural change.
2026-04-18 03:53:21 -07:00
teknium1 3b69b2fd61 test(session-search): regression coverage for CJK LIKE fallback
Twelve tests under TestCJKSearchFallback guarding:
 - CJK detection across Chinese/Japanese/Korean/Hiragana/Katakana ranges
   (including the full Hangul syllables block \uac00-\ud7af, to catch
   the shorter-range typo from one of the duplicate PRs)
 - Substring match for multi-char Chinese, Japanese, Korean queries
 - Filter preservation (source_filter, exclude_sources, role_filter)
   in the LIKE path — guards against the SQL-builder bug from another
   duplicate PR where filter clauses landed after LIMIT/OFFSET
 - Snippet centered on the matched term (instr-based substr window),
   not the leading 200 chars of content
 - English fast-path untouched
 - Empty/no-match cases
 - Mixed CJK+English queries

Also:
 - hermes_state.py: LIKE-fallback snippet is now
   `substr(content, max(1, instr(content, ?) - 40), 120)`, centered on
   the match instead of the whole-content default. Credit goes to
   @iamagenius00 for the snippet idea in PR #11517.
 - scripts/release.py: add @iamagenius00 to AUTHOR_MAP so future
   release attribution resolves cleanly.

Refs #11511, #11516, #11517, #11541.

Co-authored-by: iamagenius00 <iamagenius00@users.noreply.github.com>
2026-04-18 01:57:57 -07:00
vominh1919 8826d9c197 fix: FTS5 LIKE fallback for CJK (Chinese/Japanese/Korean) queries
FTS5 default tokenizer splits CJK text character-by-character, causing
multi-character queries like '记忆断裂' to return 0 results.

This fix adds a LIKE fallback: when FTS5 returns no results and the
query contains CJK characters, retry with WHERE content LIKE '%query%'.
Preserves FTS5 performance for English queries.

Fixes #11511
2026-04-18 01:57:57 -07:00
Teknium a2c9f5d0a7 docs(execute_code): document project/strict execution modes (#12073)
Follow-up to PR #11971. Documents the new code_execution.mode config
key and what each mode actually does.

- user-guide/configuration.md: add mode: project to the yaml example,
  explain project vs strict and call out that security invariants are
  identical across modes.
- user-guide/features/code-execution.md: new 'Execution Mode' section
  with a comparison table and usage guidance; update the 'temporary
  directory' note so it reflects that script.py runs in the session
  CWD in project mode (staging dir stays on PYTHONPATH for imports);
  drop stale 'sandboxed' framing from the intro and skill-passthrough
  paragraph.
- getting-started/learning-path.md: update the one-line Code Execution
  summary to match (no longer 'sandboxed environments' — the default
  runs in the session's real working directory).

No code changes.
2026-04-18 01:53:09 -07:00
Teknium 8322b42c6c fix(streaming): surface dropped tool-call on mid-stream stall (#12072)
When streaming died after text was already delivered to the user but
before a tool-call's arguments finished streaming, the partial-stream
stub at the end of _interruptible_streaming_api_call silently set
`tool_calls=None` on the returned message and kept `finish_reason=stop`.
The agent treated the turn as complete, the session exited cleanly with
code 0, and the attempted action was lost with zero user-facing signal.

Live-observed Apr 2026 with MiniMax M2.7 on a ~6-minute audit task:
agent streamed 'Let me write the audit:', started emitting a write_file
tool call, MiniMax stalled for 240s mid-arguments, the stale-stream
detector killed the connection, the stub fired, session ended, no file
written, no error shown.

Fix: the streaming accumulator now records each tool-call's name into
`result['partial_tool_names']` as soon as the name is known. When the
stub builder fires after a partial delivery and finds any recorded tool
names, it appends a human-visible warning to the stub's content — and
also fires it as a live stream delta so the user sees it immediately,
not only in the persisted transcript. The next turn's model also sees
the warning in conversation history and can retry on its own. Text-only
partial streams keep the original bare-recovery behaviour (no warning).

Validation:
| Scenario                                    | Before                    | After                                       |
|---------------------------------------------|---------------------------|---------------------------------------------|
| Stream dies mid tool-call, text already sent | Silent exit, no indication | User sees ⚠ warning naming the dropped tool |
| Text-only partial stream                     | Bare recovered text       | Unchanged                                   |
| tests/run_agent/test_streaming.py            | 24 passed                 | 26 passed (2 new)                           |
2026-04-18 01:52:06 -07:00
Teknium 285bb2b915 feat(execute_code): add project/strict execution modes, default to project (#11971)
Weaker models (Gemma-class) repeatedly rediscover and forget that
execute_code uses a different CWD and Python interpreter than terminal(),
causing them to flip-flop on whether user files exist and to hit import
errors on project dependencies like pandas.

Adds a new 'code_execution.mode' config key (default 'project') that
brings execute_code into line with terminal()'s filesystem/interpreter:

  project (new default):
    - cwd       = session's TERMINAL_CWD (falls back to os.getcwd())
    - python    = active VIRTUAL_ENV/bin/python or CONDA_PREFIX/bin/python
                  with a Python 3.8+ version check; falls back cleanly to
                  sys.executable if no venv or the candidate fails
    - result    : 'import pandas' works, '.env' resolves, matches terminal()

  strict (opt-in):
    - cwd       = staging tmpdir (today's behavior)
    - python    = sys.executable (today's behavior)
    - result    : maximum reproducibility and isolation; project deps
                  won't resolve

Security-critical invariants are identical across both modes and covered by
explicit regression tests:

  - env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD,
    *_CREDENTIAL, *_PASSWD, *_AUTH substrings)
  - SANDBOX_ALLOWED_TOOLS whitelist (no execute_code recursion, no
    delegate_task, no MCP from inside scripts)
  - resource caps (5-min timeout, 50KB stdout, 50 tool calls)

Deliberately avoids 'sandbox'/'isolated'/'cloud' language in tool
descriptions (regression from commit 39b83f34 where agents on local
backends falsely believed they were sandboxed and refused networking).

Override via env var: HERMES_EXECUTE_CODE_MODE=strict|project
2026-04-18 01:46:25 -07:00
Teknium 54e0eb24c0 docs: correctness audit — fix wrong values, add missing coverage (#11972)
Comprehensive audit of every reference/messaging/feature doc page against the
live code registries (PROVIDER_REGISTRY, OPTIONAL_ENV_VARS, COMMAND_REGISTRY,
TOOLSETS, tool registry, on-disk skills). Every fix was verified against code
before writing.

### Wrong values fixed (users would paste-and-fail)

- reference/environment-variables.md:
  - DASHSCOPE_BASE_URL default was `coding-intl.dashscope.aliyuncs.com/v1` \u2192
    actual `dashscope-intl.aliyuncs.com/compatible-mode/v1`.
  - MINIMAX_BASE_URL and MINIMAX_CN_BASE_URL defaults were `/v1` \u2192 actual
    `/anthropic` (Hermes calls MiniMax via its Anthropic Messages endpoint).
- reference/toolsets-reference.md MCP example used the non-existent nested
  `mcp: servers:` key \u2192 real key is the flat `mcp_servers:`.
- reference/skills-catalog.md listed ~20 bundled skills that no longer exist
  on disk (all moved to `optional-skills/`). Regenerated the whole bundled
  section from `skills/**/SKILL.md` \u2014 79 skills, accurate paths and names.
- messaging/slack.md ":::info" callout claimed Slack has no
  `free_response_channels` equivalent; both the env var and the yaml key are
  in fact read.
- messaging/qqbot.md documented `QQ_MARKDOWN_SUPPORT` as an env var, but the
  adapter only reads `extra.markdown_support` from config.yaml. Removed the
  env var row and noted config-only nature.
- messaging/qqbot.md `hermes setup gateway` \u2192 `hermes gateway setup`.

### Missing coverage added

- Providers: AWS Bedrock and Qwen Portal (qwen-oauth) \u2014 both in
  PROVIDER_REGISTRY but undocumented everywhere. Added sections to
  integrations/providers.md, rows to quickstart.md and fallback-providers.md.
- integrations/providers.md "Fallback Model" provider list now includes
  gemini, google-gemini-cli, qwen-oauth, xai, nvidia, ollama-cloud, bedrock.
- reference/cli-commands.md `--provider` enum and HERMES_INFERENCE_PROVIDER
  enum in env-vars now include the same set.
- reference/slash-commands.md: added `/agents` (alias `/tasks`) and `/copy`.
  Removed duplicate rows for `/snapshot`, `/fast` (\u00d72), `/debug`.
- reference/tools-reference.md: fixed "47 built-in tools" \u2192 52. Added
  `feishu_doc` and `feishu_drive` toolset sections.
- reference/toolsets-reference.md: added `feishu_doc` / `feishu_drive` core
  rows + all missing `hermes-<platform>` toolsets in the platform table
  (bluebubbles, dingtalk, feishu, qqbot, wecom, wecom-callback, weixin,
  homeassistant, webhook, gateway). Fixed the `debugging` composite to
  describe the actual `includes=[...]` mechanism.
- reference/optional-skills-catalog.md: added `fitness-nutrition`.
- reference/environment-variables.md: added NOUS_BASE_URL,
  NOUS_INFERENCE_BASE_URL, NVIDIA_API_KEY/BASE_URL, OLLAMA_API_KEY/BASE_URL,
  XAI_API_KEY/BASE_URL, MISTRAL_API_KEY, AWS_REGION/AWS_PROFILE,
  BEDROCK_BASE_URL, HERMES_QWEN_BASE_URL, DISCORD_ALLOWED_CHANNELS,
  DISCORD_PROXY, TELEGRAM_REPLY_TO_MODE, MATRIX_DEVICE_ID, MATRIX_REACTIONS,
  QQBOT_HOME_CHANNEL_NAME, QQ_SANDBOX.
- messaging/discord.md: documented DISCORD_ALLOWED_CHANNELS, DISCORD_PROXY,
  HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS and HERMES_DISCORD_TEXT_BATCH_SPLIT
  _DELAY_SECONDS (all actively read by the adapter).
- messaging/matrix.md: documented MATRIX_REACTIONS (default true).
- messaging/telegram.md: removed the redundant second Webhook Mode section
  that invented a `telegram.webhook_mode: true` yaml key the adapter does
  not read.
- user-guide/features/hooks.md: added `on_session_finalize` and
  `on_session_reset` (both emitted via invoke_hook but undocumented).
- user-guide/features/api-server.md: documented GET /health/detailed, the
  `/api/jobs/*` CRUD surface, POST /v1/runs, and GET /v1/runs/{id}/events
  (10 routes that were live but undocumented).
- user-guide/features/fallback-providers.md: added `approval` and
  `title_generation` auxiliary-task rows; added gemini, bedrock, qwen-oauth
  to the supported-providers table.
- user-guide/features/tts.md: "seven providers" \u2192 "eight" (post-xAI add
  oversight in #11942).
- user-guide/configuration.md: TTS provider enum gains `xai` and `gemini`;
  yaml example block gains `mistral:`, `gemini:`, `xai:` subsections.
  Auxiliary-provider enum now enumerates all real registry entries.
- reference/faq.md: stale AIAgent/config examples bumped from
  `nous/hermes-3-llama-3.1-70b` and `claude-sonnet-4.6` to
  `claude-opus-4.7`.

### Docs-site integrity

- guides/build-a-hermes-plugin.md referenced two nonexistent hooks
  (`pre_api_request`, `post_api_request`). Replaced with the real
  `on_session_finalize` / `on_session_reset` entries.
- messaging/open-webui.md and features/api-server.md had pre-existing
  broken links to `/docs/user-guide/features/profiles` (actual path is
  `/docs/user-guide/profiles`). Fixed.
- reference/skills-catalog.md had one `<1%` literal that MDX parsed as a
  JSX tag. Escaped to `&lt;1%`.

### False positives filtered out (not changed, verified correct)

- `/set-home` is a registered alias of `/sethome` \u2014 docs were fine.
- `hermes setup gateway` is valid syntax (`hermes setup \<section\>`);
  changed in qqbot.md for cross-doc consistency, not as a bug fix.
- Telegram reactions "disabled by default" matches code (default `"false"`).
- Matrix encryption "opt-in" matches code (empty env default \u2192 disabled).
- `pre_api_request` / `post_api_request` hooks do NOT exist in current code;
  documented instead the real `on_session_finalize` / `on_session_reset`.
- SIGNAL_IGNORE_STORIES is already in env-vars.md (subagent missed it).

Validation:
- `docusaurus build` \u2014 passes (only pre-existing nix-setup anchor warning).
- `ascii-guard lint docs` \u2014 124 files, 0 errors.
- 22 files changed, +317 / \u2212158.
2026-04-18 01:45:48 -07:00
Teknium 73bccc94c7 skills: consolidate mlops redundancies (gguf+llama-cpp, grpo+trl, guidance→optional) (#11965)
Three tightly-scoped built-in skill consolidations to reduce redundancy in
the available_skills listing injected into every system prompt:

1. gguf-quantization → llama-cpp (merged)
   GGUF is llama.cpp's format; two skills covered the same toolchain. The
   merged llama-cpp skill keeps the full K-quant table + imatrix workflow
   from gguf and the ROCm/benchmarks/supported-models sections from the
   original llama-cpp. All 5 reference files preserved.

2. grpo-rl-training → fine-tuning-with-trl (folded in)
   GRPO isn't a framework, it's a trainer inside TRL. Moved the 17KB
   deep-dive SKILL.md to references/grpo-training.md and the working
   template to templates/basic_grpo_training.py. TRL's GRPO workflow
   section now points to both. Atropos skill's related_skills updated.

3. guidance → optional-skills/mlops/
   Dropped from built-in. Outlines (still built-in) covers the same
   structured-generation ground with wider adoption. Listed in the
   optional catalog for users who specifically want Guidance.

Net: 3 fewer built-in skill lines in every system prompt, zero content
loss. Contributor authorship preserved via git rename detection.
2026-04-17 21:36:40 -07:00
Teknium 598cba62ad test: update stale tests to match current code (#11963)
Seven test files were asserting against older function signatures and
behaviors. CI has been red on main because of accumulated test debt
from other PRs; this catches the tests up.

- tests/agent/test_subagent_progress.py: _build_child_progress_callback
  now takes (task_index, goal, parent_agent, task_count=1); update all
  call sites and rewrite tests that assumed the old 'batch-only' relay
  semantics (now relays per-tool AND flushes a summary at BATCH_SIZE).
  Renamed test_thinking_not_relayed_to_gateway → test_thinking_relayed_to_gateway
  since thinking IS now relayed as subagent.thinking.
- tests/tools/test_delegate.py: _build_child_agent now requires
  task_count; add task_count=1 to all 8 call sites.
- tests/cli/test_reasoning_command.py: AIAgent gained _stream_callback;
  stub it on the two test agent helpers that use spec=AIAgent / __new__.
- tests/hermes_cli/test_cmd_update.py: cmd_update now runs npm install
  in repo root + ui-tui/ + web/ and 'npm run build' in web/; assert
  all four subprocess calls in the expected order.
- tests/hermes_cli/test_model_validation.py: dissimilar unknown models
  now return accepted=False (previously True with warning); update
  both affected tests.
- tests/tools/test_registry.py: include feishu_doc_tool and
  feishu_drive_tool in the expected builtin tool set.
- tests/gateway/test_voice_command.py: missing-voice-deps message now
  suggests 'pip install PyNaCl' not 'hermes-agent[messaging]'.

411/411 pass locally across these 7 files.
2026-04-17 21:35:30 -07:00
Teknium 5ff65dbf68 docs(execute_code): clarify that scripts run in their own temp dir, not session CWD (#11956)
Weaker models (Gemma-class) repeatedly rediscover and forget that execute_code's
working directory differs from terminal()/read_file()'s, leading to
os.path.exists('.env') returning False even though the file exists in the
session's CWD. They then bounce between 'the file exists' and 'the file is
missing' across tool calls.

Adds a 'Working directory' note to the execute_code schema description
pointing agents at absolute paths (os.path.expanduser) or terminal()/read_file()
for inspecting user files.

Carefully avoids the 'sandbox'/'isolated'/'cloud' language that commit
39b83f34 removed (it caused agents on local backends to refuse networking
tasks and save false sandbox beliefs to persistent memory). Purely factual
CWD guidance — no restriction implications.
2026-04-17 21:30:34 -07:00
Teknium c20e236b71 chore: map AviArora02-commits author email in release AUTHOR_MAP 2026-04-17 21:30:17 -07:00
AviArora02-commits 994faacce8 fix: suppress Authorization: Bearer for Gemini provider to prevent HTTP 400 (#7893) 2026-04-17 21:30:17 -07:00
Teknium 8a59f8a9ed fix(update): survive mid-update terminal disconnect (#11960)
hermes update no longer dies when the controlling terminal closes
(SSH drop, shell close) during pip install.  SIGHUP is set to SIG_IGN
for the duration of the update, and stdout/stderr are wrapped so writes
to a closed pipe are absorbed instead of cascading into process exit.
All update output is mirrored to ~/.hermes/logs/update.log so users can
see what happened after reconnecting.

SIGINT (Ctrl-C) and SIGTERM (systemd) are intentionally still honored —
those are deliberate cancellations, not accidents.  In gateway mode the
helper is a no-op since the update is already detached.

POSIX preserves SIG_IGN across exec(), so pip and git subprocesses
inherit hangup protection automatically — no changes to subprocess
spawning needed.
2026-04-17 21:29:24 -07:00
Teknium 1c352f6b1d docs(browser): expand Camofox persistence guide with troubleshooting (#11957)
The existing 'Persistent browser sessions' section had the correct config
snippet but users still hit the flag at the wrong config path, assumed
Hermes could force persistence when the server was ephemeral, and had no
way to verify the flag was actually taking effect.

Adds to that section:
- Warning admonition calling out the nested path vs top-level mistake.
- Explicit 'What Hermes does / does not do' split so users understand
  Hermes can only send a stable userId; the Camofox server must map it
  to a persistent profile.
- 5-step verification flow for confirming persistence works end-to-end.
- Reminder to restart Hermes after editing config.yaml.
- Where Hermes derives the stable userId (~/.hermes/browser_auth/camofox/)
  so users can reset or back up state.

Docs-only change.
2026-04-17 21:23:31 -07:00
Teknium 11a89cc032 docs: backfill coverage for recently-merged features (#11942)
Fills documentation gaps that accumulated as features merged ahead of their
docs updates. All additions are verified against code and the originating PRs.

Providers:
- Ollama Cloud (#10782) — new provider section, env vars, quickstart/fallback rows
- xAI Grok Responses API + TTS (#10783) — provider note, TTS table + config
- Google Gemini CLI OAuth (#11270) — quickstart/fallback/cli-commands entries
- NVIDIA NIM (#11774) — NVIDIA_API_KEY / NVIDIA_BASE_URL in env-vars reference
- HERMES_INFERENCE_PROVIDER enum updated

Messaging:
- DISCORD_ALLOWED_ROLES (#11608) — env-vars, discord.md access control section
- DingTalk QR device-flow (#11574) — wizard path in Option A + openClaw disclosure
- Feishu document comment intelligent reply (#11898) — full section + 3-tier access control + CLI

Skills / commands:
- concept-diagrams skill (#11363) — optional-skills-catalog entry
- /gquota (#11270) — slash-commands reference

Build: docusaurus build passes, ascii-guard lint 0 errors.
2026-04-17 21:22:11 -07:00
Teknium 45acd9beb5 fix(gateway): ignore redelivered /restart after PTB offset ACK fails (#11940)
When a Telegram /restart fires and PTB's graceful-shutdown `get_updates`
ACK call times out ("When polling for updates is restarted, updates may
be received twice" in gateway.log), the new gateway receives the same
/restart again and restarts a second time — a self-perpetuating loop.

Record the triggering update_id in `.restart_last_processed.json` when
handling /restart.  On the next process, reject a /restart whose
update_id <= the recorded one as a stale redelivery.  5-minute staleness
guard so an orphaned marker can't block a legitimately new /restart.

- gateway/platforms/base.py: add `platform_update_id` to MessageEvent
- gateway/platforms/telegram.py: propagate `update.update_id` through
  _build_message_event for text/command/location/media handlers
- gateway/run.py: write dedup marker in _handle_restart_command;
  _is_stale_restart_redelivery checks it before processing /restart
- tests/gateway/test_restart_redelivery_dedup.py: 9 new tests covering
  fresh restart, redelivery, staleness window, cross-platform,
  malformed-marker resilience, and no-update_id (CLI) bypass

Only active for Telegram today (the one platform with monotonic
cross-session update ordering); other platforms return False from
_is_stale_restart_redelivery and proceed normally.
2026-04-17 21:17:33 -07:00
Teknium c5c0bb9a73 fix: point optional-dep install hints at the venv's python (#11938)
Error messages that tell users to install optional extras now use
{sys.executable} -m pip install ... instead of a bare 'pip install
hermes-agent[extra]' string.  Under the curl installer, bare 'pip'
resolves to system pip, which either fails with PEP 668
externally-managed-environment or installs into the wrong Python.

Affects: hermes dashboard, hermes web server startup, mcp_serve,
hermes doctor Bedrock check, CLI voice mode, voice_mode tool runtime
error, Discord voice-channel join failure message.
2026-04-17 21:16:33 -07:00
Teknium 20f2258f34 fix(interrupt): propagate to concurrent-tool workers + opt-in debug trace (#11907)
* fix(interrupt): propagate to concurrent-tool workers + opt-in debug trace

interrupt() previously only flagged the agent's _execution_thread_id.
Tools running inside _execute_tool_calls_concurrent execute on
ThreadPoolExecutor worker threads whose tids are distinct from the
agent's, so is_interrupted() inside those tools returned False no matter
how many times the gateway called .interrupt() — hung ssh / curl / long
make-builds ran to their own timeout.

Changes:
- run_agent.py: track concurrent-tool worker tids in a per-agent set,
  fan interrupt()/clear_interrupt() out to them, and handle the
  register-after-interrupt race at _run_tool entry.  getattr fallback
  for the tracker so test stubs built via object.__new__ keep working.
- tools/environments/base.py: opt-in _wait_for_process trace (ENTER,
  per-30s HEARTBEAT with interrupt+activity-cb state, INTERRUPT
  DETECTED, TIMEOUT, EXIT) behind HERMES_DEBUG_INTERRUPT=1.
- tools/interrupt.py: opt-in set_interrupt() trace (caller tid, target
  tid, set snapshot) behind the same env flag.
- tests: new regression test runs a polling tool on a concurrent worker
  and asserts is_interrupted() flips to True within ~1s of interrupt().
  Second new test guards clear_interrupt() clearing tracked worker bits.

Validation: tests/run_agent/ all 762 pass; tests/tools/ interrupt+env
subset 216 pass.

* fix(interrupt-debug): bypass quiet_mode logger filter so trace reaches agent.log

AIAgent.__init__ sets logging.getLogger('tools').setLevel(ERROR) when
quiet_mode=True (the CLI default). This would silently swallow every
INFO-level trace line from the HERMES_DEBUG_INTERRUPT=1 instrumentation
added in the parent commit — confirmed by running hermes chat -q with
the flag and finding zero trace lines in agent.log even though
_wait_for_process was clearly executing (subprocess pid existed).

Fix: when HERMES_DEBUG_INTERRUPT=1, each traced module explicitly sets
its own logger level to INFO at import time, overriding the 'tools'
parent-level filter. Scoped to the opt-in case only, so production
(quiet_mode default) logs stay quiet as designed.

Validation: hermes chat -q with HERMES_DEBUG_INTERRUPT=1 now writes
'_wait_for_process ENTER/EXIT' lines to agent.log as expected.

* fix(cli): SIGTERM/SIGHUP no longer orphans tool subprocesses

Tool subprocesses spawned by the local environment backend use
os.setsid so they run in their own process group. Before this fix,
SIGTERM/SIGHUP to the hermes CLI killed the main thread via
KeyboardInterrupt but the worker thread running _wait_for_process
never got a chance to call _kill_process — Python exited, the child
was reparented to init (PPID=1), and the subprocess ran to its
natural end (confirmed live: sleep 300 survived 4+ min after SIGTERM
to the agent until manual cleanup).

Changes:
- cli.py _signal_handler (interactive) + _signal_handler_q (-q mode):
  route SIGTERM/SIGHUP through agent.interrupt() so the worker's poll
  loop sees the per-thread interrupt flag and calls _kill_process
  (os.killpg) on the subprocess group. HERMES_SIGTERM_GRACE (default
  1.5s) gives the worker time to complete its SIGTERM+SIGKILL
  escalation before KeyboardInterrupt unwinds main.
- tools/environments/base.py _wait_for_process: wrap the poll loop in
  try/except (KeyboardInterrupt, SystemExit) so the cleanup fires
  even on paths the signal handlers don't cover (direct sys.exit,
  unhandled KI from nested code, etc.). Emits EXCEPTION_EXIT trace
  line when HERMES_DEBUG_INTERRUPT=1.
- New regression test: injects KeyboardInterrupt into a running
  _wait_for_process via PyThreadState_SetAsyncExc, verifies the
  subprocess process group is dead within 3s of the exception and
  that KeyboardInterrupt re-raises cleanly afterward.

Validation:
| Before                                                  | After              |
|---------------------------------------------------------|--------------------|
| sleep 300 survives 4+ min as PPID=1 orphan after SIGTERM | dies within 2 s   |
| No INTERRUPT DETECTED in trace                          | INTERRUPT DETECTED fires + killing process group |
| tests/tools/test_local_interrupt_cleanup                | 1/1 pass          |
| tests/run_agent/test_concurrent_interrupt               | 4/4 pass          |
2026-04-17 20:39:25 -07:00
Teknium 607be54a24 fix(discord): forum channel media + polish
Extend forum support from PR #10145:

- REST path (_send_discord): forum thread creation now uploads media
  files as multipart attachments on the starter message in a single
  call. Previously media files were silently dropped on the forum
  path.
- Websocket media paths (_send_file_attachment, send_voice, send_image,
  send_animation — covers send_image_file, send_video, send_document
  transitively): forum channels now go through a new _forum_post_file
  helper that creates a thread with the file as starter content,
  instead of failing via channel.send(file=...) which forums reject.
- _send_to_forum chunk follow-up failures are collected into
  raw_response['warnings'] so partial-send outcomes surface.
- Process-local probe cache (_DISCORD_CHANNEL_TYPE_PROBE_CACHE) avoids
  GET /channels/{id} on every uncached send after the first.
- Dedup of TestSendDiscordMedia that the PR merge-resolution left
  behind.
- Docs: Forum Channels section under website/docs/user-guide/messaging/discord.md.

Tests: 117 passed (22 new for forum+media, probe cache, warnings).
2026-04-17 20:25:48 -07:00
ChimingLiu e5333e793c feat(discord): support forum channels 2026-04-17 20:25:48 -07:00
helix4u 148459716c fix(kimi): cover remaining fixed-temperature bypasses 2026-04-17 20:25:42 -07:00
Teknium 53e4a2f2c6 feat(update): warn about legacy hermes.service units during hermes update (#11918)
Follow-up to #11909: surface the legacy-unit warning where users are most
likely to see it. After a 'hermes update', if a pre-rename hermes.service
is still installed alongside the current hermes-gateway.service, print
the list of legacy units + the 'hermes gateway migrate-legacy' command.

Profile-safe: reuses _find_legacy_hermes_units() which is an explicit
allowlist of hermes.service only — profile units never match.
Platform-gated: only prints on systemd hosts (the rename is Linux-only).
Non-blocking: just prints, never prompts, so gateway-spawned
hermes update --gateway runs aren't affected.
2026-04-17 19:35:12 -07:00
Teknium 07db20c72d fix(gateway): detect legacy hermes.service + mark --replace SIGTERM as planned (#11909)
* fix(gateway): detect legacy hermes.service units from pre-rename installs

Older Hermes installs used a different service name (hermes.service) before
the rename to hermes-gateway.service. When both units remain installed, they
fight over the same bot token — after PR #5646's signal-recovery change,
this manifests as a 30-second SIGTERM flap loop between the two services.

Detection is an explicit allowlist (no globbing) plus an ExecStart content
check, so profile units (hermes-gateway-<profile>.service) and unrelated
third-party services named 'hermes' are never matched.

Wired into systemd_install, systemd_status, gateway_setup wizard, and the
main hermes setup flow — anywhere we already warn about scope conflicts now
also warns about legacy units.

* feat(gateway): add migrate-legacy command + install-time removal prompt

- New hermes_cli.gateway.remove_legacy_hermes_units() removes legacy
  unit files with stop → disable → unlink → daemon-reload. Handles user
  and system scopes separately; system scope returns path list when not
  running as root so the caller can tell the user to re-run with sudo.
- New 'hermes gateway migrate-legacy' subcommand (with --dry-run and -y)
  routes to remove_legacy_hermes_units via gateway_command dispatch.
- systemd_install now offers to remove legacy units BEFORE installing
  the new hermes-gateway.service, preventing the SIGTERM flap loop that
  hits users who still have pre-rename hermes.service around.

Profile units (hermes-gateway-<profile>.service) remain untouched in
all paths — the legacy allowlist is explicit (_LEGACY_SERVICE_NAMES)
and the ExecStart content check further narrows matches.

* fix(gateway): mark --replace SIGTERM as planned so target exits 0

PR #5646 made SIGTERM exit the gateway with code 1 so systemd's
Restart=on-failure revives it after unexpected kills. But when a user has
two gateway units fighting for the same bot token (e.g. legacy
hermes.service + hermes-gateway.service from a pre-rename install), the
--replace takeover itself becomes the 'unexpected' SIGTERM — the loser
exits 1, systemd revives it 30s later, and the cycle flaps indefinitely.

Before calling terminate_pid(), --replace now writes a short-lived marker
file naming the target PID + start_time. The target's shutdown_signal_handler
consumes the marker and, when it names this process, leaves
_signal_initiated_shutdown=False so the final exit code stays 0.

Staleness defences:
- PID + start_time combo prevents PID reuse matching an old marker
- Marker older than 60s is treated as stale and discarded
- Marker is unlinked on first read even if it doesn't match this process
- Replacer clears the marker post-loop + on permission-denied give-up
2026-04-17 19:27:58 -07:00
Teknium 38436eb4e3 chore(release): add pedh to AUTHOR_MAP 2026-04-17 19:26:53 -07:00
pedh 86fd0f846d docs(dingtalk): document AI Cards, emoji reactions, and display settings
- AI Cards: how to configure ``card_template_id`` for streaming rich replies
- Emoji reactions: 🤔Thinking → 🥳Done lifecycle
- Per-platform display settings (streaming, tool_progress, reasoning, etc.)
- Installation: switch to the ``hermes-agent[dingtalk]`` extra (adds
  alibabacloud-dingtalk alongside dingtalk-stream)
- Messaging capability matrix updated to reflect images, audio, video,
  and threading support
2026-04-17 19:26:53 -07:00
pedh 4459913f40 feat(dingtalk): AI Cards streaming, emoji reactions, and media handling
Cherry-picked from #10985 by pedh, adapted to current main:

* Keeps main's full group-chat gating (require_mention + allowed_users +
  free_response_chats + mention_patterns) — PR's simpler subset dropped.
* Keeps main's fire-and-forget process() dispatch + session_webhook
  fallback for SDK >= 0.24.
* Picks up PR's REQUIRES_EDIT_FINALIZE capability flag on
  BasePlatformAdapter + finalize kwarg on edit_message(), plumbed through
  stream_consumer.  Default False so Telegram/Slack/Discord/Matrix stay
  on the zero-overhead fast path.
* DingTalk AI Card lifecycle: per-chat _message_contexts, two-card flow
  (tool-progress + final response) with sibling auto-close driven by
  reply_to, idempotent 🤔Thinking → 🥳Done swap, $alibabacloud-dingtalk$
  for media URL resolution (replaces raw HTTP that was 403-ing).
* pyproject: dingtalk extra now dingtalk-stream>=0.20,<1 +
  alibabacloud-dingtalk>=2.0.0 + qrcode.

Closes #10991

Co-authored-by: pedh
2026-04-17 19:26:53 -07:00
Teknium d7ef562a05 fix(file-ops): follow terminal env's live cwd in _exec instead of init-time cached cwd (#11912)
ShellFileOperations captured the terminal env's cwd at __init__ time and
used that stale value for every subsequent _exec() call.  When the user
ran `cd` via the terminal tool, `env.cwd` updated but `ops.cwd` did not.
Relative paths passed to patch_replace / read_file / write_file / search
then targeted the ORIGINAL directory instead of the current one.

Observed symptom in agent sessions:

  terminal: cd .worktrees/my-branch
  patch hermes_cli/main.py <old> <new>
    → returns {"success": true} with a plausible unified diff
    → but `git diff` in the worktree shows nothing
    → the patch landed in the main repo's checkout of main.py instead

The diff looked legitimate because patch_replace computes it from the
IN-MEMORY content vs new_content, not by re-reading the file.  The
write itself DID succeed — it just wrote to the wrong directory's copy
of the same-named file.

Fix: _exec() now resolves cwd from live sources in this order:

  1. Explicit `cwd` arg (if provided by the caller)
  2. Live `self.env.cwd` (tracks `cd` commands run via terminal)
  3. Init-time `self.cwd` (fallback when env has no cwd attribute)

Includes a 5-test regression suite covering:
  - cd followed by relative read follows live cwd
  - the exact reported bug: patch_replace with relative path after cd
  - explicit cwd= arg still wins over env.cwd
  - env without cwd attribute falls back to init-time cwd
  - patch_replace success reflects real file state (safety rail)

Co-authored-by: teknium1 <teknium@nousresearch.com>
2026-04-17 19:26:40 -07:00
helix4u 47010e0757 fix(gateway): allow systemd-backed distrobox services 2026-04-17 19:24:30 -07:00
Teknium 213e39463b chore(release): add akhater to AUTHOR_MAP
Contributor of PR #11858 (nous OAuth providers mirror fix).  CI
blocks releases on unmapped author emails.
2026-04-17 19:13:40 -07:00
Teknium 2297c5f5ce fix(auth): restore --label for hermes auth add nous --type oauth
persist_nous_credentials() now accepts an optional label kwarg which
gets embedded in providers.nous under the 'label' key.
_seed_from_singletons() prefers the embedded label over the
auto-derived label_from_token() fingerprint when materialising the
pool entry, so re-seeding on every load_pool('nous') preserves the
user's chosen label.

auth_commands.py threads --label through to the helper, restoring
parity with how other OAuth providers (anthropic, codex, google,
qwen) honor the flag.

Tests: 4 new (embed, reseed-survives, no-label fallback, end-to-end
through auth_add_command). All 390 nous/auth/credential_pool tests
pass.
2026-04-17 19:13:40 -07:00
Antoine Khater c7fece1f9d fix: normalise Nous device-code pool source to avoid duplicates
Review feedback on the original commit: the helper wrote a pool entry
with source `manual:device_code` while `_seed_from_singletons()` upserts
with `device_code` (no `manual:` prefix), so the pool grew a duplicate
row on every `load_pool()` after login.

Normalise: the helper now writes `providers.nous` and delegates the pool
write entirely to `_seed_from_singletons()` via a follow-up
`load_pool()` call. The canonical source is `device_code`; the helper
never materialises a parallel `manual:device_code` entry.

- `persist_nous_credentials()` loses its `label` and `source` kwargs —
  both are now derived by the seed path from the singleton state.
- CLI and web dashboard call sites simplified accordingly.
- New test `test_persist_nous_credentials_idempotent_no_duplicate_pool_entries`
  asserts that two consecutive persists leave exactly one pool row and
  no stray `manual:` entries.
- Existing `test_auth_add_nous_oauth_persists_pool_entry` updated to
  assert the canonical source and single-entry invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:13:40 -07:00
Antoine Khater c096a6935f fix(auth): mirror Nous OAuth credentials to providers.nous on CLI login
`hermes auth add nous --type oauth` only wrote credential_pool.nous,
leaving providers.nous empty. When the Nous agent_key's 24h TTL expired,
run_agent.py's 401-recovery path called resolve_nous_runtime_credentials
(which reads providers.nous), got AuthError "Hermes is not logged into
Nous Portal", caught it as logger.debug (suppressed at INFO level), and
the agent died with "Non-retryable client error" — no signal to the
user that recovery even tried.

Introduce persist_nous_credentials() as the single source of truth for
Nous device-code login persistence. Both auth_commands (CLI) and
web_server (dashboard) now route through it, so pool and providers
stay in sync at write time.

Why: CLI-provisioned profiles couldn't recover from agent_key expiry,
producing silent daily outages 24h after first login. PR #6856/#6869
addressed adjacent issues but assumed providers.nous was populated;
this one wasn't being written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:13:40 -07:00
Teknium a155b4a159 feat(auxiliary): default 'auto' routing to main model for all users (#11900)
Before: aggregator users (OpenRouter / Nous Portal) running 'auto'
routing for auxiliary tasks — compression, vision, web extraction,
session search, etc. — got routed to a cheap provider-side default
model (Gemini Flash).  Non-aggregator users already got their main
model.  Behavior was inconsistent and surprising — users picked
Claude / GPT / their preferred model, but side tasks ran on
Gemini Flash.

After: 'auto' means "use my main chat model" for every user,
regardless of provider type.  Only when the main provider has no
working client does the fallback chain run (OpenRouter → Nous →
custom → Codex → API-key providers).  Explicit per-task overrides
in config.yaml (auxiliary.<task>.provider / .model) still win —
they are a hard constraint, not subject to the auto policy.

Vision auto-detection follows the same policy: try main provider +
main model first (with _PROVIDER_VISION_MODELS overrides preserved
for providers like xiaomi and zai that ship a dedicated multimodal
model distinct from their chat model).  Aggregator strict vision
backends are fallbacks, not the primary path.

Changes:
  - agent/auxiliary_client.py: _resolve_auto() drops the
    `_AGGREGATOR_PROVIDERS` guard.  resolve_vision_provider_client()
    auto branch unifies aggregator and exotic-provider paths —
    everyone goes through resolve_provider_client() with main_model.
    Dead _AGGREGATOR_PROVIDERS constant removed (was only used by
    the guard we just removed).
  - hermes_cli/main.py: aux config menu copy updated to reflect
    the new semantics ("'auto' means 'use my main model'").
  - tests/agent/test_auxiliary_main_first.py: 12 regression tests
    covering OpenRouter/Nous/DeepSeek main paths, runtime-override
    wins, explicit-config wins, vision override preservation for
    exotic providers, and fallback-chain activation when the main
    provider has no working client.

Co-authored-by: teknium1 <teknium@nousresearch.com>
2026-04-17 19:13:23 -07:00
Teknium b449a0e049 fix(feishu-comment): use get_hermes_home(); drop dead asyncio wrapper; AUTHOR_MAP
Follow-up polish on top of the cherry-picked #11023 commit.

- feishu_comment_rules.py: replace import-time "~/.hermes" expanduser fallback
  with get_hermes_home() from hermes_constants (canonical, profile-safe).
- tools/feishu_doc_tool.py, tools/feishu_drive_tool.py: drop the
  asyncio.get_event_loop().run_until_complete(asyncio.to_thread(...)) dance.
  Tool handlers run synchronously in a worker thread with no running loop, so
  the RuntimeError branch was always the one that executed. Calls client.request
  directly now. Unused asyncio import removed.
- tests/gateway/test_feishu.py: add register_p2_customized_event to the mock
  EventDispatcher builder so the existing adapter test matches the new handler
  registration for drive.notice.comment_add_v1.
- scripts/release.py: map liujinkun@bytedance.com -> liujinkun2025 for
  contributor attribution on release notes.
2026-04-17 19:04:11 -07:00
liujinkun 85cdb04bd4 feat: add Feishu document comment intelligent reply with 3-tier access control
- Full comment handler: parse drive.notice.comment_add_v1 events, build
  timeline, run agent, deliver reply with chunking support.
- 5 tools: feishu_doc_read, feishu_drive_list_comments,
  feishu_drive_list_comment_replies, feishu_drive_reply_comment,
  feishu_drive_add_comment.
- 3-tier access control rules (exact doc > wildcard "*" > top-level >
  defaults) with per-field fallback. Config via
  ~/.hermes/feishu_comment_rules.json, mtime-cached hot-reload.
- Self-reply filter using generalized self_open_id (supports future
  user-identity subscriptions). Receiver check: only process events
  where the bot is the @mentioned target.
- Smart timeline selection, long text chunking, semantic text extraction,
  session sharing per document, wiki link resolution.

Change-Id: I31e82fd6355173dbcc400b8934b6d9799e3137b9
2026-04-17 19:04:11 -07:00
Teknium 9b14b76eb3 fix(wecom): bound req_id cache, revert undocumented is_group change, add tests
Follow-up to the cherry-picked contributor fix:

- Extract `_remember_chat_req_id()` and bound it at DEDUP_MAX_SIZE like
  `_reply_req_ids` — the unbounded dict would grow forever on a long-
  running gateway with many chats.
- Move the cache write to AFTER the group/DM policy check so we don't
  cache req_ids from blocked senders.
- Revert the undocumented `is_group` change: the contributor flipped
  `chattype == 'group'` to `bool(chatid)`, which wasn't mentioned in
  the PR description and weakens the signal (chattype is the explicit
  hint; relying on chatid presence assumes DMs never carry it). Keep
  the original check.
- Drop the defensive `getattr(self, '_last_chat_req_ids', {})` reads
  at both send sites — the attribute is initialized in __init__.
- Update `test_send_uses_passive_reply_stream_...` → `_markdown_...`
  to match the new msgtype, and add a new TestWeComZombieSessionFix
  class covering device_id presence in subscribe, per-chat req_id
  caching + bounding, blocked-sender cache exclusion, and the group
  APP_CMD_RESPONSE fallback path.
2026-04-17 19:03:29 -07:00
Devorun 2992802b35 fix(wecom): resolve WebSocket zombie sessions and group chat 600039 errors #11554 2026-04-17 19:03:29 -07:00
Teknium 04a0c3cb95 fix(config): preserve env refs when save_config rewrites config (#11892)
Co-authored-by: binhnt92 <84617813+binhnt92@users.noreply.github.com>
2026-04-17 19:03:26 -07:00
Teknium 8444f66890 feat(hermes model): add Configure auxiliary models UI to hermes model (#11891)
Previously users had to hand-edit config.yaml to route individual auxiliary
tasks (vision, compression, web_extract, etc.) to a specific provider+model.
Add a first-class picker reachable from the bottom of the existing `hermes
model` provider list.

Flow:
  hermes model
    → Configure auxiliary models...
      → <task picker: 9 tasks, shows current setting inline>
        → <provider picker: authenticated providers + auto + custom>
          → <model picker: curated list + live pricing>

The aux picker does NOT re-run credential/OAuth setup; users authenticate
providers through the normal `hermes model` flow, then route aux tasks to
them here.  `list_authenticated_providers()` gates the list to providers
the user has configured.

Also:
  - 'Cancel' entry relabeled 'Leave unchanged' (sentinel still 'cancel'
    internally, so dispatch logic is unchanged)
  - 'Reset all to auto' entry to bulk-clear aux overrides; preserves
    user-tuned timeout / download_timeout values
  - Adds `title_generation` task to DEFAULT_CONFIG.auxiliary — the task
    was called from agent/title_generator.py but was missing from defaults,
    so config-backed timeout overrides never worked for it

Co-authored-by: teknium1 <teknium@nousresearch.com>
2026-04-17 19:02:06 -07:00
Teknium bb85404b16 chore: add Sara Reynolds to AUTHOR_MAP 2026-04-17 18:58:29 -07:00
Sara Reynolds 8ab1aa2efc fix(gateway): fix discrepancies in gateway status 2026-04-17 18:58:29 -07:00
Xowiek 511ed4dacc fix(gateway): bypass active-session guard for gateway-handled slash commands 2026-04-17 18:58:03 -07:00
Michel Belleau d465fc5869 fix(skills): use frontmatter name in skills index instead of directory name
build_skills_system_prompt() was using the skill directory name (skill_name)
when appending to skills_by_category in all three code paths (snapshot cache,
cold filesystem scan, external dirs). This meant any skill whose directory name
differed from its frontmatter `name` field would appear under the wrong name in
the system prompt, causing LLM routing failures.

The snapshot entry already stores both skill_name (dir) and frontmatter_name
(declared); switch the three tuple appends to use frontmatter_name. Also fix
the external-dir dedup set (seen_skill_names) to track frontmatter names for
consistency with the local-skill tuples now stored under frontmatter_name.

Fixes #11777

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:56:37 -07:00
helix4u 016ae5c334 fix(kimi): force 0.6 on main chat path 2026-04-17 18:47:01 -07:00
Teknium 304fb921bf fix: two process leaks (agent-browser daemons, paste.rs sleepers) (#11843)
Both fixes close process leaks observed in production (18+ orphaned
agent-browser node daemons, 15+ orphaned paste.rs sleep interpreters
accumulated over ~3 days, ~2.7 GB RSS).

## agent-browser daemon leak

Previously the orphan reaper (_reap_orphaned_browser_sessions) only ran
from _start_browser_cleanup_thread, which is only invoked on the first
browser tool call in a process. Hermes sessions that never used the
browser never swept orphans, and the cross-process orphan detection
relied on in-process _active_sessions, which doesn't see other hermes
PIDs' sessions (race risk).

- Write <session>.owner_pid alongside the socket dir recording the
  hermes PID that owns the daemon (extracted into _write_owner_pid for
  direct testability).
- Reaper prefers owner_pid liveness over in-process _active_sessions.
  Cross-process safe: concurrent hermes instances won't reap each
  other's daemons. Legacy tracked_names fallback kept for daemons
  that predate owner_pid.
- atexit handler (_emergency_cleanup_all_sessions) now always runs
  the reaper, not just when this process had active sessions —
  every clean hermes exit sweeps accumulated orphans.

## paste.rs auto-delete leak

_schedule_auto_delete spawned a detached Python subprocess per call
that slept 6 hours then issued DELETE requests. No dedup, no tracking —
every 'hermes debug share' invocation added ~20 MB of resident Python
interpreters that stuck around until the sleep finished.

- Replaced the spawn with ~/.hermes/pastes/pending.json: records
  {url, expire_at} entries.
- _sweep_expired_pastes() synchronously DELETEs past-due entries on
  every 'hermes debug' invocation (run_debug() dispatcher).
- Network failures stay in pending.json for up to 24h, then give up
  (paste.rs's own retention handles the 'user never runs hermes again'
  edge case).
- Zero subprocesses; regression test asserts subprocess/Popen/time.sleep
  never appear in the function source (skipping docstrings via AST).

## Validation

|                              | Before        | After        |
|------------------------------|---------------|--------------|
| Orphan agent-browser daemons | 18 accumulated| 2 (live)     |
| paste.rs sleep interpreters  | 15 accumulated| 0            |
| RSS reclaimed                | -             | ~2.7 GB      |
| Targeted tests               | -             | 2253 pass    |

E2E verified: alive-owner daemons NOT reaped; dead-owner daemons
SIGTERM'd and socket dirs cleaned; pending.json sweep deletes expired
entries without spawning subprocesses.
2026-04-17 18:46:30 -07:00
helix4u 64b354719f Support browser CDP URL from config 2026-04-17 16:05:04 -07:00
brooklyn! e9b8ece103 Merge pull request #4692 from NousResearch/feat/ink-refactor
Feat/ink refactor
2026-04-17 18:02:37 -05:00
Teknium 3f43aec15d fix(tools): bound _read_tracker sub-containers + prune _completion_consumed (#11839)
Two accretion-over-time leaks that compound over long CLI / gateway
lifetimes.  Both were flagged in the memory-leak audit.

## file_tools._read_tracker

_read_tracker[task_id] holds three sub-containers that grew unbounded:

  read_history     set of (path, offset, limit) tuples — 1 per unique read
  dedup            dict of (path, offset, limit) → mtime — same growth pattern
  read_timestamps  dict of resolved_path → mtime — 1 per unique path

A CLI session uses one stable task_id for its lifetime, so these were
uncapped.  A 10k-read session accumulated ~1.5MB of tracker state that
the tool no longer needed (only the most recent reads are relevant for
dedup, consecutive-loop detection, and write/patch external-edit
warnings).

Fix: _cap_read_tracker_data() enforces hard caps on each container
after every add.  Defaults: read_history=500, dedup=1000,
read_timestamps=1000.  Eviction is insertion-order (Python 3.7+ dict
guarantee) for the dicts; arbitrary for the set (which only feeds
diagnostic summaries).

## process_registry._completion_consumed

Module-level set that recorded every session_id ever polled / waited /
logged.  No pruning.  Each entry is ~20 bytes, so the absolute leak is
small, but on a gateway processing thousands of background commands
per day the set grows until process exit.

Fix: _prune_if_needed() now discards _completion_consumed entries
alongside the session dict evictions it already performs (both the
TTL-based prune and the LRU-over-cap prune).  Adds a final
belt-and-suspenders pass that drops any dangling entries whose
session_id no longer appears in _running or _finished.

Tests: tests/tools/test_accretion_caps.py — 9 cases
  * Each container bound respected, oldest evicted
  * No-op when under cap (no unnecessary work)
  * Handles missing sub-containers without crashing
  * Live read_file_tool path enforces caps end-to-end
  * _completion_consumed pruned on TTL expiry
  * _completion_consumed pruned on LRU eviction
  * Dangling entries (no backing session) cleared

Broader suite: 3486 tests/tools + tests/cli pass.  The single flake
(test_alias_command_passes_args) reproduces on unchanged main — known
cross-test pollution under suite-order load.
2026-04-17 15:53:57 -07:00
Brooklyn Nicholson aa583cb14e Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-17 17:51:40 -05:00
Teknium 0a83187801 refactor(kimi): use _fixed_temperature_for_model helper in flush_memories
Replace the hardcoded 'kimi-for-coding' string check with the helper
from auxiliary_client so there is one source of truth for the list of
models with fixed-temperature contracts. Adding a new entry to
_FIXED_TEMPERATURE_MODELS now automatically covers flush_memories too.
2026-04-17 15:49:14 -07:00
helix4u 2b60478fc2 fix(kimi): force kimi-for-coding temperature to 0.6 2026-04-17 15:49:14 -07:00
Teknium c6fd2619f7 fix(gemini-cli): surface MODEL_CAPACITY_EXHAUSTED cleanly + drop retired gemma-4-26b (#11833)
Google-side 429 Code Assist errors now flow through Hermes' normal rate-limit
path (status_code on the exception, Retry-After preserved via error.response)
instead of being opaque RuntimeErrors. User sees a one-line capacity message
instead of a 500-char JSON dump.

Changes
- CodeAssistError grows status_code / response / retry_after / details attrs.
  _extract_status_code in error_classifier picks up status_code and classifies
  429 as FailoverReason.rate_limit, so fallback_providers triggers the same
  way it does for SDK errors. run_agent.py line ~10428 already walks
  error.response.headers for Retry-After — preserving the response means that
  path just works.
- _gemini_http_error parses the Google error envelope (error.status +
  error.details[].reason from google.rpc.ErrorInfo, retryDelay from
  google.rpc.RetryInfo). MODEL_CAPACITY_EXHAUSTED / RESOURCE_EXHAUSTED / 404
  model-not-found each produce a human-readable message; unknown shapes fall
  back to the previous raw-body format.
- Drop gemma-4-26b-it from hermes_cli/models.py, hermes_cli/setup.py, and
  agent/model_metadata.py — Google returned 404 for it today in local repro.
  Kept gemma-4-31b-it (capacity-constrained but not retired).

Validation
|                           | Before                         | After                                     |
|---------------------------|--------------------------------|-------------------------------------------|
| Error message             | 'Code Assist returned HTTP 429: {500 chars JSON}' | 'Gemini capacity exhausted for gemini-2.5-pro (Google-side throttle...)' |
| status_code on error      | None (opaque RuntimeError)     | 429                                       |
| Classifier reason         | unknown (string-match fallback) | FailoverReason.rate_limit                |
| Retry-After honored       | ignored                        | extracted from RetryInfo or header        |
| gemma-4-26b-it picker     | advertised (404s on Google)    | removed                                   |

Unit + E2E tests cover non-streaming 429, streaming 429, 404 model-not-found,
Retry-After header fallback, malformed body, and classifier integration.
Targeted suites: tests/agent/test_gemini_cloudcode.py (81 tests), full
tests/hermes_cli (2203 tests) green.

Co-authored-by: teknium1 <teknium@nousresearch.com>
2026-04-17 15:34:12 -07:00
Teknium d2206c69cc fix(qqbot): add back-compat for env var rename; drop qrcode core dep
Follow-up to WideLee's salvaged PR #11582.

Back-compat for QQ_HOME_CHANNEL → QQBOT_HOME_CHANNEL rename:
  - gateway/config.py reads QQBOT_HOME_CHANNEL, falls back to QQ_HOME_CHANNEL
    with a one-shot deprecation warning so users on the old name aren't
    silently broken.
  - cron/scheduler.py: _HOME_TARGET_ENV_VARS['qqbot'] now maps to the new
    name; _get_home_target_chat_id falls back to the legacy name via a
    _LEGACY_HOME_TARGET_ENV_VARS table.
  - hermes_cli/status.py + hermes_cli/setup.py: honor both names when
    displaying or checking for missing home channels.
  - hermes_cli/config.py: keep legacy QQ_HOME_CHANNEL[_NAME] in
    _EXTRA_ENV_KEYS so .env sanitization still recognizes them.

Scope cleanup:
  - Drop qrcode from core dependencies and requirements.txt (remains in
    messaging/dingtalk/feishu extras). _qqbot_render_qr already degrades
    gracefully when qrcode is missing, printing a 'pip install qrcode' tip
    and falling back to URL-only display.
  - Restore @staticmethod on QQAdapter._detect_message_type (it doesn't
    use self). Revert the test change that was only needed when it was
    converted to an instance method.
  - Reset uv.lock to origin/main; the PR's stale lock also included
    unrelated changes (atroposlib source URL, hermes-agent version bump,
    fastapi additions) that don't belong.

Verified E2E:
  - Existing user (QQ_HOME_CHANNEL set): gateway + cron both pick up the
    legacy name; deprecation warning logs once.
  - Fresh user (QQBOT_HOME_CHANNEL set): gateway + cron use new name,
    no warning.
  - Both set: new name wins on both surfaces.

Targeted tests: 296 passed, 4 skipped (qqbot + cron + hermes_cli).
2026-04-17 15:31:14 -07:00
WideLee 103beea7a6 fix(qqbot): fix test failures after package refactor
- Re-export _ssrf_redirect_guard from __init__.py
- Fix _parse_json @staticmethod using self._log_tag
- Update test_detect_message_type to call as instance method
- Fix mock.patch path for httpx.AsyncClient in adapter submodule
2026-04-17 15:31:14 -07:00
WideLee 287d3e12c7 chore: add author map 2026-04-17 15:31:14 -07:00
WideLee 6fd58e1e4a refactor(qqbot): replace log tags with self._log_tag 2026-04-17 15:31:14 -07:00
WideLee 235e6ecc0e refactor(qqbot): replace hardcoded log tags with self._log_tag and adjust STT log levels
- Remove @staticmethod from _detect_message_type, _convert_silk_to_wav,
  _convert_raw_to_wav, _convert_ffmpeg_to_wav so they can use self._log_tag
- Replace all remaining hardcoded "QQBot" log args with self._log_tag
- Downgrade STT routine flow logs (download, convert, success) from info to debug
- Keep warning level for actual failures (STT failed, ffmpeg error, empty transcript)
2026-04-17 15:31:14 -07:00
WideLee 1648e41c17 refactor(qqbot): change qrcode style 2026-04-17 15:31:14 -07:00
WideLee c4cdf3b861 refactor(qqbot): change setup method selection prompt_choice style 2026-04-17 15:31:14 -07:00
WideLee 02f5e3dc27 refactor(qqbot): use _log_tag with app_id in all logger calls for multi-instance disambiguation 2026-04-17 15:31:14 -07:00
WideLee b7d330211a fix(qqbot): simplify home channel prompt wording 2026-04-17 15:31:14 -07:00
WideLee a5f4d652d3 feat(qqbot): prompt to add scanned user to allow list and home channel during setup 2026-04-17 15:31:14 -07:00
WideLee 6358501915 refactor(qqbot): split qqbot.py into package & add QR scan-to-configure onboard flow
- Refactor gateway/platforms/qqbot.py into gateway/platforms/qqbot/ package:
  - adapter.py: core QQAdapter (unchanged logic, constants from shared module)
  - constants.py: shared constants (API URLs, timeouts, message types)
  - crypto.py: AES-256-GCM key generation and secret decryption
  - onboard.py: QR-code scan-to-configure API (create_bind_task, poll_bind_result)
  - utils.py: User-Agent builder, HTTP headers, config helpers
  - __init__.py: re-exports all public symbols for backward compatibility

- Add interactive QR-code setup flow in hermes_cli/gateway.py:
  - Terminal QR rendering via qrcode package (graceful fallback to URL)
  - Auto-refresh on QR expiry (up to 3 times)
  - AES-256-GCM encrypted credential exchange
  - DM security policy selection (pairing/allowlist/open)

- Update hermes_cli/setup.py to delegate to gateway's _setup_qqbot()
- Add qrcode>=7.4 dependency to pyproject.toml and requirements.txt
2026-04-17 15:31:14 -07:00
Teknium 31e7276474 fix(gateway): consolidate per-session cleanup; close SessionDB on shutdown (#11800)
Three closely-related fixes for shutdown / lifecycle hygiene.

1. _release_running_agent_state(session_key) helper
   ----------------------------------------------------
   Per-running-agent state lived in three dicts that drifted out of sync
   across cleanup sites:
     self._running_agents       — AIAgent per session_key
     self._running_agents_ts    — start timestamp per session_key
     self._busy_ack_ts          — last busy-ack timestamp per session_key

   Inventory before this PR:
     8 sites: del self._running_agents[key]
       — only 1 (stale-eviction) cleaned all three
       — 1 cleaned _running_agents + _running_agents_ts only
       — 6 cleaned _running_agents only

   Each missed entry was a (str, float) tuple per session per gateway
   lifetime — small, persistent, accumulates across thousands of
   sessions over months.  Per-platform leaks compounded.

   This change adds a single helper that pops all three dicts in
   lockstep, and replaces every bare 'del self._running_agents[key]'
   site with it.  Per-session state that PERSISTS across turns
   (_session_model_overrides, _voice_mode, _pending_approvals,
   _update_prompt_pending) is intentionally NOT touched here — those
   have their own lifecycles tied to user actions, not turn boundaries.

2. _running_agents_ts cleared in _stop_impl
   ----------------------------------------
   Was being missed alongside _running_agents.clear(); now included.

3. SessionDB close() in _stop_impl
   ---------------------------------
   The SQLite WAL write lock stayed held by the old gateway connection
   until Python actually exited — causing 'database is locked' errors
   when --replace launched a new gateway against the same file.  We
   now explicitly close both self._db and self.session_store._db
   inside _stop_impl, with try/except so a flaky close on one doesn't
   block the other.

Tests
-----
tests/gateway/test_session_state_cleanup.py — 10 cases covering:
  * helper pops all three dicts atomically
  * idempotent on missing/empty keys
  * preserves other sessions
  * tolerates older runners without _busy_ack_ts attribute
  * thread-safe under concurrent release
  * regression guard: scans gateway/run.py and fails if a future
    contributor reintroduces 'del self._running_agents[...]'
    outside docstrings
  * SessionDB close called on both holders during shutdown
  * shutdown tolerates missing session_store
  * shutdown tolerates close() raising on one db (other still closes)

Broader gateway suite: 3108 passed (vs 3100 on baseline) — failure
delta is +8 net passes; the 10 remaining failures are pre-existing
cross-test pollution / missing optional deps (matrix needs olm,
signal/telegram approval flake, dingtalk Mock wiring), all reproduce
on stashed baseline.
2026-04-17 15:18:23 -07:00
Teknium 036dacf659 feat(telegram): auto-wrap markdown tables in code blocks (#11794)
Telegram's MarkdownV2 has no table syntax — pipes get backslash-escaped
and tables render as noisy unaligned text.  format_message now detects
GFM-style pipe tables (header row + delimiter row + optional body) and
wraps them in ``` fences before the existing MarkdownV2 conversion runs.
Telegram renders fenced code blocks as monospace preformatted text with
columns intact.

Tables already inside an existing code block are left alone.  Plain
prose with pipes, lone '---' horizontal rules, and non-table content
are unaffected.

Closes the recurring community request to stop having to ask the agent
to re-render tables as code blocks manually.
2026-04-17 14:27:26 -07:00
Teknium 3207b9bda0 test: speed up slow tests (backoff + subprocess + IMDS network) (#11797)
Cuts shard-3 local runtime in half by neutralizing real wall-clock
waits across three classes of slow test:

## 1. Retry backoff mocks

- tests/run_agent/conftest.py (NEW): autouse fixture mocks
  jittered_backoff to 0.0 so the `while time.time() < sleep_end`
  busy-loop exits immediately. No global time.sleep mock (would
  break threading tests).
- test_anthropic_error_handling, test_413_compression,
  test_run_agent_codex_responses, test_fallback_model: per-file
  fixtures mock time.sleep / asyncio.sleep for retry / compression
  paths.
- test_retaindb_plugin: cap the retaindb module's bound time.sleep
  to 0.05s via a per-test shim (background writer-thread retries
  sleep 2s after errors; tests don't care about exact duration).
  Plus replace arbitrary time.sleep(N) waits with short polling
  loops bounded by deadline.

## 2. Subprocess sleeps in production code

- test_update_gateway_restart: mock time.sleep. Production code
  does time.sleep(3) after `systemctl restart` to verify the
  service survived. Tests mock subprocess.run \u2014 nothing actually
  restarts \u2014 so the wait is dead time.

## 3. Network / IMDS timeouts (biggest single win)

- tests/conftest.py: add AWS_EC2_METADATA_DISABLED=true plus
  AWS_METADATA_SERVICE_TIMEOUT=1 and ATTEMPTS=1. boto3 falls back
  to IMDS (169.254.169.254) when no AWS creds are set. Any test
  hitting has_aws_credentials() / resolve_aws_auth_env_var() (e.g.
  test_status, test_setup_copilot_acp, anything that touches
  provider auto-detect) burned ~2-4s waiting for that to time out.
- test_exit_cleanup_interrupt: explicitly mock
  resolve_runtime_provider which was doing real network auto-detect
  (~4s). Tests don't care about provider resolution \u2014 the agent
  is already mocked.
- test_timezone: collapse the 3-test "TZ env in subprocess" suite
  into 2 tests by checking both injection AND no-leak in the same
  subprocess spawn (was 3 \u00d7 3.2s, now 2 \u00d7 4s).

## Validation

| Test | Before | After |
|---|---|---|
| test_anthropic_error_handling (8 tests) | ~80s | ~15s |
| test_413_compression (14 tests) | ~18s | 2.3s |
| test_retaindb_plugin (67 tests) | ~13s | 1.3s |
| test_status_includes_tavily_key | 4.0s | 0.05s |
| test_setup_copilot_acp_skips_same_provider_pool_step | 8.0s | 0.26s |
| test_update_gateway_restart (5 tests) | ~18s total | ~0.35s total |
| test_exit_cleanup_interrupt (2 tests) | 8s | 1.5s |
| **Matrix shard 3 local** | **108s** | **50s** |

No behavioral contract changed \u2014 tests still verify retry happens,
service restart logic runs, etc.; they just don't burn real seconds
waiting for it.

Supersedes PR #11779 (those changes are included here).
2026-04-17 14:21:22 -07:00
Teknium eb07c05646 fix(gateway): prune stale SessionStore entries to bound memory + disk (#11789)
SessionStore._entries grew unbounded.  Every unique
(platform, chat_id, thread_id, user_id) tuple ever seen was kept in
RAM and rewritten to sessions.json on every message.  A Discord bot
in 100 servers x 100 channels x ~100 rotating users accumulates on
the order of 10^5 entries after a few months; each sessions.json
write becomes an O(n) fsync.  Nothing trimmed this — there was no
TTL, no cap, no eviction path.

Changes
-------
* SessionStore.prune_old_entries(max_age_days) — drops entries whose
  updated_at is older than the cutoff.  Preserves:
    - suspended entries (user paused them via /stop for later resume)
    - entries with an active background process attached
  Pruning is functionally identical to a natural reset-policy expiry:
  SQLite transcript stays, session_key -> session_id mapping dropped,
  returning user gets a fresh session.

* GatewayConfig.session_store_max_age_days (default 90; 0 disables).
  Serialized in to_dict/from_dict, coerced from bad types / negatives
  to safe defaults.  No migration needed — missing field -> 90 days.

* _session_expiry_watcher calls prune_old_entries once per hour
  (first tick is immediate).  Uses the existing watcher loop so no
  new background task is created.

Why not more aggressive
-----------------------
90 days is long enough that legitimate long-idle users (seasonal,
vacation, etc.) aren't surprised — pruning just means they get a
fresh session on return, same outcome they'd get from any other
reset-policy trigger.  Admins can lower it via config; 0 disables.

Tests
-----
tests/gateway/test_session_store_prune.py — 17 cases covering:
  * entry age based on updated_at, not created_at
  * max_age_days=0 disables; negative coerces to 0
  * suspended + active-process entries are skipped
  * _save fires iff something was removed
  * disk JSON reflects post-prune state
  * thread safety against concurrent readers
  * config field roundtrips + graceful fallback on bad values
  * watcher gate logic (first tick prunes, subsequent within 1h don't)

119 broader session/gateway tests remain green.
2026-04-17 13:48:49 -07:00
Teknium f362083c64 fix(providers): complete NVIDIA NIM parity with other providers
Follow-up on the native NVIDIA NIM provider salvage. The original PR wired
PROVIDER_REGISTRY + HERMES_OVERLAYS correctly but missed several touchpoints
required for full parity with other OpenAI-compatible providers (xai,
huggingface, deepseek, zai).

Gaps closed:

- hermes_cli/main.py:
  - Add 'nvidia' to the _model_flow_api_key_provider dispatch tuple so
    selecting 'NVIDIA NIM' in `hermes model` actually runs the api-key
    provider flow (previously fell through silently).
  - Add 'nvidia' to `hermes chat --provider` argparse choices so the
    documented test command (`hermes chat --provider nvidia --model ...`)
    parses successfully.

- hermes_cli/config.py: Register NVIDIA_API_KEY and NVIDIA_BASE_URL in
  OPTIONAL_ENV_VARS so setup wizard can prompt for them and they're
  auto-added to the subprocess env blocklist.

- hermes_cli/doctor.py: Add NVIDIA NIM row to `_apikey_providers` so
  `hermes doctor` probes https://integrate.api.nvidia.com/v1/models.

- hermes_cli/dump.py: Add NVIDIA_API_KEY → 'nvidia' mapping for
  `hermes dump` credential masking.

- tests/tools/test_local_env_blocklist.py: Extend registry_vars fixture
  with NVIDIA_API_KEY to verify it's blocked from leaking into subprocesses.

- agent/model_metadata.py: Add 'nemotron' → 131072 context-length entry
  so all Nemotron variants get 128K context via substring match (rather
  than falling back to MINIMUM_CONTEXT_LENGTH).

- hermes_cli/models.py: Fix hallucinated model ID
  'nvidia/nemotron-3-nano-8b-a4b' → 'nvidia/nemotron-3-nano-30b-a3b'
  (verified against live integrate.api.nvidia.com/v1/models catalog).
  Expand curated list from 5 to 9 agentic models mapping to OpenRouter
  defaults per provider-guide convention: add qwen3.5-397b-a17b,
  deepseek-v3.2, llama-3.3-nemotron-super-49b-v1.5, gpt-oss-120b.

- cli-config.yaml.example: Document 'nvidia' provider option.

- scripts/release.py: Map asurla@nvidia.com → anniesurla in AUTHOR_MAP
  for CI attribution.

E2E verified: `hermes chat --provider nvidia ...` now reaches NVIDIA's
endpoint (returns 401 with bogus key instead of argparse error);
`hermes doctor` detects NVIDIA NIM when NVIDIA_API_KEY is set.
2026-04-17 13:47:46 -07:00
asurla 3b569ff576 feat(providers): add native NVIDIA NIM provider
Adds NVIDIA NIM as a first-class provider: ProviderConfig in
auth.py, HermesOverlay in providers.py, curated models
(Nemotron plus other open source models hosted on
build.nvidia.com), URL mapping in model_metadata.py, aliases
(nim, nvidia-nim, build-nvidia, nemotron), and env var tests.

Docs updated: providers page, quickstart table, fallback
providers table, and README provider list.
2026-04-17 13:47:46 -07:00
Brooklyn Nicholson bd09e42eac Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-17 15:44:57 -05:00
Teknium cc3aa76675 build(deps): add qrcode to dingtalk + feishu extras (parity with messaging) (#11627)
#4b1567f4 (anthhub) added qrcode to the messaging extra for Weixin's
QR login. The same package is needed by:

  * hermes_cli/dingtalk_auth.py — QR device-flow auth shipped in #11574
  * gateway/platforms/feishu.py:3962 — Feishu QR login

These extras are independent of [messaging] (users can install
hermes-agent[dingtalk] or hermes-agent[feishu] without [messaging]),
so the dep needs to be declared on each.

Pin matches anthhub's choice (>=7.0,<8) for consistency. The all
extra inherits from all three, so it picks up qrcode transitively.

Adds parallel tests to tests/test_project_metadata.py — same shape
as test_messaging_extra_includes_qrcode_for_weixin_setup.

Refs #9431.
2026-04-17 13:31:53 -07:00
Teknium 2ff1ef6ae6 fix(surrogates): sanitize reasoning/reasoning_content/reasoning_details fields (#11628)
Byte-level reasoning models (xiaomi/mimo-v2-pro, kimi, glm) can emit lone
surrogates in reasoning output. The proactive sanitizer walked content/
name/tool_calls but not extra fields like reasoning or the nested
reasoning_details array. Surrogates in those fields survived the
proactive pass, crashed json.dumps() in the OpenAI SDK, and the recovery
block's _sanitize_messages_surrogates(messages) call also didn't check
those fields — so 'found' was False, no retry happened, and after 3
attempts the user saw:

  API call failed after 3 retries. 'utf-8' codec can't encode characters
  in position N-M: surrogates not allowed

Changes:
- _sanitize_messages_surrogates: walk any extra string fields (reasoning,
  reasoning_content, etc.) and recurse into nested dict/list values
  (reasoning_details). Mirrors _sanitize_messages_non_ascii coverage
  added in PR #10537.
- _sanitize_structure_surrogates: new recursive walker, mirror of
  _sanitize_structure_non_ascii but for surrogate recovery.
- UnicodeEncodeError recovery block: also sanitize api_messages,
  api_kwargs, and prefill_messages (not just the canonical messages
  list — the API-copy carries reasoning_content transformed from
  reasoning and that's what the SDK actually serializes). Always
  retry on detected surrogate errors, not only when we found
  something to strip — gate on error type per PR #10537's pattern.

Tests: extended tests/cli/test_surrogate_sanitization.py with
coverage for reasoning, reasoning_content, reasoning_details (flat
and deeply nested), structure walker, and an integration case that
reproduces the exact api_messages shape that was crashing.
2026-04-17 13:30:47 -07:00
Teknium 1229d8855c fix: remove misleading model.max_tokens suggestion from thinking-exhausted error (#11626)
The 'Thinking Budget Exhausted' user-facing error message advised users to
'set model.max_tokens in config.yaml'. That config key is documented but
intentionally not wired through to the API call in CLI/gateway paths — we
omit max_tokens by default so the inference server uses its full output
budget (llama-server -1=infinity, vLLM max_model_len-prompt_len, etc.).

Users followed the suggestion, saw no change, and kept filing bugs (see
closed #4404, #10917, #6955 and PRs #5001/#6080/#6446/#6707/#7075/#8804/
#10924/#11173/#11268 — all reporting the same misdirection).

Replace the misleading suggestion with an actionable one: switch models
via /model. Lowering reasoning effort remains the primary remediation.
2026-04-17 13:29:54 -07:00
Henkey d49126b987 fix(release): map HenkDz contributor email 2026-04-17 13:29:26 -07:00
Henkey cb883f9e97 fix(acp): improve zed integration 2026-04-17 13:29:26 -07:00
Brooklyn Nicholson d5b9db8b4a Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-17 15:13:36 -05:00
Brooklyn Nicholson 6a37802476 chore: uptick 2026-04-17 15:13:33 -05:00
Teknium d0e1388ca9 fix(tests): make AIAgent constructor calls self-contained (#11755)
* fix(tests): make AIAgent constructor calls self-contained (no env leakage)

Tests in tests/run_agent/ were constructing AIAgent() without passing
both api_key and base_url, then relying on leaked state from other
tests in the same xdist worker (or process-level env vars) to keep
provider resolution happy. Under hermetic conftest + pytest-split,
that state is gone and the tests fail with 'No LLM provider configured'.

Fix: pass both api_key and base_url explicitly on 47 AIAgent()
construction sites across 13 files. AIAgent.__init__ with both set
takes the direct-construction path (line 960 in run_agent.py) and
skips the resolver entirely.

One call site (test_none_base_url_passed_as_none) left alone — that
test asserts behavior for base_url=None specifically.

This is a prerequisite for any future matrix-split or stricter
isolation work, and lands cleanly on its own.

Validation:
- tests/run_agent/ full: 760 passed, 0 failed (local)
- Previously relied on cross-test pollution; now self-contained

* fix(tests): update opencode-go model order assertion to match kimi-k2.5-first

commit 78a74bb promoted kimi-k2.5 to first position in model suggestion
lists but didn't update this test, which has been failing on main since.
Reorder expected list to match the new canonical order.
2026-04-17 12:32:03 -07:00
kshitij 78a74bb097 feat: promote kimi-k2.5 to first position in all model suggestion lists (#11745)
Move moonshotai/kimi-k2.5 to position #1 in every model picker list:
- OPENROUTER_MODELS (with 'recommended' tag)
- _PROVIDER_MODELS: nous, kimi-coding, opencode-zen, opencode-go, alibaba, huggingface
- _model_flow_kimi() Coding Plan model list in main.py

kimi-coding-cn and moonshot lists already had kimi-k2.5 first.
2026-04-17 12:05:22 -07:00
Brooklyn Nicholson bedbeebbc8 feat(tui): interleave tool rows into live assistant turns
Live turn rendering used to show the streaming assistant text as one
blob with tool calls pooled in a separate section below, so the live
view drifted from the reload view (which threads tool rows inline via
toTranscriptMessages). Model now mirrors reload:

- turnStore gains streamSegments (completed assistant chunks, each
  with any tool rows that landed between its predecessor and itself)
  and streamPendingTools (tool rows waiting for the next chunk)
- turnController.flushStreamingSegment() seals the current bufRef into
  a segment when a new tool.start fires; pending tools get attached to
  that next chunk so order matches reload hydration
- recordMessageComplete returns finalMessages instead of one payload,
  so appendMessage gets the same shape for live-ending turns as for
  reloaded ones
- appLayout renders segments before the progress/streaming area, and
  the streaming message + pending-tools fallback carry whatever tools
  arrived after the last assistant chunk
2026-04-17 11:33:29 -05:00
Brooklyn Nicholson f53250b5e1 fix(tui): tighten /resume render, follow-up to 42721dbe
- useVirtualHistory: track last-seen ScrollBox metrics in a ref inside
  the post-layout effect and bump ver when sticky/top/vp change — the
  subscribe-based rearm was sufficient for fresh clicks but not for the
  "hydrated mid-commit, measured empty, then metrics settle" path where
  nothing re-triggered the hook until the next unrelated keystroke
- useSessionLifecycle: resume scrollToBottom from queueMicrotask to
  setTimeout(..., 0) so the fresh transcript has a full task turn to
  commit + measure before we try to land at the newest content
2026-04-17 11:33:14 -05:00
Brooklyn Nicholson 00591e3801 chore: fmt 2026-04-17 11:06:25 -05:00
Brooklyn Nicholson be768db627 fix: long history session thingy 2026-04-17 11:05:23 -05:00
Brooklyn Nicholson 42721dbe1c fix(tui): big-session /resume now renders without first keystroke
useVirtualHistory set up its useSyncExternalStore subscription during
the first render, when scrollRef.current was still null (the ScrollBox
ref attaches during commit, after render). Its useCallback for
subscribe had a stable scrollRef identity as its only dep, so it never
re-subscribed once the ref actually attached — the hook stayed stuck
with vp=0, top=0, no scroll subscription. Small sessions fit entirely
in cold-start so you didn't notice; big /resume sessions got sliced to
the last 40 items with a huge topSpacer and the viewport sat on empty
space until some unrelated state change (e.g. a keystroke) re-rendered
and finally read a real vp.

- flip a hasScrollRef flag in useLayoutEffect once the ref attaches and
  add it to the subscribe useCallback deps so useSyncExternalStore
  rearms with a real subscription
- on resume, scrollToBottom() after history hydrates so the ScrollBox
  lands at the newest messages instead of scrollTop=0 (stickyScroll
  doesn't auto-engage on the initial empty→full dump)
2026-04-17 11:04:29 -05:00
Brooklyn Nicholson 8f553a55b2 chore(tui): fix eslint/prettier nits from npm run fix
- drop inline `import()` type annotation in useSessionLifecycle (import
  `PanelSection` at the top like everything else)
- include `panel` and `session.resumeById` in the useMainApp useMemo
  deps now that the event handler depends on them
- wrap the derived `selected` range in a useMemo so it has stable
  identity and stops invalidating the TextInput `rendered` memo every
  render
- prettier re-sorting of a couple of export/import lines
2026-04-17 11:00:15 -05:00
Brooklyn Nicholson a82097e7a2 feat(tui): /model and /setup slash commands with in-place CLI handoff
- hermes-ink: export `withInkSuspended()` + `useExternalProcess()` that
  pause/resume Ink around an arbitrary external process (built on the
  existing enterAlternateScreen/exitAlternateScreen plumbing)
- tui: `launchHermesCommand(args)` spawns the `hermes` binary with
  inherited stdio, with `HERMES_BIN` override for non-standard launches
- tui: `/model` and `/setup` slash commands invoke the CLI wizards
  in-place, then re-preflight `setup.status` and auto-start a session on
  success — no more exit-and-relaunch to finish first-run setup
- setup panel now advertises those slashes instead of only pointing
  users back at the shell
2026-04-17 10:58:18 -05:00
Brooklyn Nicholson 0dd5055d59 fix(tui): first-run setup preflight + actionable no-provider panel
- tui_gateway: new `setup.status` RPC that reuses CLI's
  `_has_any_provider_configured()`, so the TUI can ask the same question
  the CLI bootstrap asks before launching a session
- useSessionLifecycle: preflight `setup.status` before both `newSession`
  and `resumeById`, and render a clear "Setup Required" panel when no
  provider is configured instead of booting a session that immediately
  fails with `agent init failed`
- createGatewayEventHandler: drop duplicate startup resume logic in
  favor of the preflighted `resumeById`, and special-case the
  no-provider agent-init error as a last-mile fallback to the same
  setup panel
- add regression tests for both paths
2026-04-17 10:58:01 -05:00
Brooklyn Nicholson 5b386ced71 fix(tui): approval flow + input ergonomics + selection perf
- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/
  HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of
  silently falling through the CLI input() path and auto-denying
- approval UX: dedicated PromptZone between transcript and composer, safer
  defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line,
  outcome footer under the cost row
- text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed
  (fixes Ctrl+Backspace at cursor 0 inserting literal w)
- hermes-ink selection: swap synchronous onRender() for throttled
  scheduleRender() on drag, and only notify React subscribers on presence
  change — no more per-cell paint/subscribe spam
- useConfigSync: silence config.get polling failures instead of surfacing
  'error: timeout: config.get' in the transcript
2026-04-17 10:37:48 -05:00
Brooklyn Nicholson 0219da9626 chore: uptick 2026-04-17 09:47:19 -05:00
Brooklyn Nicholson 1f37ef2fd1 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-17 08:59:33 -05:00
Brooklyn Nicholson 5435287dec chore: uptick 2026-04-16 22:35:45 -05:00
Brooklyn Nicholson 41d3d7afb7 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 22:35:27 -05:00
Brooklyn Nicholson 39231f29c6 refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC
Full codebase pass using the /clean doctrine (KISS/DRY, no one-off
helpers, no variables-used-once, pure functional where natural,
inlined obvious one-liners, killed dead exports, narrowed types,
spaced JSX). All contracts preserved — no RPC method, event name,
or exported type shape changed.

app/ — 15 files, -134 LOC
- inlined 4 one-off helpers (titleCase, isLong, statusToneFrom,
  focusOutside predicate)
- stores to arrow-const style (buildUiState, buildTurnState,
  buildOverlayState plus get/patch/reset triplets)
- functional slash/registry byName map (flatMap over for-loops)
- dropped dead param `live` in cancelOverlayFromCtrlC
- DRY'd duplicate shift() call in scrollWithSelection
- consolidated sections.push calls in /help

components/ — 12 files, -40 LOC
- extracted inline prop types to interfaces at file bottom (13×)
- inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint)
- promoted HEART_COLORS + OPTS/LABELS to module scope
- JSX sibling spacing across 9 files
- un-shadowed `raw` in textInput
- components/thinking.tsx + components/markdown.tsx untouched
  (structurally load-bearing / edge-case-heavy)

config content domain protocol/ — 8 files, -77 LOC
- tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand,
  hasInterpolation — dropped stateful lastIndex dance)
- dead export ParsedSlashCommand removed
- MODES narrowed to `as const`, `.find(m => m === s)` replaces
  `.includes() ? (as cast) : null`
- fortunes.ts hash via reduce
- fmtDuration ternary chain
- inlined aboveViewport predicate in viewport.ts

hooks/ + lib/ — 9 files, -38 LOC
- ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module
  scope (no more eslint-disable no-control-regex)
- compactPreview/edgePreview/thinkingPreview → ternary arrows
- useCompletion: hoisted pathReplace, moved stale-ref guard earlier
- useInputHistory: dropped useCallback wrapper (append is stable)
- useVirtualHistory: replaced 4× any with unknown + narrow
  MeasuredNode interface + one cast site

root TS — 3 files, -63 LOC
- banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex,
  artWidth via reduce
- gatewayClient.ts: resolvePython candidate list collapse, inlined
  one-branch guards in dispatch/pushLog/drain/request
- types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq
  members

eslint config
- disabled react-hooks/exhaustive-deps on packages/hermes-ink/**
  (compiled by react/compiler, deps live in $[N] memo arrays that
  eslint can't introspect) and removed the now-orphan in-file
  disable directive in ScrollBox.tsx

fixes (not from the cleaner pass)
- useComposerState: unlinkSync(file) + try/catch → rmSync(file,
  { force: true }) — kills the no-empty lint error and is more
  idiomatic
- useConfigSync: added setBellOnComplete + setVoiceEnabled to the
  two useEffect dep arrays (they're stable React setState setters;
  adding is safe and silences exhaustive-deps)

verification
- npx eslint src/ packages/ → 0 errors, 0 warnings
- npm run type-check → clean
- npm test → 50/50
- npm run build → 394.8kb ink-bundle.js, 11ms esbuild
- pytest tests/tui_gateway/ tests/test_tui_gateway_server.py
  tests/hermes_cli/test_tui_resume_flow.py
  tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
Brooklyn Nicholson c730ab8ad7 chore: fmt 2026-04-16 21:09:50 -05:00
Brooklyn Nicholson c74017f405 fix(tui): sticky prompt correctness + scrollbar re-render thrash
Sticky prompt:
The loop was skipping `first` (the first row in the viewport) when
looking for a user message scrolled above the top edge. If `first`
itself was a user row that had just ticked above the viewport, we'd
fall through the early-return guard (`role === 'user' && !above`),
then walk from `first - 1` backward — never rechecking `first`, never
finding anything, returning '' and leaving the sticky empty. This is
why it felt "stuck" at the start: one-turn sessions with the user row
exactly at/near the top never surfaced the breadcrumb.

Collapsed the two branches into one loop starting at `first`: nearest
user wins — still-on-screen → empty (redundant to echo), already
above → text. Same semantics, covers the gap.

Scrollbar:
`useSyncExternalStore` snapshot was `scrollTop:vp:scrollHeight` —
scrollHeight ticks up by ~1 row on every streamed chunk, forcing a
re-render per chunk. Quantized snapshot to the displayed values
(`thumbTop:thumbSize:vp`) so we only re-render when the bar actually
changes. Drops render count per turn by ~100x during streaming and
stops the "constantly resizes" flicker.
2026-04-16 21:07:19 -05:00
Brooklyn Nicholson 40f2368875 fix(tui): ungate reasoning events so the Thinking panel shows live tokens
The gateway was gating `reasoning.delta` and `reasoning.available`
behind `_reasoning_visible(sid)` (true iff `display.show_reasoning:
true` or `tool_progress_mode: verbose`). With the default config,
neither was true — so reasoning events never reached the TUI,
`turn.reasoning` stayed empty, `reasoningTokens` stayed 0, and the
Thinking expander showed no token label for the whole turn. Tools
still reported tokens because `tool.start` had no such gate.

Then `message.complete` fired with `payload.reasoning` populated, the
TUI saved it into `msg.thinking`, and the finalized row's expander
sprouted "~36 tokens" post-hoc. That's the "tokens appear after the
turn" jank.

Remove the gate on emission. The TUI is responsible for whether to
display reasoning content (detailsMode + collapsed expander already
handle that). Token counting becomes continuous throughout the turn,
matching how tools work.

Also dropped the now-unused `_reasoning_visible` and
`_session_show_reasoning` helpers. `show_reasoning` config key stays
in place — it's still toggled via `/reasoning show|hide` and read
elsewhere for potential future TUI-side gating.
2026-04-16 20:56:47 -05:00
Brooklyn Nicholson 319aabbb80 refactor(tui): wrap progress panel + streaming body in StreamingAssistant
Two improvements:

1. The progress ToolTrail and the streaming MessageLine were two
   sibling JSX blocks in appLayout with hand-rolled margin glue
   between them. Extracted into `<StreamingAssistant>`, a single
   component that owns both the trail and the streaming body plus
   the 1-row gap between them. appLayout just hands it `progress`
   and theme; the layout logic lives in one place, matching the
   mental model that these two pieces are one live assistant turn.

2. Thinking token label was hidden when `reasoningTokens === 0` even
   if the live reasoning text was already populated (the
   scheduleReasoning timer hadn't ticked, or the model sent no
   reasoning but the text was coming in via reasoning.delta).
   Changed the tokenCount fallback from `reasoningTokens !==
   undefined ? reasoningTokens : estimate` to `reasoningTokens > 0 ?
   ... : estimate` so the label appears the moment text exists.
2026-04-16 20:49:41 -05:00
Brooklyn Nicholson 26f3a05c9c fix(tui): don't clobber busy on the progress panel during streaming
`appLayout` was passing `busy={ui.busy && !progress.streaming}` into
ToolTrail, so the moment `message.delta` fired and streaming began,
the panel internally saw `busy=false`. With the prior fix in place
(hasThinking = !!cot || reasoningActive || busy), that flipped
hasThinking to false and the Thinking expander vanished mid-turn —
reappearing only after message.complete when the finalized row
rendered with its own internal expander.

The `!progress.streaming` override was a defensive guard against the
panel implying "still thinking" once the response text was streaming.
But that's already handled inside ToolTrail — `streaming` prop on the
Thinking component uses `busy && reasoningStreaming`, and
reasoningStreaming is already falsey once recordMessageDelta calls
endReasoningPhase.

Pass plain `busy={ui.busy}`. Panel stays up start-to-finish; handoff
to the finalized-message row is continuous.
2026-04-16 20:39:02 -05:00
Brooklyn Nicholson 15096903c7 fix(tui): keep the newline above the streaming assistant text
Finalized assistant messages rendered the thinking/tools trail inside
MessageLine with marginBottom=1 before the response body — giving a
clean blank line above the text. The streaming path rendered the
progress ToolTrail and the streaming MessageLine as two separate
siblings with no margin between, so the in-progress response butted
right up against the thinking panel. That's the "newline appears
after it's done" jank.

Wrap the streaming MessageLine in a Box with marginTop=1 whenever the
progress area is visible above it. Same spacing as the finalized
version, continuous through the handoff.
2026-04-16 20:35:46 -05:00
Brooklyn Nicholson 26859e3fcb fix(tui): keep the Thinking expander visible for the whole turn
Previously `hasThinking = !!cot || reasoningActive || (busy && !hasTools)`
so the moment a tool started streaming (`hasTools` → true) the expander
vanished mid-turn. If the model also produced no `reasoning.delta`
events (reasoning-less models, or reasoning arriving after tools), the
whole turn ran with no Thinking row — then `message.complete`
populated `msg.thinking` from the payload's post-hoc reasoning trace
and the expander suddenly appeared in the transcript AFTER the turn.

Drop the `!hasTools` restriction. The Thinking row now anchors for the
entire `busy` window; tools and thinking coexist as sibling sections
(they already did — the exclusion was a UX mistake). Reasoning-less
models show a dim empty header; streaming models show live content;
tool-interleaved turns keep the anchor visible throughout.
2026-04-16 20:27:06 -05:00
Brooklyn Nicholson aedc767c66 feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.

Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).

Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00
Brooklyn Nicholson 23212d6b40 docs: kill "PT" shorthand — say "classic (prompt_toolkit) CLI"
"PT" was internal shorthand for prompt_toolkit that leaked into
AGENTS.md and the TUI post-mortem. Spell it out.

- AGENTS.md: "PT CLI" → "classic (prompt_toolkit) CLI"
- docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md: both hits
2026-04-16 19:39:09 -05:00
Brooklyn Nicholson 7ffefc2d6c docs(tui): rename "Ink TUI" to just "TUI" throughout user-facing surfaces
"Ink" is the React reconciler — implementation detail, not branding.
Consistent naming: the classic CLI is the CLI, the new one is the TUI.

Updated docs: user-guide/tui.md, user-guide/cli.md cross-link, quickstart,
cli-commands reference, environment-variables reference.

Updated code: main.py --tui help text, server.py user-visible setup
error, AGENTS.md "TUI Architecture" section.

Kept "Ink" only where it is literally the library (hermes-ink internal
source comments, AGENTS.md tree note flagging ui-tui/ as a React/Ink dir).
2026-04-16 19:38:21 -05:00
Brooklyn Nicholson 2812bfe5b9 docs(tui): add Ink TUI user guide + cross-link from CLI docs
New primary guide at `user-guide/tui.md` covering launch, requirements,
keybindings, slash commands, status line, configuration, sessions, and
the revert path. Matches the voice of `user-guide/cli.md`.

Cross-links:
- `user-guide/cli.md`: tip callout pointing readers at the Ink TUI
- `getting-started/quickstart.md`: shows both `hermes` and `hermes --tui`
  under "Start Chatting" so first-run users know they have the choice
- `reference/environment-variables.md`: new "Interface" section with
  `HERMES_TUI` and `HERMES_TUI_DIR`
- `reference/cli-commands.md`: `--tui` and `--dev` added to global options

Sidebar: `user-guide/tui` slotted right after `user-guide/cli`.
2026-04-16 19:29:18 -05:00
Brooklyn Nicholson ca30803d89 chore(tui): strip noise comments 2026-04-16 19:14:05 -05:00
Brooklyn Nicholson 7f1204840d test(tui): fix stale mocks + xdist flakes in TUI test suite
All 61 TUI-related tests green across 3 consecutive xdist runs.

tests/tui_gateway/test_protocol.py:
- rename `get_messages` → `get_messages_as_conversation` on mock DB (method
  was renamed in the real backend, test was still stubbing the old name)
- update tool-message shape expectation: `{role, name, context}` matches
  current `_history_to_messages` output, not the legacy `{role, text}`

tests/hermes_cli/test_tui_resume_flow.py:
- `cmd_chat` grew a first-run provider-gate that bailed to "Run: hermes
  setup" before `_launch_tui` was ever reached; 3 tests stubbed
  `_resolve_last_session` + `_launch_tui` but not the gate
- factored a `main_mod` fixture that stubs `_has_any_provider_configured`,
  reused by all three tests

tests/test_tui_gateway_server.py:
- `test_config_set_personality_resets_history_and_returns_info` was flaky
  under xdist because the real `_write_config_key` touches
  `~/.hermes/config.yaml`, racing with any other worker that writes
  config. Stub it in the test.
2026-04-16 19:07:49 -05:00
Brooklyn Nicholson dd2ec6bfa0 chore: uptick 2026-04-16 18:57:56 -05:00
Brooklyn Nicholson 3746c60439 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 18:25:49 -05:00
Brooklyn Nicholson 727f0eaf74 refactor(tui): clean up touched files — DRY, KISS, functional
Python (tui_gateway/server.py):
- hoist `_wait_agent` next to `_sess` so `_sess` no longer forward-refs
- simplify `_wait_agent`: `ready.wait()` already returns True when set,
  no separate `.is_set()` check, collapse two returns into one expr
- factor `_sess_nowait` for handlers that don't need the agent (currently
  `terminal.resize` + `input.detect_drop`) — DRY up the duplicated
  `_sessions.get` + "session not found" dance
- inline `session = _sessions[sid]` in the session.create build thread so
  agent/worker writes don't re-look-up the dict each time
- rename inline `ready_event` → `ready` (it's never ambiguous)

TS:
- `useSessionLifecycle.newSession`: hoist `r.info ?? null` into `info`
  so it's one lookup, drop ceremonial `{ … }` blocks around single-line
  bodies
- `createGatewayEventHandler.session.info`: wrap the case in a block,
  hoist `ev.payload` into `info`, tighten comments
- `useMainApp` flush effect: collapse two guard returns into one
- `bootBanner.ts`: lift `TAGLINE` + `FALLBACK` to module constants, make
  `GRADIENT` readonly, one-liner return via template literal
- `theme.ts`: group `selectionBg` inside the status* block (it's a UI
  surface bg, same family), trim the comment
2026-04-16 18:07:23 -05:00
Brooklyn Nicholson 275256cdb4 feat(tui): uniform selection background instead of SGR inverse
Selection was falling back to SGR-7 inverse (fg ↔ bg per cell), which
fragments over syntax-highlighted content — each amber/gold/dim/cornsilk
fg turned into a different bg stripe, producing the staircase look.

Now `useMainApp` calls `selection.setSelectionBgColor()` with a muted
navy (`#3a3a55`) on theme change. `setSelectionBg` in screen.ts replaces
just the bg cell-by-cell while preserving fg/bold/dim/italic, so the
highlight is one solid color across the whole drag range and the text
stays readable in its original color.

Skins can override via `selection_bg` in their color map.
2026-04-16 15:50:28 -05:00
Brooklyn Nicholson 9503896aa2 perf(tui): paint banner to stdout in ~2ms, before Ink loads
Dynamic-importing @hermes/ink + App costs ~170ms on cold start — during
that window the terminal was blank. Now `entry.tsx` writes a raw-ANSI
banner to stdout immediately after the TTY check, using hardcoded
DEFAULT_THEME colors. Ink's `<AlternateScreen>` wipes the normal-screen
buffer when it mounts, so the boot banner is replaced seamlessly by the
real React render a moment later — no double-banner, no flash.

  T=2ms    banner visible (vs. ~170ms before)
  T=~170ms React + Ink mounts
  T=~200ms alt screen takes over, Banner component repaints

Palette drift between `bootBanner.ts` and the live theme is harmless —
the live render overrides after ~200ms. Narrow terminals (cols < 98)
fall back to the one-line "⚕ NOUS HERMES" marker.
2026-04-16 15:48:41 -05:00
Brooklyn Nicholson 04e36851b7 feat(tui): honest status 'starting agent…' until session.info arrives
Post-async-session.create, `session.create` returns in ~1ms with partial
info and the real agent fires `session.info` ~1s later. Previously the
status bar went straight to 'ready' right after the instant RPC return,
which was misleading — `prompt.submit` would block server-side waiting
for the agent to finish building.

Now:
- `newSession`: status = 'starting agent…' when info has no `version`,
  else 'ready' (covers the fast resume path too)
- `session.info` event: flips status to 'ready' only if it was
  'starting agent…', preserving running/interrupted/error states
2026-04-16 15:41:44 -05:00
Brooklyn Nicholson a8e0a1148f perf(tui): async session.create — sid live in ~250ms instead of ~1350ms
Previously `session.create` blocked for ~1.2s on `_make_agent` (mostly
`run_agent` transitive imports + AIAgent constructor). The UI waited
through that whole window before sid became known and the banner/panel
could render.

Now `session.create` returns immediately with `{session_id, info:
{model, cwd, tools:{}, skills:{}}}` and spawns a background thread that
does the real `_make_agent` + `_init_session`. When the agent is live,
the thread emits `session.info` with the full payload.

Python side:
- `_sessions[sid]` gets a placeholder dict with `agent=None` and a
  `threading.Event()` named `agent_ready`
- `_wait_agent(session, rid, timeout=30)` blocks until the event is set
  (no-op when already set or absent, e.g. for `session.resume`)
- `_sess()` now calls `_wait_agent` — so every handler routed through it
  (prompt.submit, session.usage, session.compress, session.branch,
  rollback.*, tools.configure, etc.) automatically holds until the agent
  is live, but only during the ~1s startup window
- `terminal.resize` and `input.detect_drop` bypass the wait via direct
  dict lookup — they don't touch the agent and would otherwise block
  the first post-startup RPCs unnecessarily

TS side:
- `session.info` event handler now patches the intro message's `info`
  in-place so the seeded banner upgrades to the full session panel when
  the agent finishes initializing
- `appLayout` gates `SessionPanel` on `info.version` being present
  (only set by `_session_info(agent)`, not by the partial payload from
  `session.create`) — so the panel only appears when real data arrives

Net effect on cold start:
  T=~400ms  banner paints (seeded intro)
  T=~245ms  ui.sid set (session.create responds in ~1ms after ready)
  T=~1400ms session panel fills in (real session.info event)

Pre-session keystrokes queue as before (already handled by the flush
effect); `prompt.submit` will wait on `agent_ready` on the Python side
when the flush tries to send before the agent is live.
2026-04-16 15:39:19 -05:00
Brooklyn Nicholson 842a122964 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 15:37:28 -05:00
Brooklyn Nicholson 2d693c865c perf(tui): spawn python gateway before loading @hermes/ink
Before: entry.tsx imports @hermes/ink (394KB bundle) + App + GatewayClient
in declaration order, then calls `gw.start()` at ~T=220ms. Python fork +
server.py import starts then.

After: only `GatewayClient` is statically imported (5ms, node builtins
only). `gw.start()` fires at ~T=5ms. @hermes/ink + App load in parallel
via `Promise.all(import(...))`. Python gets ~215ms of free runway to do
its own module import before node even finishes loading.

Net: session.info arrives ~150ms earlier in cold start. First React frame
timing is unchanged (still ~240ms — still gated by ink+app imports).

Removed a previously-tried warm-thread in server.py that pre-imported
`run_agent` in the background. Measured variance showed occasional
5-10s outliers (GIL thrashing); median gain was <100ms. Not worth the
non-determinism.
2026-04-16 15:21:49 -05:00
Brooklyn Nicholson f3920fec0b feat(tui): queue pre-session input, auto-flush when session lands
The TUI is fully interactive from the first frame but `session.create`
(agent + tools + MCP) takes ~2s. Plain-text messages typed before the
session is live used to fail with "session not ready yet"; slash and
shell commands worked but agent prompts were dropped.

Now:
- `dispatchSubmission` enqueues plain text when `sid` is null (slash/shell
  still short-circuit first)
- `useMainApp` tracks sid transitions and kicks off one `sendQueued()`
  when the session first becomes ready; subsequent queued messages drain
  on `message.complete` as before
- Fixed pre-existing double-Enter bug that dequeued without sid check

User flow: type `hello` → shows in `queuedDisplay` preview → 2s later
agent wakes → message auto-sends → reply streams. Zero wasted input.
2026-04-16 15:04:18 -05:00
Brooklyn Nicholson c6ed61430a perf(tui): paint banner on first frame, don't wait on session.create
Previously `historyItems` was seeded empty and the intro (with Banner +
SessionPanel) was only pushed after Python's `session.create` returned —
~1.8s of agent + tools + MCP init with nothing on screen. Base CLI feels
instant because it prints the banner as its first action.

Seed `historyItems` with an info-less intro on mount. `appLayout` now
renders the Banner unconditionally for `kind === 'intro'` and gates only
the SessionPanel on `info` being present. Gateway.ready swaps the skin
(~200ms) and session.info fills in the panel when the agent is ready.

Net: first usable frame drops from ~2s to ~300ms (node + module graph +
React mount). No behavior change — intro message is replaced in place
by `introMsg(info)` when `newSession()` / `resumeById()` resolve.
2026-04-16 14:58:12 -05:00
Brooklyn Nicholson cb2a737bc8 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 14:48:33 -05:00
Brooklyn Nicholson 18840bcff8 chore: uptick 2026-04-16 14:48:29 -05:00
Brooklyn Nicholson 0478266831 refactor(tui): stop shadowing python — slash fallback inherits worker output
Python's slash worker already prints every echo/panel command through Rich.
TS was reformatting the same data client-side for 23 commands. Delete those
shadows; let the `slash.exec` fallback in `createSlashHandler` route the
worker's text (via `<Ansi>`) and page-wrap long output.

TS registry now contains 23 commands (down from 45) — only those that:
  - mutate React-local state (composer, transcript, overlays, uiStore)
  - touch the terminal (OSC52 copy, `$EDITOR`, clipboard)
  - open pickers (`/model`, `/resume`)
  - trigger history surgery (`/undo`, `/retry`, `/compress`, `/personality`)
  - need TS-only composition (`/help` merges HOTKEYS + catalog)

Deleted shadows:
  session: yolo, skin, verbose, reasoning, provider, stop, reload-mcp,
           save, title, insights, debug, fast, platforms, snapshot,
           usage, history, profile
  ops:     plugins, rollback, agents, tasks, cron, config, toolsets,
           browser, skills (list/browse only; `/tools configure` kept
           for its history-reset side effect)

Side effects:
- Drops `slash/shared.ts` + `SlashShared` + `shared`/`SLASH_OUTPUT_PAGE` —
  generic slash.exec fallback handles titled paging via `createSlashHandler`.
- Prunes 17 now-unreferenced `*Response` interfaces from gatewayTypes.ts.
- `createSlashHandler` fallback now pages long output (len>180 || lines>2)
  and uses the command name as title.

session.ts: 670 -> 199  (-70%)
ops.ts:     460 ->  52  (-88%)
gatewayTypes.ts: 450 -> 302  (-33%)
2026-04-16 14:26:15 -05:00
Brooklyn Nicholson beccd1bc04 Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 12:42:44 -05:00
Brooklyn Nicholson 68ecdb6e26 refactor(tui): store-driven turn state + slash registry + module split
Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.

Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.

Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).

Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.

Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.

Tests: 50 passing. Build + type-check clean.
2026-04-16 12:34:45 -05:00
Ari Lotter fc0623f0af update nix 2026-04-16 11:50:35 -04:00
Brooklyn Nicholson 9c71f3a6ea Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 10:47:41 -05:00
Brooklyn Nicholson c4b9750bc1 feat: lazy bootstrap node 2026-04-16 10:47:37 -05:00
Brooklyn Nicholson 39b1336d1f fix: ctx usage display 2026-04-16 08:27:41 -05:00
Brooklyn Nicholson f81dba0da2 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-16 08:23:20 -05:00
Brooklyn Nicholson 8e06db56fd chore: uptick 2026-04-16 01:04:35 -05:00
Brooklyn Nicholson cb31732c4f chore: uptick 2026-04-15 23:29:00 -05:00
Brooklyn Nicholson 097702c8a7 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 19:11:07 -05:00
Brooklyn Nicholson 72aebfbb24 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 17:43:41 -05:00
Brooklyn Nicholson c9f78d110a feat: good vibes indi 2026-04-15 17:43:38 -05:00
Brooklyn Nicholson baa0de7649 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 16:35:01 -05:00
Brooklyn Nicholson 57e4b61155 feat: change to $ when in ! mode 2026-04-15 16:34:58 -05:00
Brooklyn Nicholson 53a024a941 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 14:37:54 -05:00
Brooklyn Nicholson cb7b740e32 feat: add subagent details 2026-04-15 14:35:42 -05:00
Brooklyn Nicholson 4b4b4d47bc feat: just more cleaning 2026-04-15 14:14:01 -05:00
Brooklyn Nicholson 46cef4b7fa Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 12:48:17 -05:00
Brooklyn Nicholson 9931d1d814 chore: cleanup 2026-04-15 10:35:08 -05:00
Brooklyn Nicholson cc15b55bb9 chore: uptick 2026-04-15 10:23:15 -05:00
Brooklyn Nicholson 371166fe26 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 10:21:00 -05:00
Brooklyn Nicholson 33c615504d feat: add inline token count etc and fix venv 2026-04-15 10:20:56 -05:00
Brooklyn Nicholson 561cea0d4a Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-15 00:02:31 -05:00
Brooklyn Nicholson 496bfb3c59 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-14 22:30:22 -05:00
Brooklyn Nicholson 99d859ce4a feat: refactor by splitting up app and doing proper state 2026-04-14 22:30:18 -05:00
Brooklyn Nicholson 4cbf54fb33 chore: uptick 2026-04-14 19:38:04 -05:00
Brooklyn Nicholson 77cd5bf565 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-14 19:33:03 -05:00
Brooklyn Nicholson bf54f1fb2f Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-14 18:26:05 -05:00
Brooklyn Nicholson 3bc661ea29 fix: model et al selection on enter 2026-04-14 18:26:00 -05:00
Brooklyn Nicholson 52c11d172a feat: add scrollbar and fix selection on scroll 2026-04-14 14:34:33 -05:00
Brooklyn Nicholson 9804aa7443 fix: scrolling while selecting 2026-04-14 12:50:22 -05:00
Brooklyn Nicholson 7aed09e1ba fix: ctrlc 2026-04-14 12:07:29 -05:00
Brooklyn Nicholson dd2b0b4775 chore: uptick 2026-04-14 11:53:55 -05:00
Brooklyn Nicholson ea2d5754ab Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-14 11:49:40 -05:00
Brooklyn Nicholson 9a3a2925ed feat: scroll aware sticky prompt 2026-04-14 11:49:32 -05:00
Brooklyn Nicholson c189d5e98b fix: pasting 2026-04-13 22:39:03 -05:00
Brooklyn Nicholson 6bbac046a7 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-13 21:46:11 -05:00
Brooklyn Nicholson bbc7316007 feat: add cur cwd 2026-04-13 21:46:08 -05:00
Brooklyn Nicholson 35dbb1da3f chore: uptick 2026-04-13 21:22:44 -05:00
Brooklyn Nicholson 6d6b3b03ac feat: add clicky handles 2026-04-13 21:20:55 -05:00
Brooklyn Nicholson 1b573b7b21 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-13 21:17:41 -05:00
Brooklyn Nicholson 7e4dd6ea02 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-13 18:32:13 -05:00
Brooklyn Nicholson aeb53131f3 fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle 2026-04-13 18:29:24 -05:00
Brooklyn Nicholson 783c6b6ed6 chore: uptick 2026-04-13 15:08:06 -05:00
Brooklyn Nicholson 4a260b51fe fix: deep markdown parsing 2026-04-13 15:01:15 -05:00
Brooklyn Nicholson ebe3270430 fix: fake models 2026-04-13 14:57:42 -05:00
Brooklyn Nicholson 77b97b810a chore: update how txt pasting ux feels 2026-04-13 14:49:10 -05:00
Brooklyn Nicholson 9db94e8521 Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-13 14:17:55 -05:00
Brooklyn Nicholson cac1b1b724 fix(ui-tui): surface RPC errors and guard invalid gateway responses 2026-04-13 14:17:52 -05:00
Ari Lotter 56524bb1d9 fix: nix local dev with tui 2026-04-13 15:09:31 -04:00
Brooklyn Nicholson 0642b6cc53 fix: clean newline paste thingy 2026-04-13 12:54:48 -05:00
Brooklyn Nicholson eec1db36f7 chore: preserve commands 2026-04-13 10:43:42 -05:00
Brooklyn Nicholson 713a614ea8 chore: uptick 2026-04-13 10:22:44 -05:00
Brooklyn Nicholson a27167fb30 chore: fmt 2026-04-13 10:14:05 -05:00
Brooklyn Nicholson a2c0597ae4 feat: show thinking indicator while inferencing 2026-04-13 10:11:18 -05:00
Brooklyn Nicholson 0fd33a98cd feat: ctrl t for diff thinking rendering types 2026-04-12 20:08:12 -05:00
Brooklyn Nicholson ddb0871769 feat(tui): hierarchical tool progress with grouped parent/child rows and transient line pruning 2026-04-12 17:39:17 -05:00
Brooklyn Nicholson e03bef684e chore: fmt 2026-04-12 16:33:25 -05:00
Brooklyn Nicholson 4b026d6761 fix: little box typey thing 2026-04-12 16:31:30 -05:00
Brooklyn Nicholson 8efd3db1b4 fix: force builds 2026-04-12 16:08:03 -05:00
Brooklyn Nicholson ef51bb0091 fix: tool drafting stuff 2026-04-12 16:06:39 -05:00
Ari Lotter 3bf0f39337 wrap preformatted ansi in <Ansi> component 2026-04-12 16:53:53 -04:00
Brooklyn Nicholson 690d62a6d1 Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-12 13:19:07 -05:00
Brooklyn Nicholson 2aea75e91e Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-12 13:18:55 -05:00
Austin Pickett 5552e1ffe1 Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 22:10:11 -04:00
Austin Pickett 90890f8f04 feat: personality selector 2026-04-11 22:10:02 -04:00
Ari Lotter 8e0df1d532 launch tui later to allow setup et al 2026-04-11 20:23:30 -04:00
Ari Lotter 29721fcc58 nix fixes 2026-04-11 19:35:00 -04:00
Brooklyn Nicholson a1d2a0c0fd feat: self update npm deps on hermes update 2026-04-11 18:29:18 -05:00
Brooklyn Nicholson ec553fdb49 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 17:15:41 -05:00
Brooklyn Nicholson 24a498eb90 feat: better markdown 2026-04-11 17:15:36 -05:00
Brooklyn Nicholson 9ccb490cf3 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 15:30:23 -05:00
Brooklyn Nicholson 32302c37dd feat: fix types and add type checking plus lazybundle on launch andddd dev flag 2026-04-11 14:42:28 -05:00
Ari Lotter 5e5e65f6d5 fix nix build 2026-04-11 15:30:37 -04:00
Brooklyn Nicholson acbf1794f2 Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 14:05:17 -05:00
Brooklyn Nicholson e2ea8934d4 feat: ensure feature parity once again 2026-04-11 14:02:36 -05:00
Austin Pickett 7e7f78f86c Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 15:00:28 -04:00
Austin Pickett 5fb6a4418b feat: panels 2026-04-11 14:29:24 -04:00
Brooklyn Nicholson bf6af95ff5 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 13:14:36 -05:00
Brooklyn Nicholson 3fd5cf6e3c feat: fix img pasting in new ink plus newline after tools 2026-04-11 13:14:32 -05:00
Brooklyn Nicholson b04248f4d5 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
# Conflicts:
#	gateway/platforms/base.py
#	gateway/run.py
#	tests/gateway/test_command_bypass_active_session.py
2026-04-11 11:39:47 -05:00
Brooklyn Nicholson 7803d21bcc Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-11 11:39:19 -05:00
Brooklyn Nicholson 8760faf991 feat: fork ink and make it work nicely 2026-04-11 11:29:08 -05:00
jonny cab6447d58 fix(tui): render tool trail consistently between live and resume
Resumed sessions showed raw JSON tool output in content boxes instead
of the compact trail lines seen during live use. The root cause was
two separate rendering paths with no shared code.

Extract buildToolTrailLine() into lib/text.ts as the single source
of truth for formatting tool trail lines. Both the live tool.complete
handler and toTranscriptMessages now call it.

Server-side, reconstruct tool name and args from the assistant
message's tool_calls field (tool_name column is unpopulated) and
pass them through _tool_ctx/build_tool_preview — the same path
the live tool.start callback uses.
2026-04-11 06:35:00 +00:00
jonny 57e8d44af8 fix(tui): preserve tool metadata in resumed session history
session.resume was building conversation history with only role and
content, stripping tool_call_id, tool_calls, and tool_name. The API
requires tool messages to reference their parent tool_call, so resumed
sessions with tool history would fail with HTTP 500.

Use get_messages_as_conversation() which already preserves the full
message structure including tool metadata and reasoning fields.
2026-04-11 05:23:44 +00:00
jonny cb79018977 fix(tui): improve session picker readability
- Show full session ID in a fixed-width column for easy scanning
- Pad row numbers to 2 digits to keep alignment past 9 entries
- Always show session source (tui/cli) instead of conditionally hiding it
- Use Box-based column layout so ID, metadata, and title don't run together
2026-04-10 11:16:41 +00:00
jonny 90f0aa174d fix(tui): support /resume <id> to bypass session picker
- Extract resumeById callback from inline onSelect handler
- /resume with no arg opens picker (unchanged behavior)
- /resume <id> resumes directly, skipping the picker
2026-04-10 11:00:08 +00:00
jonny 304f1463a9 fix(tui): show CLI sessions in resume picker
- session.list RPC now queries both tui and cli sources, merged by recency
- Session picker shows source label for non-tui sessions (e.g. ", cli")
- Added source field to SessionItem interface
2026-04-10 09:34:01 +00:00
jonny 294c377c0c fix(tui): use PROJECT_ROOT instead of cwd for HERMES_ROOT fallback
When HERMES_ROOT was added for Nix-bundled TUI support, the fallback
was set to os.getcwd(). This overrode the TUI's own import.meta.dirname
resolution, so launching `hermes --tui` from outside the repo caused
the gateway client to look for venv/bin/python relative to the user's
working directory instead of the repo root.

Use PROJECT_ROOT (resolved from the source file location) as the
fallback, which is stable regardless of where the command is invoked.
2026-04-10 09:18:06 +00:00
Ari Lotter 660379637a one more nix fix 2026-04-10 01:41:29 -04:00
Ari Lotter bc80848e49 update lockfile 2026-04-10 00:50:39 -04:00
Ari Lotter 658cd2dd4c nix: add tui lockfile update script 2026-04-10 00:46:37 -04:00
Brooklyn Nicholson 8c1ba639c6 Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-09 23:35:29 -05:00
Brooklyn Nicholson 17a9c47178 feat: support shift enter for ghostty etc 2026-04-09 23:35:25 -05:00
Austin Pickett e1df13cf20 fix: menus 2026-04-10 00:01:37 -04:00
Brooklyn Nicholson 4fe78d5b88 chore: fix bad merge apparently? 2026-04-09 19:17:06 -05:00
Brooklyn Nicholson aa5b697a9d Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-09 19:12:31 -05:00
Brooklyn Nicholson aca479c1ae Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-09 19:08:52 -05:00
Brooklyn Nicholson b85ff282bc feat(ui-tui): slash command history/display, CoT fade, live skin switch, fix double reasoning 2026-04-09 19:08:47 -05:00
Austin Pickett f805323517 chore: merge main 2026-04-09 20:00:34 -04:00
Austin Pickett 4406b4b100 fix: add delete support 2026-04-09 19:53:55 -04:00
Brooklyn Nicholson 17ecdce936 feat: add slash commands to the history so it doesnt get lost 2026-04-09 18:51:17 -05:00
Brooklyn Nicholson 7e813a30e0 fix: sexier cots 2026-04-09 18:33:25 -05:00
Brooklyn Nicholson 6e24b9947e feat(ui-tui): render tool calls inline in message flow instead of activity lane 2026-04-09 17:40:30 -05:00
Brooklyn Nicholson 99fd3b518d feat: add /copy and /agents 2026-04-09 17:19:36 -05:00
Brooklyn Nicholson c5511bbc5a fix: leading ./ thingy 2026-04-09 16:27:06 -05:00
Brooklyn Nicholson b7d4ea1550 feat: better hyperlink formatting 2026-04-09 15:13:43 -05:00
Ari Lotter 74241328f0 direnv: watch lockfiles/nix files; gitignore .nix-stamps 2026-04-09 15:50:24 -04:00
Ari Lotter df5874c119 nix: add bundled TUI build-time verification check 2026-04-09 15:50:24 -04:00
Ari Lotter 21afb3fa3c nix: delegate devShell setup to package passthru hooks
- use inputsFrom to inherit build inputs from packages
- concat passthru.devShellHook from each package
2026-04-09 15:50:24 -04:00
Ari Lotter 31b2c12f0f nix: bundle TUI in main package with passthru hooks
- build tui.nix, copy to $out/ui-tui/ (same layout as dev)
- set HERMES_TUI_DIR, HERMES_PYTHON in wrapper
- add passthru.devShellHook with stamp-checked venv setup
- expose tui as separate package output
2026-04-09 15:50:24 -04:00
Ari Lotter 405c1b4e84 nix: add TUI derivation with buildNpmPackage
- fetchNpmDeps for reproducibilty
- compile ts to js
- passthru.devShellHook for dev shell stamp-checked auto dep install
2026-04-09 15:50:24 -04:00
Ari Lotter 5ff96551d5 cli: support bundled TUI at HERMES_TUI_DIR (for nix)
- Fix cwd to use bundled TUI dir, not PROJECT_ROOT
- Set HERMES_ROOT from env with cwd fallback
2026-04-09 15:50:24 -04:00
Ari Lotter 2b4272ef5b ui-tui: update package-lock.json 2026-04-09 15:35:54 -04:00
Ari Lotter 670dcea8f4 ui-tui: add tsc build pipeline
- Switch tsconfig to nodenext module resolution for Node 22 (used by
installer script)
- Add shebang to entry.tsx, preserved into index.js
- Add HERMES_ROOT env var fallback for repo root resolution
2026-04-09 15:35:29 -04:00
Brooklyn Nicholson 17f13013eb chore: fmt 2026-04-09 14:17:45 -05:00
Brooklyn Nicholson 00e1d42b9e feat: image pasting 2026-04-09 13:45:23 -05:00
Brooklyn Nicholson b2ea9b4176 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-09 12:31:20 -05:00
Brooklyn Nicholson 0d7c19a42f fix(ui-tui): ref-based input buffer, gateway listener stability, usage display, and 6 correctness bugs 2026-04-09 12:21:24 -05:00
Brooklyn Nicholson 8755b9dfc0 fix: resizing etc 2026-04-09 00:46:35 -05:00
Brooklyn Nicholson 54bd25ff4a fix(tui): -c resume, ctrl z, pasting updates, exit summary, session fix 2026-04-09 00:36:53 -05:00
Brooklyn Nicholson b66550ed08 fix(tui): stabilize multiline input, persist tool traces, and port CLI-style context status bar 2026-04-08 23:59:56 -05:00
Brooklyn Nicholson c49bbbe8c2 chore: fmt 2026-04-08 22:02:38 -05:00
Brooklyn Nicholson 9d8f9765c1 feat: add tests and update mds 2026-04-08 19:31:25 -05:00
Brooklyn Nicholson f226e6be10 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-08 19:11:44 -05:00
Brooklyn Nicholson a435c7274a chore: uptick 2026-04-08 14:22:36 -05:00
Brooklyn Nicholson b597123489 feat: better bg tasks 2026-04-08 14:18:37 -05:00
Brooklyn Nicholson af0f4a52fe feat: cute spinners 2026-04-08 13:45:34 -05:00
Brooklyn Nicholson b50d81f212 fix: diff colours 2026-04-08 12:11:55 -05:00
Brooklyn Nicholson a9fa054df9 chore: uptick 2026-04-08 10:35:07 -05:00
Brooklyn Nicholson 31cb23890a Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-08 09:46:46 -05:00
Brooklyn Nicholson a3cfb1de86 feat: auto install tui deps 2026-04-08 09:46:40 -05:00
Austin Pickett 371efafc46 feat: personality 2026-04-08 00:15:15 -04:00
Austin Pickett ebd2d83ef2 feat: add skin logo support 2026-04-07 23:59:11 -04:00
Brooklyn Nicholson af077b2c0d fix: history up arrow 2026-04-07 20:47:59 -05:00
Brooklyn Nicholson 2d884ff12d chore: uptick 2026-04-07 20:46:59 -05:00
Brooklyn Nicholson b397c91d4a chore: uptick 2026-04-07 20:44:18 -05:00
Brooklyn Nicholson 9c2c9e3a3e chore: fmt 2026-04-07 20:30:22 -05:00
Brooklyn Nicholson c3eeb03e26 chore: clean exit 2026-04-07 20:29:31 -05:00
Brooklyn Nicholson d9d0ac06b9 chore: readme update 2026-04-07 20:24:46 -05:00
Brooklyn Nicholson 29f2610e4b tui updates for rendering pipeline 2026-04-07 20:11:05 -05:00
Brooklyn Nicholson dcb97f7465 chore: readme 2026-04-06 18:52:45 -05:00
Brooklyn Nicholson 86308b6de4 chore: better command support 2026-04-06 18:49:40 -05:00
Brooklyn Nicholson 2d349bbf7a chore: fmt 2026-04-06 18:43:00 -05:00
Brooklyn Nicholson 39878aff00 chore: uptick 2026-04-06 18:40:21 -05:00
Brooklyn Nicholson afd670a36f feat: small refactors 2026-04-06 18:38:13 -05:00
Brooklyn Nicholson e2b3b1c5e4 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-06 17:56:45 -05:00
Brooklyn Nicholson 4c7d5ec778 tui: add tui arg 2026-04-05 18:55:59 -05:00
Brooklyn Nicholson f116c59071 tui: inherit Python-side rendering via gateway bridge 2026-04-05 18:50:41 -05:00
Brooklyn Nicholson 0f556a17f5 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-05 18:24:10 -05:00
Brooklyn Nicholson ee92460763 Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor 2026-04-04 16:35:13 -05:00
Brooklyn Nicholson 2893e9df71 feat: add image pasting capability 2026-04-04 13:00:55 -05:00
Brooklyn Nicholson 5a5d90c85a chore: formatting etc 2026-04-03 20:14:57 -05:00
Brooklyn Nicholson 56a69e519b chore: uptick 2026-04-03 19:55:15 -05:00
Brooklyn Nicholson fab4d8d470 chore: uptick 2026-04-03 19:52:50 -05:00
Brooklyn Nicholson 1218994992 chore: uptick 2026-04-03 14:44:50 -05:00
Brooklyn Nicholson f4bf57ff7a chore: uptick 2026-04-02 23:00:38 -05:00
Brooklyn Nicholson bbba9ed4f2 feat: split apart main.tsx 2026-04-02 20:39:52 -05:00
Brooklyn Nicholson 2818dd8611 feat: add prettier etc for ui-tui 2026-04-02 19:34:30 -05:00
Brooklyn Nicholson 2ea5345a7b feat: new tui based on ink 2026-04-02 19:07:53 -05:00
558 changed files with 85158 additions and 4333 deletions
+4
View File
@@ -1 +1,5 @@
watch_file pyproject.toml uv.lock
watch_file ui-tui/package-lock.json ui-tui/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
use flake
+7 -2
View File
@@ -3,8 +3,13 @@ name: Docker Build and Publish
on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
release:
types: [published]
-248
View File
@@ -1,248 +0,0 @@
name: Supply Chain Audit
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: read
jobs:
scan:
name: Scan PR for supply chain risks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Scan diff for suspicious patterns
id: scan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Get the full diff (added lines only)
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
FINDINGS=""
CRITICAL=false
# --- .pth files (auto-execute on Python startup) ---
PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
CRITICAL=true
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required. This is the exact mechanism used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512).
**Files:**
\`\`\`
${PTH_FILES}
\`\`\`
"
fi
# --- base64 + exec/eval combo (the litellm attack pattern) ---
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
if [ -n "$B64_EXEC_HITS" ]; then
CRITICAL=true
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: base64 decode + exec/eval combo
This is the exact pattern used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512) — base64-decoded strings passed to exec/eval to hide credential-stealing payloads.
**Matches:**
\`\`\`
${B64_EXEC_HITS}
\`\`\`
"
fi
# --- base64 decode/encode (alone — legitimate uses exist) ---
B64_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|b64encode|decodebytes|encodebytes|urlsafe_b64decode)|atob\(|btoa\(|Buffer\.from\(.*base64' | head -20 || true)
if [ -n "$B64_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: base64 encoding/decoding detected
Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.
**Matches (first 20):**
\`\`\`
${B64_HITS}
\`\`\`
"
fi
# --- exec/eval with string arguments ---
EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E '(exec|eval)\s*\(' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert\|# ' | head -20 || true)
if [ -n "$EXEC_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: exec() or eval() usage
Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.
**Matches (first 20):**
\`\`\`
${EXEC_HITS}
\`\`\`
"
fi
# --- subprocess with encoded/obfuscated commands ---
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|decode|encode|\\x|chr\(' | head -10 || true)
if [ -n "$PROC_HITS" ]; then
CRITICAL=true
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
Subprocess calls with encoded arguments are a strong indicator of payload execution.
**Matches:**
\`\`\`
${PROC_HITS}
\`\`\`
"
fi
# --- Network calls to non-standard domains ---
EXFIL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'requests\.(post|put)\(|httpx\.(post|put)\(|urllib\.request\.urlopen' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert' | head -10 || true)
if [ -n "$EXFIL_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Outbound network calls (POST/PUT)
Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.
**Matches (first 10):**
\`\`\`
${EXFIL_HITS}
\`\`\`
"
fi
# --- setup.py / setup.cfg install hooks ---
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(setup\.py|setup\.cfg|__init__\.pth|sitecustomize\.py|usercustomize\.py)$' || true)
if [ -n "$SETUP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Install hook files modified
These files can execute code during package installation or interpreter startup.
**Files:**
\`\`\`
${SETUP_HITS}
\`\`\`
"
fi
# --- Compile/marshal/pickle (code object injection) ---
MARSHAL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'marshal\.loads|pickle\.loads|compile\(' | grep -v '^\+\s*#' | grep -v 'test_\|re\.compile\|ast\.compile' | head -10 || true)
if [ -n "$MARSHAL_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: marshal/pickle/compile usage
These can deserialize or construct executable code objects.
**Matches:**
\`\`\`
${MARSHAL_HITS}
\`\`\`
"
fi
# --- CI/CD workflow files modified ---
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
if [ -n "$WORKFLOW_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: CI/CD workflow files modified
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
**Files:**
\`\`\`
${WORKFLOW_HITS}
\`\`\`
"
fi
# --- Dockerfile / container build files modified ---
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
if [ -n "$DOCKER_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Container build files modified
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
**Files:**
\`\`\`
${DOCKER_HITS}
\`\`\`
"
fi
# --- Dependency manifest files modified ---
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
if [ -n "$DEP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Dependency manifest files modified
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
**Files:**
\`\`\`
${DEP_HITS}
\`\`\`
"
fi
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
if [ -n "$ACTIONS_UNPIN" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: GitHub Actions with mutable version tags
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
**Matches:**
\`\`\`
${ACTIONS_UNPIN}
\`\`\`
"
fi
# --- Output results ---
if [ -n "$FINDINGS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
if [ "$CRITICAL" = true ]; then
echo "critical=true" >> "$GITHUB_OUTPUT"
else
echo "critical=false" >> "$GITHUB_OUTPUT"
fi
# Write findings to a file (multiline env vars are fragile)
echo "$FINDINGS" > /tmp/findings.md
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "critical=false" >> "$GITHUB_OUTPUT"
fi
- name: Post warning comment
if: steps.scan.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SEVERITY="⚠️ Supply Chain Risk Detected"
if [ "${{ steps.scan.outputs.critical }}" = "true" ]; then
SEVERITY="🚨 CRITICAL Supply Chain Risk Detected"
fi
BODY="## ${SEVERITY}
This PR contains patterns commonly associated with supply chain attacks. This does **not** mean the PR is malicious — but these patterns require careful human review before merging.
$(cat /tmp/findings.md)
---
*Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
- name: Fail on critical findings
if: steps.scan.outputs.critical == 'true'
run: |
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
exit 1
+8 -8
View File
@@ -3,8 +3,14 @@ name: Tests
on:
push:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
permissions:
contents: read
@@ -16,13 +22,8 @@ concurrency:
jobs:
test:
name: test (${{ matrix.group }}/4)
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
group: [1, 2, 3, 4]
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -42,11 +43,10 @@ jobs:
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests (shard ${{ matrix.group }}/4)
- name: Run tests
run: |
source .venv/bin/activate
python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short \
--splits 4 --group ${{ matrix.group }}
python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
+1
View File
@@ -60,5 +60,6 @@ mini-swe-agent/
# Nix
.direnv/
.nix-stamps/
result
website/static/api/skills-index.json
+66
View File
@@ -56,6 +56,19 @@ hermes-agent/
│ ├── run.py # Main loop, slash commands, message dispatch
│ ├── session.py # SessionStore — conversation persistence
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal, qqbot
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
│ ├── src/entry.tsx # TTY gate + render()
│ ├── src/app.tsx # Main state machine and UI
│ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge
│ ├── src/app/ # Decomposed app logic (event handler, slash handler, stores, hooks)
│ ├── src/components/ # Ink components (branding, markdown, prompts, pickers, etc.)
│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
│ └── src/lib/ # Pure helpers (history, osc52, text, rpc, messages)
├── tui_gateway/ # Python JSON-RPC backend for the TUI
│ ├── entry.py # stdio entrypoint
│ ├── server.py # RPC handlers and session logic
│ ├── render.py # Optional rich/ANSI bridge
│ └── slash_worker.py # Persistent HermesCLI subprocess for slash commands
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── cron/ # Scheduler (jobs.py, scheduler.py)
├── environments/ # RL training environments (Atropos)
@@ -179,6 +192,59 @@ if canonical == "mycommand":
---
## TUI Architecture (ui-tui + tui_gateway)
The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
### Process Model
```
hermes --tui
└─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
│ └─ AIAgent + tools + sessions
└─ renders transcript, composer, prompts, activity
```
TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
### Transport
Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
### Key Surfaces
| Surface | Ink component | Gateway method |
|---------|---------------|----------------|
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit``message.delta/complete` |
| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
| Approvals | `prompts.tsx` | `approval.respond``approval.request` |
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
| Slash commands | Local handler + fallthrough | `slash.exec``_SlashWorker`, `command.dispatch` |
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
### Slash Command Flow
1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
### Dev Commands
```bash
cd ui-tui
npm install # first time
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
npm start # production
npm run build # full build (hermes-ink + tsc)
npm run type-check # typecheck only (tsc --noEmit)
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
```
---
## Adding New Tools
Requires changes in **2 files**:
+20 -10
View File
@@ -21,26 +21,36 @@ RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
COPY . /opt/hermes
WORKDIR /opt/hermes
# Install Node dependencies and Playwright as root (--with-deps needs apt)
# ---------- Layer-cached dependency install ----------
# Copy only package manifests first so npm install + Playwright are cached
# unless the lockfiles themselves change.
COPY package.json package-lock.json ./
COPY scripts/whatsapp-bridge/package.json scripts/whatsapp-bridge/package-lock.json scripts/whatsapp-bridge/
COPY web/package.json web/package-lock.json web/
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
cd /opt/hermes/scripts/whatsapp-bridge && \
npm install --prefer-offline --no-audit && \
(cd scripts/whatsapp-bridge && npm install --prefer-offline --no-audit) && \
(cd web && npm install --prefer-offline --no-audit) && \
npm cache clean --force
# Hand ownership to hermes user, then install Python deps in a virtualenv
RUN chown -R hermes:hermes /opt/hermes
USER hermes
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build web dashboard (Vite outputs to hermes_cli/web_dist/)
RUN cd web && npm run build
# ---------- Python virtualenv ----------
RUN chown hermes:hermes /opt/hermes
USER hermes
RUN uv venv && \
uv pip install --no-cache-dir -e ".[all]"
USER root
RUN chmod +x /opt/hermes/docker/entrypoint.sh
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
ENV HERMES_HOME=/opt/data
VOLUME [ "/opt/data" ]
ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ]
+9 -2
View File
@@ -13,7 +13,7 @@
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
Use any model you want — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (200+ models), [NVIDIA NIM](https://build.nvidia.com) (Nemotron), [Xiaomi MiMo](https://platform.xiaomimimo.com), [z.ai/GLM](https://z.ai), [Kimi/Moonshot](https://platform.moonshot.ai), [MiniMax](https://www.minimax.io), [Hugging Face](https://huggingface.co), OpenAI, or your own endpoint. Switch with `hermes model` — no code changes, no lock-in.
<table>
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
@@ -141,11 +141,18 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
Quick start for contributors:
Quick start for contributors — clone and go with `setup-hermes.sh`:
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
./hermes # auto-detects the venv, no need to `source` first
```
Manual path (equivalent to the above):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv venv --python 3.11
source venv/bin/activate
+20 -1
View File
@@ -49,6 +49,7 @@ def make_tool_progress_cb(
session_id: str,
loop: asyncio.AbstractEventLoop,
tool_call_ids: Dict[str, Deque[str]],
tool_call_meta: Dict[str, Dict[str, Any]],
) -> Callable:
"""Create a ``tool_progress_callback`` for AIAgent.
@@ -84,6 +85,16 @@ def make_tool_progress_cb(
tool_call_ids[name] = queue
queue.append(tc_id)
snapshot = None
if name in {"write_file", "patch", "skill_manage"}:
try:
from agent.display import capture_local_edit_snapshot
snapshot = capture_local_edit_snapshot(name, args)
except Exception:
logger.debug("Failed to capture ACP edit snapshot for %s", name, exc_info=True)
tool_call_meta[tc_id] = {"args": args, "snapshot": snapshot}
update = build_tool_start(tc_id, name, args)
_send_update(conn, session_id, loop, update)
@@ -119,6 +130,7 @@ def make_step_cb(
session_id: str,
loop: asyncio.AbstractEventLoop,
tool_call_ids: Dict[str, Deque[str]],
tool_call_meta: Dict[str, Dict[str, Any]],
) -> Callable:
"""Create a ``step_callback`` for AIAgent.
@@ -132,10 +144,12 @@ def make_step_cb(
for tool_info in prev_tools:
tool_name = None
result = None
function_args = None
if isinstance(tool_info, dict):
tool_name = tool_info.get("name") or tool_info.get("function_name")
result = tool_info.get("result") or tool_info.get("output")
function_args = tool_info.get("arguments") or tool_info.get("args")
elif isinstance(tool_info, str):
tool_name = tool_info
@@ -145,8 +159,13 @@ def make_step_cb(
tool_call_ids[tool_name] = queue
if tool_name and queue:
tc_id = queue.popleft()
meta = tool_call_meta.pop(tc_id, {})
update = build_tool_complete(
tc_id, tool_name, result=str(result) if result is not None else None
tc_id,
tool_name,
result=str(result) if result is not None else None,
function_args=function_args or meta.get("args"),
snapshot=meta.get("snapshot"),
)
_send_update(conn, session_id, loop, update)
if not queue:
+148 -30
View File
@@ -26,6 +26,7 @@ from acp.schema import (
McpServerHttp,
McpServerSse,
McpServerStdio,
ModelInfo,
NewSessionResponse,
PromptResponse,
ResumeSessionResponse,
@@ -36,6 +37,7 @@ from acp.schema import (
SessionCapabilities,
SessionForkCapabilities,
SessionListCapabilities,
SessionModelState,
SessionResumeCapabilities,
SessionInfo,
TextContentBlock,
@@ -147,6 +149,98 @@ class HermesACPAgent(acp.Agent):
self._conn = conn
logger.info("ACP client connected")
@staticmethod
def _encode_model_choice(provider: str | None, model: str | None) -> str:
"""Encode a model selection so ACP clients can keep provider context."""
raw_model = str(model or "").strip()
if not raw_model:
return ""
raw_provider = str(provider or "").strip().lower()
if not raw_provider:
return raw_model
return f"{raw_provider}:{raw_model}"
def _build_model_state(self, state: SessionState) -> SessionModelState | None:
"""Return the ACP model selector payload for editors like Zed."""
model = str(state.model or getattr(state.agent, "model", "") or "").strip()
provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter"
try:
from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label
normalized_provider = normalize_provider(provider)
provider_name = provider_label(normalized_provider)
available_models: list[ModelInfo] = []
seen_ids: set[str] = set()
for model_id, description in curated_models_for_provider(normalized_provider):
rendered_model = str(model_id or "").strip()
if not rendered_model:
continue
choice_id = self._encode_model_choice(normalized_provider, rendered_model)
if choice_id in seen_ids:
continue
desc_parts = [f"Provider: {provider_name}"]
if description:
desc_parts.append(str(description).strip())
if rendered_model == model:
desc_parts.append("current")
available_models.append(
ModelInfo(
model_id=choice_id,
name=rendered_model,
description="".join(part for part in desc_parts if part),
)
)
seen_ids.add(choice_id)
current_model_id = self._encode_model_choice(normalized_provider, model)
if current_model_id and current_model_id not in seen_ids:
available_models.insert(
0,
ModelInfo(
model_id=current_model_id,
name=model,
description=f"Provider: {provider_name} • current",
),
)
if available_models:
return SessionModelState(
available_models=available_models,
current_model_id=current_model_id or available_models[0].model_id,
)
except Exception:
logger.debug("Could not build ACP model state", exc_info=True)
if not model:
return None
fallback_choice = self._encode_model_choice(provider, model)
return SessionModelState(
available_models=[ModelInfo(model_id=fallback_choice, name=model)],
current_model_id=fallback_choice,
)
@staticmethod
def _resolve_model_selection(raw_model: str, current_provider: str) -> tuple[str, str]:
"""Resolve ``provider:model`` input into the provider and normalized model id."""
target_provider = current_provider
new_model = raw_model.strip()
try:
from hermes_cli.models import detect_provider_for_model, parse_model_input
target_provider, new_model = parse_model_input(new_model, current_provider)
if target_provider == current_provider:
detected = detect_provider_for_model(new_model, current_provider)
if detected:
target_provider, new_model = detected
except Exception:
logger.debug("Provider detection failed, using model as-is", exc_info=True)
return target_provider, new_model
async def _register_session_mcp_servers(
self,
state: SessionState,
@@ -273,7 +367,10 @@ class HermesACPAgent(acp.Agent):
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("New session %s (cwd=%s)", state.session_id, cwd)
self._schedule_available_commands_update(state.session_id)
return NewSessionResponse(session_id=state.session_id)
return NewSessionResponse(
session_id=state.session_id,
models=self._build_model_state(state),
)
async def load_session(
self,
@@ -289,7 +386,7 @@ class HermesACPAgent(acp.Agent):
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("Loaded session %s", session_id)
self._schedule_available_commands_update(session_id)
return LoadSessionResponse()
return LoadSessionResponse(models=self._build_model_state(state))
async def resume_session(
self,
@@ -305,7 +402,7 @@ class HermesACPAgent(acp.Agent):
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("Resumed session %s", state.session_id)
self._schedule_available_commands_update(state.session_id)
return ResumeSessionResponse()
return ResumeSessionResponse(models=self._build_model_state(state))
async def cancel(self, session_id: str, **kwargs: Any) -> None:
state = self.session_manager.get_session(session_id)
@@ -340,11 +437,20 @@ class HermesACPAgent(acp.Agent):
cwd: str | None = None,
**kwargs: Any,
) -> ListSessionsResponse:
infos = self.session_manager.list_sessions()
sessions = [
SessionInfo(session_id=s["session_id"], cwd=s["cwd"])
for s in infos
]
infos = self.session_manager.list_sessions(cwd=cwd)
sessions = []
for s in infos:
updated_at = s.get("updated_at")
if updated_at is not None and not isinstance(updated_at, str):
updated_at = str(updated_at)
sessions.append(
SessionInfo(
session_id=s["session_id"],
cwd=s["cwd"],
title=s.get("title"),
updated_at=updated_at,
)
)
return ListSessionsResponse(sessions=sessions)
# ---- Prompt (core) ------------------------------------------------------
@@ -389,12 +495,13 @@ class HermesACPAgent(acp.Agent):
state.cancel_event.clear()
tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
tool_call_meta: dict[str, dict[str, Any]] = {}
previous_approval_cb = None
if conn:
tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids)
tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)
thinking_cb = make_thinking_cb(conn, session_id, loop)
step_cb = make_step_cb(conn, session_id, loop, tool_call_ids)
step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)
message_cb = make_message_cb(conn, session_id, loop)
approval_cb = make_approval_callback(conn.request_permission, loop, session_id)
else:
@@ -449,6 +556,19 @@ class HermesACPAgent(acp.Agent):
self.session_manager.save_session(session_id)
final_response = result.get("final_response", "")
if final_response:
try:
from agent.title_generator import maybe_auto_title
maybe_auto_title(
self.session_manager._get_db(),
session_id,
user_text,
final_response,
state.history,
)
except Exception:
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
if final_response and conn:
update = acp.update_agent_message_text(final_response)
await conn.session_update(session_id, update)
@@ -556,27 +676,15 @@ class HermesACPAgent(acp.Agent):
provider = getattr(state.agent, "provider", None) or "auto"
return f"Current model: {model}\nProvider: {provider}"
new_model = args.strip()
target_provider = None
current_provider = getattr(state.agent, "provider", None) or "openrouter"
# Auto-detect provider for the requested model
try:
from hermes_cli.models import parse_model_input, detect_provider_for_model
target_provider, new_model = parse_model_input(new_model, current_provider)
if target_provider == current_provider:
detected = detect_provider_for_model(new_model, current_provider)
if detected:
target_provider, new_model = detected
except Exception:
logger.debug("Provider detection failed, using model as-is", exc_info=True)
target_provider, new_model = self._resolve_model_selection(args, current_provider)
state.model = new_model
state.agent = self.session_manager._make_agent(
session_id=state.session_id,
cwd=state.cwd,
model=new_model,
requested_provider=target_provider or current_provider,
requested_provider=target_provider,
)
self.session_manager.save_session(state.session_id)
provider_label = getattr(state.agent, "provider", None) or target_provider or current_provider
@@ -678,20 +786,30 @@ class HermesACPAgent(acp.Agent):
"""Switch the model for a session (called by ACP protocol)."""
state = self.session_manager.get_session(session_id)
if state:
state.model = model_id
current_provider = getattr(state.agent, "provider", None)
current_base_url = getattr(state.agent, "base_url", None)
current_api_mode = getattr(state.agent, "api_mode", None)
requested_provider, resolved_model = self._resolve_model_selection(
model_id,
current_provider or "openrouter",
)
state.model = resolved_model
provider_changed = bool(current_provider and requested_provider != current_provider)
current_base_url = None if provider_changed else getattr(state.agent, "base_url", None)
current_api_mode = None if provider_changed else getattr(state.agent, "api_mode", None)
state.agent = self.session_manager._make_agent(
session_id=session_id,
cwd=state.cwd,
model=model_id,
requested_provider=current_provider,
model=resolved_model,
requested_provider=requested_provider,
base_url=current_base_url,
api_mode=current_api_mode,
)
self.session_manager.save_session(session_id)
logger.info("Session %s: model switched to %s", session_id, model_id)
logger.info(
"Session %s: model switched to %s via provider %s",
session_id,
resolved_model,
requested_provider,
)
return SetSessionModelResponse()
logger.warning("Session %s: model switch requested for missing session", session_id)
return None
+127 -34
View File
@@ -13,8 +13,12 @@ from hermes_constants import get_hermes_home
import copy
import json
import logging
import os
import re
import sys
import time
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Dict, List, Optional
@@ -22,6 +26,64 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _normalize_cwd_for_compare(cwd: str | None) -> str:
raw = str(cwd or ".").strip()
if not raw:
raw = "."
expanded = os.path.expanduser(raw)
# Normalize Windows drive paths into the equivalent WSL mount form so
# ACP history filters match the same workspace across Windows and WSL.
match = re.match(r"^([A-Za-z]):[\\/](.*)$", expanded)
if match:
drive = match.group(1).lower()
tail = match.group(2).replace("\\", "/")
expanded = f"/mnt/{drive}/{tail}"
elif re.match(r"^/mnt/[A-Za-z]/", expanded):
expanded = f"/mnt/{expanded[5].lower()}/{expanded[7:]}"
return os.path.normpath(expanded)
def _build_session_title(title: Any, preview: Any, cwd: str | None) -> str:
explicit = str(title or "").strip()
if explicit:
return explicit
preview_text = str(preview or "").strip()
if preview_text:
return preview_text
leaf = os.path.basename(str(cwd or "").rstrip("/\\"))
return leaf or "New thread"
def _format_updated_at(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str) and value.strip():
return value
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
except Exception:
return None
def _updated_at_sort_key(value: Any) -> float:
if value is None:
return float("-inf")
if isinstance(value, (int, float)):
return float(value)
raw = str(value).strip()
if not raw:
return float("-inf")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except Exception:
try:
return float(raw)
except Exception:
return float("-inf")
def _acp_stderr_print(*args, **kwargs) -> None:
"""Best-effort human-readable output sink for ACP stdio sessions.
@@ -162,47 +224,78 @@ class SessionManager:
logger.info("Forked ACP session %s -> %s", session_id, new_id)
return state
def list_sessions(self) -> List[Dict[str, Any]]:
def list_sessions(self, cwd: str | None = None) -> List[Dict[str, Any]]:
"""Return lightweight info dicts for all sessions (memory + database)."""
normalized_cwd = _normalize_cwd_for_compare(cwd) if cwd else None
db = self._get_db()
persisted_rows: dict[str, dict[str, Any]] = {}
if db is not None:
try:
for row in db.list_sessions_rich(source="acp", limit=1000):
persisted_rows[str(row["id"])] = dict(row)
except Exception:
logger.debug("Failed to load ACP sessions from DB", exc_info=True)
# Collect in-memory sessions first.
with self._lock:
seen_ids = set(self._sessions.keys())
results = [
{
"session_id": s.session_id,
"cwd": s.cwd,
"model": s.model,
"history_len": len(s.history),
}
for s in self._sessions.values()
]
results = []
for s in self._sessions.values():
history_len = len(s.history)
if history_len <= 0:
continue
if normalized_cwd and _normalize_cwd_for_compare(s.cwd) != normalized_cwd:
continue
persisted = persisted_rows.get(s.session_id, {})
preview = next(
(
str(msg.get("content") or "").strip()
for msg in s.history
if msg.get("role") == "user" and str(msg.get("content") or "").strip()
),
persisted.get("preview") or "",
)
results.append(
{
"session_id": s.session_id,
"cwd": s.cwd,
"model": s.model,
"history_len": history_len,
"title": _build_session_title(persisted.get("title"), preview, s.cwd),
"updated_at": _format_updated_at(
persisted.get("last_active") or persisted.get("started_at") or time.time()
),
}
)
# Merge any persisted sessions not currently in memory.
db = self._get_db()
if db is not None:
try:
rows = db.search_sessions(source="acp", limit=1000)
for row in rows:
sid = row["id"]
if sid in seen_ids:
continue
# Extract cwd from model_config JSON.
cwd = "."
mc = row.get("model_config")
if mc:
try:
cwd = json.loads(mc).get("cwd", ".")
except (json.JSONDecodeError, TypeError):
pass
results.append({
"session_id": sid,
"cwd": cwd,
"model": row.get("model") or "",
"history_len": row.get("message_count") or 0,
})
except Exception:
logger.debug("Failed to list ACP sessions from DB", exc_info=True)
for sid, row in persisted_rows.items():
if sid in seen_ids:
continue
message_count = int(row.get("message_count") or 0)
if message_count <= 0:
continue
# Extract cwd from model_config JSON.
session_cwd = "."
mc = row.get("model_config")
if mc:
try:
session_cwd = json.loads(mc).get("cwd", ".")
except (json.JSONDecodeError, TypeError):
pass
if normalized_cwd and _normalize_cwd_for_compare(session_cwd) != normalized_cwd:
continue
results.append({
"session_id": sid,
"cwd": session_cwd,
"model": row.get("model") or "",
"history_len": message_count,
"title": _build_session_title(row.get("title"), row.get("preview"), session_cwd),
"updated_at": _format_updated_at(row.get("last_active") or row.get("started_at")),
})
results.sort(key=lambda item: _updated_at_sort_key(item.get("updated_at")), reverse=True)
return results
def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]:
+174 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import uuid
from typing import Any, Dict, List, Optional
@@ -96,6 +97,170 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
return tool_name
def _build_patch_mode_content(patch_text: str) -> List[Any]:
"""Parse V4A patch mode input into ACP diff blocks when possible."""
if not patch_text:
return [acp.tool_content(acp.text_block(""))]
try:
from tools.patch_parser import OperationType, parse_v4a_patch
operations, error = parse_v4a_patch(patch_text)
if error or not operations:
return [acp.tool_content(acp.text_block(patch_text))]
content: List[Any] = []
for op in operations:
if op.operation == OperationType.UPDATE:
old_chunks: list[str] = []
new_chunks: list[str] = []
for hunk in op.hunks:
old_lines = [line.content for line in hunk.lines if line.prefix in (" ", "-")]
new_lines = [line.content for line in hunk.lines if line.prefix in (" ", "+")]
if old_lines or new_lines:
old_chunks.append("\n".join(old_lines))
new_chunks.append("\n".join(new_lines))
old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk)
new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk)
if old_text or new_text:
content.append(
acp.tool_diff_content(
path=op.file_path,
old_text=old_text or None,
new_text=new_text or "",
)
)
continue
if op.operation == OperationType.ADD:
added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"]
content.append(
acp.tool_diff_content(
path=op.file_path,
new_text="\n".join(added_lines),
)
)
continue
if op.operation == OperationType.DELETE:
content.append(
acp.tool_diff_content(
path=op.file_path,
old_text=f"Delete file: {op.file_path}",
new_text="",
)
)
continue
if op.operation == OperationType.MOVE:
content.append(
acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}"))
)
return content or [acp.tool_content(acp.text_block(patch_text))]
except Exception:
return [acp.tool_content(acp.text_block(patch_text))]
def _strip_diff_prefix(path: str) -> str:
raw = str(path or "").strip()
if raw.startswith(("a/", "b/")):
return raw[2:]
return raw
def _parse_unified_diff_content(diff_text: str) -> List[Any]:
"""Convert unified diff text into ACP diff content blocks."""
if not diff_text:
return []
content: List[Any] = []
current_old_path: Optional[str] = None
current_new_path: Optional[str] = None
old_lines: list[str] = []
new_lines: list[str] = []
def _flush() -> None:
nonlocal current_old_path, current_new_path, old_lines, new_lines
if current_old_path is None and current_new_path is None:
return
path = current_new_path if current_new_path and current_new_path != "/dev/null" else current_old_path
if not path or path == "/dev/null":
current_old_path = None
current_new_path = None
old_lines = []
new_lines = []
return
content.append(
acp.tool_diff_content(
path=_strip_diff_prefix(path),
old_text="\n".join(old_lines) if old_lines else None,
new_text="\n".join(new_lines),
)
)
current_old_path = None
current_new_path = None
old_lines = []
new_lines = []
for line in diff_text.splitlines():
if line.startswith("--- "):
_flush()
current_old_path = line[4:].strip()
continue
if line.startswith("+++ "):
current_new_path = line[4:].strip()
continue
if line.startswith("@@"):
continue
if current_old_path is None and current_new_path is None:
continue
if line.startswith("+"):
new_lines.append(line[1:])
elif line.startswith("-"):
old_lines.append(line[1:])
elif line.startswith(" "):
shared = line[1:]
old_lines.append(shared)
new_lines.append(shared)
_flush()
return content
def _build_tool_complete_content(
tool_name: str,
result: Optional[str],
*,
function_args: Optional[Dict[str, Any]] = None,
snapshot: Any = None,
) -> List[Any]:
"""Build structured ACP completion content, falling back to plain text."""
display_result = result or ""
if len(display_result) > 5000:
display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)"
if tool_name in {"write_file", "patch", "skill_manage"}:
try:
from agent.display import extract_edit_diff
diff_text = extract_edit_diff(
tool_name,
result,
function_args=function_args,
snapshot=snapshot,
)
if isinstance(diff_text, str) and diff_text.strip():
diff_content = _parse_unified_diff_content(diff_text)
if diff_content:
return diff_content
except Exception:
pass
return [acp.tool_content(acp.text_block(display_result))]
# ---------------------------------------------------------------------------
# Build ACP content objects for tool-call events
# ---------------------------------------------------------------------------
@@ -119,9 +284,8 @@ def build_tool_start(
new = arguments.get("new_string", "")
content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)]
else:
# Patch mode — show the patch content as text
patch_text = arguments.get("patch", "")
content = [acp.tool_content(acp.text_block(patch_text))]
content = _build_patch_mode_content(patch_text)
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
@@ -178,16 +342,17 @@ def build_tool_complete(
tool_call_id: str,
tool_name: str,
result: Optional[str] = None,
function_args: Optional[Dict[str, Any]] = None,
snapshot: Any = None,
) -> ToolCallProgress:
"""Create a ToolCallUpdate (progress) event for a completed tool call."""
kind = get_tool_kind(tool_name)
# Truncate very large results for the UI
display_result = result or ""
if len(display_result) > 5000:
display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)"
content = [acp.tool_content(acp.text_block(display_result))]
content = _build_tool_complete_content(
tool_name,
result,
function_args=function_args,
snapshot=snapshot,
)
return acp.update_tool_call(
tool_call_id,
kind=kind,
+88 -33
View File
@@ -94,6 +94,54 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:
return "custom"
return _PROVIDER_ALIASES.get(normalized, normalized)
_FIXED_TEMPERATURE_MODELS: Dict[str, float] = {
"kimi-for-coding": 0.6,
}
# Moonshot's kimi-for-coding endpoint (api.kimi.com/coding) documents:
# "k2.5 model will use a fixed value 1.0, non-thinking mode will use a fixed
# value 0.6. Any other value will result in an error." The same lock applies
# to the other k2.* models served on that endpoint. Enumerated explicitly so
# non-coding siblings like `kimi-k2-instruct` (variable temperature, served on
# the standard chat API and third parties) are NOT clamped.
# Source: https://platform.kimi.ai/docs/guide/kimi-k2-5-quickstart
_KIMI_INSTANT_MODELS: frozenset = frozenset({
"kimi-k2.5",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
})
_KIMI_THINKING_MODELS: frozenset = frozenset({
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
})
def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]:
"""Return a required temperature override for models with strict contracts.
Moonshot's kimi-for-coding endpoint rejects any non-approved temperature on
the k2.5 family. Non-thinking variants require exactly 0.6; thinking
variants require 1.0. An optional ``vendor/`` prefix (e.g.
``moonshotai/kimi-k2.5``) is tolerated for aggregator routings.
Returns ``None`` for every other model, including ``kimi-k2-instruct*``
which is the separate non-coding K2 family with variable temperature.
"""
normalized = (model or "").strip().lower()
fixed = _FIXED_TEMPERATURE_MODELS.get(normalized)
if fixed is not None:
logger.debug("Forcing temperature=%s for model %r (fixed map)", fixed, model)
return fixed
bare = normalized.rsplit("/", 1)[-1]
if bare in _KIMI_THINKING_MODELS:
logger.debug("Forcing temperature=1.0 for kimi thinking model %r", model)
return 1.0
if bare in _KIMI_INSTANT_MODELS:
logger.debug("Forcing temperature=0.6 for kimi instant model %r", model)
return 0.6
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
@@ -1064,8 +1112,6 @@ _AUTO_PROVIDER_LABELS = {
"_resolve_api_key_provider": "api-key",
}
_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"})
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode")
@@ -1196,11 +1242,15 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
"""Full auto-detection chain.
Priority:
1. If the user's main provider is NOT an aggregator (OpenRouter / Nous),
use their main provider + main model directly. This ensures users on
Alibaba, DeepSeek, ZAI, etc. get auxiliary tasks handled by the same
provider they already have credentials for — no OpenRouter key needed.
2. OpenRouter → Nous → custom → Codex → API-key providers (original chain).
1. User's main provider + main model, regardless of provider type.
This means auxiliary tasks (compression, vision, web extraction,
session search, etc.) use the same model the user configured for
chat. Users on OpenRouter/Nous get their chosen chat model; users
on DeepSeek/ZAI/Alibaba get theirs; etc. Running aux tasks on the
user's picked model keeps behavior predictable — no surprise
switches to a cheap fallback model for side tasks.
2. OpenRouter → Nous → custom → Codex → API-key providers (fallback
chain, only used when the main provider has no working client).
"""
global auxiliary_is_nous, _stale_base_url_warned
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
@@ -1230,11 +1280,16 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
)
_stale_base_url_warned = True
# ── Step 1: non-aggregator main provider → use main model directly ──
# ── Step 1: main provider + main model → use them directly ──
#
# This is the primary aux backend for every user. "auto" means
# "use my main chat model for side tasks as well" — including users
# on aggregators (OpenRouter, Nous) who previously got routed to a
# cheap provider-side default. Explicit per-task overrides set via
# config.yaml (auxiliary.<task>.provider) still win over this.
main_provider = runtime_provider or _read_main_provider()
main_model = runtime_model or _read_main_model()
if (main_provider and main_model
and main_provider not in _AGGREGATOR_PROVIDERS
and main_provider not in ("auto", "")):
resolved_provider = main_provider
explicit_base_url = None
@@ -1593,7 +1648,6 @@ def resolve_provider_client(
from hermes_cli.models import copilot_default_headers
headers.update(copilot_default_headers())
client = OpenAI(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
@@ -1817,34 +1871,31 @@ def resolve_vision_provider_client(
if requested == "auto":
# Vision auto-detection order:
# 1. Active provider + model (user's main chat config)
# 2. OpenRouter (known vision-capable default model)
# 3. Nous Portal (known vision-capable default model)
# 1. User's main provider + main model (including aggregators).
# _PROVIDER_VISION_MODELS provides per-provider vision model
# overrides when the provider has a dedicated multimodal model
# that differs from the chat model (e.g. xiaomi → mimo-v2-omni,
# zai → glm-5v-turbo).
# 2. OpenRouter (vision-capable aggregator fallback)
# 3. Nous Portal (vision-capable aggregator fallback)
# 4. Stop
main_provider = _read_main_provider()
main_model = _read_main_model()
if main_provider and main_provider not in ("auto", ""):
if main_provider in _VISION_AUTO_PROVIDER_ORDER:
# Known strict backend — use its defaults.
sync_client, default_model = _resolve_strict_vision_backend(main_provider)
if sync_client is not None:
return _finalize(main_provider, sync_client, default_model)
else:
# Exotic provider (DeepSeek, Alibaba, Xiaomi, named custom, etc.)
# Use provider-specific vision model if available, otherwise main model.
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
rpc_client, rpc_model = resolve_provider_client(
main_provider, vision_model,
api_mode=resolved_api_mode)
if rpc_client is not None:
logger.info(
"Vision auto-detect: using active provider %s (%s)",
main_provider, rpc_model or vision_model,
)
return _finalize(
main_provider, rpc_client, rpc_model or vision_model)
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
rpc_client, rpc_model = resolve_provider_client(
main_provider, vision_model,
api_mode=resolved_api_mode)
if rpc_client is not None:
logger.info(
"Vision auto-detect: using main provider %s (%s)",
main_provider, rpc_model or vision_model,
)
return _finalize(
main_provider, rpc_client, rpc_model or vision_model)
# Fall back through aggregators.
# Fall back through aggregators (uses their dedicated vision model,
# not the user's main model) when main provider has no client.
for candidate in _VISION_AUTO_PROVIDER_ORDER:
if candidate == main_provider:
continue # already tried above
@@ -2293,6 +2344,10 @@ def _build_call_kwargs(
"timeout": timeout,
}
fixed_temperature = _fixed_temperature_for_model(model)
if fixed_temperature is not None:
temperature = fixed_temperature
# Opus 4.7+ rejects any non-default temperature/top_p/top_k — silently
# drop here so auxiliary callers that hardcode temperature (e.g. 0.3 on
# flush_memories, 0 on structured-JSON extraction) don't 400 the moment
+55 -2
View File
@@ -63,6 +63,52 @@ _CHARS_PER_TOKEN = 4
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
"""Shrink long string values inside a tool-call arguments JSON blob while
preserving JSON validity.
The ``function.arguments`` field on a tool call is a JSON-encoded string
passed through to the LLM provider; downstream providers strictly
validate it and return a non-retryable 400 when it is not well-formed.
An earlier implementation sliced the raw JSON at a fixed byte offset and
appended ``...[truncated]`` — which routinely produced strings like::
{"path": "/foo/bar", "content": "# long markdown
...[truncated]
i.e. an unterminated string and a missing closing brace. MiniMax, for
example, rejects this with ``invalid function arguments json string``
and the session gets stuck re-sending the same broken history on every
turn. See issue #11762 for the observed loop.
This helper parses the arguments, shrinks long string leaves inside the
parsed structure, and re-serialises. Non-string values (paths, ints,
booleans) are preserved intact. If the arguments are not valid JSON
to begin with — some model backends use non-JSON tool arguments — the
original string is returned unchanged rather than replaced with
something neither we nor the backend can parse.
"""
try:
parsed = json.loads(args)
except (ValueError, TypeError):
return args
def _shrink(obj: Any) -> Any:
if isinstance(obj, str):
if len(obj) > head_chars:
return obj[:head_chars] + "...[truncated]"
return obj
if isinstance(obj, dict):
return {k: _shrink(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_shrink(v) for v in obj]
return obj
shrunken = _shrink(parsed)
# ensure_ascii=False preserves CJK/emoji instead of bloating with \uXXXX
return json.dumps(shrunken, ensure_ascii=False)
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
"""Create an informative 1-line summary of a tool call + result.
@@ -449,6 +495,11 @@ class ContextCompressor(ContextEngine):
# Pass 3: Truncate large tool_call arguments in assistant messages
# outside the protected tail. write_file with 50KB content, for
# example, survives pruning entirely without this.
#
# The shrinking is done inside the parsed JSON structure so the
# result remains valid JSON — otherwise downstream providers 400
# on every subsequent turn until the broken call falls out of
# the window. See ``_truncate_tool_call_args_json`` docstring.
for i in range(prune_boundary):
msg = result[i]
if msg.get("role") != "assistant" or not msg.get("tool_calls"):
@@ -459,8 +510,10 @@ class ContextCompressor(ContextEngine):
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
if len(args) > 500:
tc = {**tc, "function": {**tc["function"], "arguments": args[:200] + "...[truncated]"}}
modified = True
new_args = _truncate_tool_call_args_json(args)
if new_args != args:
tc = {**tc, "function": {**tc["function"], "arguments": new_args}}
modified = True
new_tcs.append(tc)
if modified:
result[i] = {**msg, "tool_calls": new_tcs}
+17 -122
View File
@@ -22,8 +22,6 @@ from hermes_cli.auth import (
_auth_store_lock,
_codex_access_token_is_expiring,
_decode_jwt_claims,
_import_codex_cli_tokens,
_write_codex_cli_tokens,
_load_auth_store,
_load_provider_state,
_resolve_kimi_base_url,
@@ -457,39 +455,6 @@ class CredentialPool:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential:
"""Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ.
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When the Codex CLI (or another Hermes profile) refreshes its token,
the pool entry's refresh_token becomes stale. This method detects that
by comparing against ~/.codex/auth.json and syncing the fresh pair.
"""
if self.provider != "openai-codex":
return entry
try:
cli_tokens = _import_codex_cli_tokens()
if not cli_tokens:
return entry
cli_refresh = cli_tokens.get("refresh_token", "")
cli_access = cli_tokens.get("access_token", "")
if cli_refresh and cli_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)", entry.id)
updated = replace(
entry,
access_token=cli_access,
refresh_token=cli_refresh,
last_status=None,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(entry, updated)
self._persist()
return updated
except Exception as exc:
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
return entry
def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:
"""Write refreshed pool entry tokens back to auth.json providers.
@@ -585,13 +550,6 @@ class CredentialPool:
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
elif self.provider == "openai-codex":
# Proactively sync from ~/.codex/auth.json before refresh.
# The Codex CLI (or another Hermes profile) may have already
# consumed our refresh_token. Syncing first avoids a
# "refresh_token_reused" error when the CLI has a newer pair.
synced = self._sync_codex_entry_from_cli(entry)
if synced is not entry:
entry = synced
refreshed = auth_mod.refresh_codex_oauth_pure(
entry.access_token,
entry.refresh_token,
@@ -677,45 +635,6 @@ class CredentialPool:
# Credentials file had a valid (non-expired) token — use it directly
logger.debug("Credentials file has valid token, using without refresh")
return synced
# For openai-codex: the refresh_token may have been consumed by
# the Codex CLI between our proactive sync and the refresh call.
# Re-sync and retry once.
if self.provider == "openai-codex":
synced = self._sync_codex_entry_from_cli(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json")
try:
refreshed = auth_mod.refresh_codex_oauth_pure(
synced.access_token,
synced.refresh_token,
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
last_refresh=refreshed.get("last_refresh"),
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(synced, updated)
self._persist()
self._sync_device_code_entry_to_auth_store(updated)
try:
_write_codex_cli_tokens(
updated.access_token,
updated.refresh_token,
last_refresh=updated.last_refresh,
)
except Exception as wexc:
logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc)
return updated
except Exception as retry_exc:
logger.debug("Codex retry refresh also failed: %s", retry_exc)
elif not self._entry_needs_refresh(synced):
logger.debug("Codex CLI has valid token, using without refresh")
self._sync_device_code_entry_to_auth_store(synced)
return synced
self._mark_exhausted(entry, None)
return None
@@ -734,17 +653,6 @@ class CredentialPool:
# _seed_from_singletons() on the next load_pool() sees fresh state
# instead of re-seeding stale/consumed tokens.
self._sync_device_code_entry_to_auth_store(updated)
# Write refreshed tokens back to ~/.codex/auth.json so Codex CLI
# and VS Code don't hit "refresh_token_reused" on their next refresh.
if self.provider == "openai-codex":
try:
_write_codex_cli_tokens(
updated.access_token,
updated.refresh_token,
last_refresh=updated.last_refresh,
)
except Exception as wexc:
logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc)
return updated
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
@@ -790,16 +698,6 @@ class CredentialPool:
if synced is not entry:
entry = synced
cleared_any = True
# For openai-codex entries, sync from ~/.codex/auth.json before
# any status/refresh checks. This picks up tokens refreshed by
# the Codex CLI or another Hermes profile.
if (self.provider == "openai-codex"
and entry.last_status == STATUS_EXHAUSTED
and entry.refresh_token):
synced = self._sync_codex_entry_from_cli(entry)
if synced is not entry:
entry = synced
cleared_any = True
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
@@ -1130,6 +1028,14 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
state = _load_provider_state(auth_store, "nous")
if state:
active_sources.add("device_code")
# Prefer a user-supplied label embedded in the singleton state
# (set by persist_nous_credentials(label=...) when the user ran
# `hermes auth add nous --label <name>`). Fall back to the
# auto-derived token fingerprint for logins that didn't supply one.
custom_label = str(state.get("label") or "").strip()
seeded_label = custom_label or label_from_token(
state.get("access_token", ""), "device_code"
)
changed |= _upsert_entry(
entries,
provider,
@@ -1148,7 +1054,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
"agent_key": state.get("agent_key"),
"agent_key_expires_at": state.get("agent_key_expires_at"),
"tls": state.get("tls") if isinstance(state.get("tls"), dict) else None,
"label": label_from_token(state.get("access_token", ""), "device_code"),
"label": seeded_label,
},
)
@@ -1210,8 +1116,8 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
elif provider == "openai-codex":
# Respect user suppression — `hermes auth remove openai-codex` marks
# the device_code source as suppressed so it won't be re-seeded from
# either the Hermes auth store or ~/.codex/auth.json. Without this
# gate the removal is instantly undone on the next load_pool() call.
# the Hermes auth store. Without this gate the removal is instantly
# undone on the next load_pool() call.
codex_suppressed = False
try:
from hermes_cli.auth import is_source_suppressed
@@ -1223,23 +1129,12 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
# Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth
# store has no tokens. This mirrors resolve_codex_runtime_credentials()
# so that load_pool() and list_authenticated_providers() detect tokens
# that only exist in the Codex CLI shared file.
if not (isinstance(tokens, dict) and tokens.get("access_token")):
try:
from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
logger.info("Importing Codex CLI tokens into Hermes auth store.")
_save_codex_tokens(cli_tokens)
# Re-read state after import
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex")
tokens = state.get("tokens") if isinstance(state, dict) else None
except Exception as exc:
logger.debug("Codex CLI token import failed: %s", exc)
# Hermes owns its own Codex auth state — we do NOT auto-import from
# ~/.codex/auth.json at pool-load time. OAuth refresh tokens are
# single-use, so sharing them with Codex CLI / VS Code causes
# refresh_token_reused race failures. Users who want to adopt
# existing Codex CLI credentials get a one-time, explicit prompt
# via `hermes auth openai-codex`.
if isinstance(tokens, dict) and tokens.get("access_token"):
active_sources.add("device_code")
changed |= _upsert_entry(
+135 -4
View File
@@ -747,18 +747,149 @@ class GeminiCloudCodeClient:
def _gemini_http_error(response: httpx.Response) -> CodeAssistError:
"""Translate an httpx response into a CodeAssistError with rich metadata.
Parses Google's error envelope (``{"error": {"code", "message", "status",
"details": [...]}}``) so the agent's error classifier can reason about
the failure — ``status_code`` enables the rate_limit / auth classification
paths, and ``response`` lets the main loop honor ``Retry-After`` just
like it does for OpenAI SDK exceptions.
Also lifts a few recognizable Google conditions into human-readable
messages so the user sees something better than a 500-char JSON dump:
MODEL_CAPACITY_EXHAUSTED → "Gemini model capacity exhausted for
<model>. This is a Google-side throttle..."
RESOURCE_EXHAUSTED w/o reason → quota-style message
404 → "Model <name> not found at cloudcode-pa..."
"""
status = response.status_code
# Parse the body once, surviving any weird encodings.
body_text = ""
body_json: Dict[str, Any] = {}
try:
body = response.text[:500]
body_text = response.text
except Exception:
body = ""
# Let run_agent's retry logic see auth errors as rotatable via `api_key`
body_text = ""
if body_text:
try:
parsed = json.loads(body_text)
if isinstance(parsed, dict):
body_json = parsed
except (ValueError, TypeError):
body_json = {}
# Dig into Google's error envelope. Shape is:
# {"error": {"code": 429, "message": "...", "status": "RESOURCE_EXHAUSTED",
# "details": [{"@type": ".../ErrorInfo", "reason": "MODEL_CAPACITY_EXHAUSTED",
# "metadata": {...}},
# {"@type": ".../RetryInfo", "retryDelay": "30s"}]}}
err_obj = body_json.get("error") if isinstance(body_json, dict) else None
if not isinstance(err_obj, dict):
err_obj = {}
err_status = str(err_obj.get("status") or "").strip()
err_message = str(err_obj.get("message") or "").strip()
err_details_list = err_obj.get("details") if isinstance(err_obj.get("details"), list) else []
# Extract google.rpc.ErrorInfo reason + metadata. There may be more
# than one ErrorInfo (rare), so we pick the first one with a reason.
error_reason = ""
error_metadata: Dict[str, Any] = {}
retry_delay_seconds: Optional[float] = None
for detail in err_details_list:
if not isinstance(detail, dict):
continue
type_url = str(detail.get("@type") or "")
if not error_reason and type_url.endswith("/google.rpc.ErrorInfo"):
reason = detail.get("reason")
if isinstance(reason, str) and reason:
error_reason = reason
md = detail.get("metadata")
if isinstance(md, dict):
error_metadata = md
elif retry_delay_seconds is None and type_url.endswith("/google.rpc.RetryInfo"):
# retryDelay is a google.protobuf.Duration string like "30s" or "1.5s".
delay_raw = detail.get("retryDelay")
if isinstance(delay_raw, str) and delay_raw.endswith("s"):
try:
retry_delay_seconds = float(delay_raw[:-1])
except ValueError:
pass
elif isinstance(delay_raw, (int, float)):
retry_delay_seconds = float(delay_raw)
# Fall back to the Retry-After header if the body didn't include RetryInfo.
if retry_delay_seconds is None:
try:
header_val = response.headers.get("Retry-After") or response.headers.get("retry-after")
except Exception:
header_val = None
if header_val:
try:
retry_delay_seconds = float(header_val)
except (TypeError, ValueError):
retry_delay_seconds = None
# Classify the error code. ``code_assist_rate_limited`` stays the default
# for 429s; a more specific reason tag helps downstream callers (e.g. tests,
# logs) without changing the rate_limit classification path.
code = f"code_assist_http_{status}"
if status == 401:
code = "code_assist_unauthorized"
elif status == 429:
code = "code_assist_rate_limited"
if error_reason == "MODEL_CAPACITY_EXHAUSTED":
code = "code_assist_capacity_exhausted"
# Build a human-readable message. Keep the status + a raw-body tail for
# debugging, but lead with a friendlier summary when we recognize the
# Google signal.
model_hint = ""
if isinstance(error_metadata, dict):
model_hint = str(error_metadata.get("model") or error_metadata.get("modelId") or "").strip()
if status == 429 and error_reason == "MODEL_CAPACITY_EXHAUSTED":
target = model_hint or "this Gemini model"
message = (
f"Gemini capacity exhausted for {target} (Google-side throttle, "
f"not a Hermes issue). Try a different Gemini model or set a "
f"fallback_providers entry to a non-Gemini provider."
)
if retry_delay_seconds is not None:
message += f" Google suggests retrying in {retry_delay_seconds:g}s."
elif status == 429 and err_status == "RESOURCE_EXHAUSTED":
message = (
f"Gemini quota exhausted ({err_message or 'RESOURCE_EXHAUSTED'}). "
f"Check /gquota for remaining daily requests."
)
if retry_delay_seconds is not None:
message += f" Retry suggested in {retry_delay_seconds:g}s."
elif status == 404:
# Google returns 404 when a model has been retired or renamed.
target = model_hint or (err_message or "model")
message = (
f"Code Assist 404: {target} is not available at "
f"cloudcode-pa.googleapis.com. It may have been renamed or "
f"retired. Check hermes_cli/models.py for the current list."
)
elif err_message:
# Generic fallback with the parsed message.
message = f"Code Assist HTTP {status} ({err_status or 'error'}): {err_message}"
else:
# Last-ditch fallback — raw body snippet.
message = f"Code Assist returned HTTP {status}: {body_text[:500]}"
return CodeAssistError(
f"Code Assist returned HTTP {status}: {body}",
message,
code=code,
status_code=status,
response=response,
retry_after=retry_delay_seconds,
details={
"status": err_status,
"reason": error_reason,
"metadata": error_metadata,
"message": err_message,
},
)
+37 -1
View File
@@ -68,9 +68,45 @@ _ONBOARDING_POLL_INTERVAL_SECONDS = 5.0
class CodeAssistError(RuntimeError):
def __init__(self, message: str, *, code: str = "code_assist_error") -> None:
"""Exception raised by the Code Assist (``cloudcode-pa``) integration.
Carries HTTP status / response / retry-after metadata so the agent's
``error_classifier._extract_status_code`` and the main loop's Retry-After
handling (which walks ``error.response.headers``) pick up the right
signals. Without these, 429s from the OAuth path look like opaque
``RuntimeError`` and skip the rate-limit path.
"""
def __init__(
self,
message: str,
*,
code: str = "code_assist_error",
status_code: Optional[int] = None,
response: Any = None,
retry_after: Optional[float] = None,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.code = code
# ``status_code`` is picked up by ``agent.error_classifier._extract_status_code``
# so a 429 from Code Assist classifies as FailoverReason.rate_limit and
# triggers the main loop's fallback_providers chain the same way SDK
# errors do.
self.status_code = status_code
# ``response`` is the underlying ``httpx.Response`` (or a shim with a
# ``.headers`` mapping and ``.json()`` method). The main loop reads
# ``error.response.headers["Retry-After"]`` to honor Google's retry
# hints when the backend throttles us.
self.response = response
# Parsed ``Retry-After`` seconds (kept separately for convenience —
# Google returns retry hints in both the header and the error body's
# ``google.rpc.RetryInfo`` details, and we pick whichever we found).
self.retry_after = retry_after
# Parsed structured error details from the Google error envelope
# (e.g. ``{"reason": "MODEL_CAPACITY_EXHAUSTED", "status": "RESOURCE_EXHAUSTED"}``).
# Useful for logging and for tests that want to assert on specifics.
self.details = details or {}
class ProjectIdRequiredError(CodeAssistError):
+4 -1
View File
@@ -38,6 +38,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
"mimo", "xiaomi-mimo",
"arcee-ai", "arceeai",
"xai", "x-ai", "x.ai", "grok",
"nvidia", "nim", "nvidia-nim", "nemotron",
"qwen-portal",
})
@@ -124,7 +125,6 @@ DEFAULT_CONTEXT_LENGTHS = {
"gemini": 1048576,
# Gemma (open models served via AI Studio)
"gemma-4-31b": 256000,
"gemma-4-26b": 256000,
"gemma-3": 131072,
"gemma": 8192, # fallback for older gemma models
# DeepSeek
@@ -158,6 +158,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
"kimi": 262144,
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
"nemotron": 131072,
# Arcee
"trinity": 262144,
# OpenRouter
@@ -240,6 +242,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"api.fireworks.ai": "fireworks",
"opencode.ai": "opencode-go",
"api.x.ai": "xai",
"integrate.api.nvidia.com": "nvidia",
"api.xiaomimimo.com": "xiaomi",
"xiaomimimo.com": "xiaomi",
"ollama.com": "ollama-cloud",
+43 -3
View File
@@ -420,7 +420,10 @@ def list_provider_models(provider: str) -> List[str]:
models = _get_provider_models(provider)
if models is None:
return []
return list(models.keys())
return [
mid for mid in models.keys()
if not _should_hide_from_provider_catalog(provider, mid)
]
# Patterns that indicate non-agentic or noise models (TTS, embedding,
@@ -432,6 +435,43 @@ _NOISE_PATTERNS: re.Pattern = re.compile(
re.IGNORECASE,
)
# Google's live Gemini catalogs currently include a mix of stale slugs and
# Gemma models whose TPM quotas are too small for normal Hermes agent traffic.
# Keep capability metadata available for direct/manual use, but hide these from
# the Gemini model catalogs we surface in setup and model selection.
_GOOGLE_HIDDEN_MODELS = frozenset({
# Low-TPM Gemma models that trip Google input-token quota walls under
# agent-style traffic despite advertising large context windows.
"gemma-4-31b-it",
"gemma-4-26b-it",
"gemma-4-26b-a4b-it",
"gemma-3-1b",
"gemma-3-1b-it",
"gemma-3-2b",
"gemma-3-2b-it",
"gemma-3-4b",
"gemma-3-4b-it",
"gemma-3-12b",
"gemma-3-12b-it",
"gemma-3-27b",
"gemma-3-27b-it",
# Stale/retired Google slugs that still surface through models.dev-backed
# Gemini selection but 404 on the current Google endpoints.
"gemini-1.5-flash",
"gemini-1.5-pro",
"gemini-1.5-flash-8b",
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
})
def _should_hide_from_provider_catalog(provider: str, model_id: str) -> bool:
provider_lower = (provider or "").strip().lower()
model_lower = (model_id or "").strip().lower()
if provider_lower in {"gemini", "google"} and model_lower in _GOOGLE_HIDDEN_MODELS:
return True
return False
def list_agentic_models(provider: str) -> List[str]:
"""Return model IDs suitable for agentic use from models.dev.
@@ -448,6 +488,8 @@ def list_agentic_models(provider: str) -> List[str]:
for mid, entry in models.items():
if not isinstance(entry, dict):
continue
if _should_hide_from_provider_catalog(provider, mid):
continue
if not entry.get("tool_call", False):
continue
if _NOISE_PATTERNS.search(mid):
@@ -582,5 +624,3 @@ def get_model_info(
return _parse_model_info(mid, mdata, mdev_id)
return None
+7 -6
View File
@@ -654,7 +654,7 @@ def build_skills_system_prompt(
):
continue
skills_by_category.setdefault(category, []).append(
(skill_name, entry.get("description", ""))
(frontmatter_name, entry.get("description", ""))
)
category_descriptions = {
str(k): str(v)
@@ -679,7 +679,7 @@ def build_skills_system_prompt(
):
continue
skills_by_category.setdefault(entry["category"], []).append(
(skill_name, entry["description"])
(entry["frontmatter_name"], entry["description"])
)
# Read category-level DESCRIPTION.md files
@@ -722,9 +722,10 @@ def build_skills_system_prompt(
continue
entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc)
skill_name = entry["skill_name"]
if skill_name in seen_skill_names:
frontmatter_name = entry["frontmatter_name"]
if frontmatter_name in seen_skill_names:
continue
if entry["frontmatter_name"] in disabled or skill_name in disabled:
if frontmatter_name in disabled or skill_name in disabled:
continue
if not _skill_should_show(
extract_skill_conditions(frontmatter),
@@ -732,9 +733,9 @@ def build_skills_system_prompt(
available_toolsets,
):
continue
seen_skill_names.add(skill_name)
seen_skill_names.add(frontmatter_name)
skills_by_category.setdefault(entry["category"], []).append(
(skill_name, entry["description"])
(frontmatter_name, entry["description"])
)
except Exception as e:
logger.debug("Error reading external skill %s: %s", skill_file, e)
+1
View File
@@ -24,6 +24,7 @@ model:
# "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
# "huggingface" - Hugging Face Inference (requires: HF_TOKEN)
# "nvidia" - NVIDIA NIM / build.nvidia.com (requires: NVIDIA_API_KEY)
# "xiaomi" - Xiaomi MiMo (requires: XIAOMI_API_KEY)
# "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY)
# "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings)
+345 -95
View File
@@ -18,6 +18,8 @@ import os
import shutil
import sys
import json
import re
import base64
import atexit
import tempfile
import time
@@ -78,6 +80,76 @@ _project_env = Path(__file__).parent / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
_REASONING_TAGS = (
"REASONING_SCRATCHPAD",
"think",
"thinking",
"reasoning",
"thought",
)
def _strip_reasoning_tags(text: str) -> str:
"""Remove reasoning/thinking blocks from displayed text.
Handles every case:
* Closed pairs ``<tag></tag>`` (case-insensitive, multi-line).
* Unterminated open tags that run to end-of-text (e.g. truncated
generations on NIM/MiniMax where the close tag is dropped).
* Stray orphan close tags (``stuff</think>answer``) left behind by
partial-content dumps.
Covers the variants emitted by reasoning models today: ``<think>``,
``<thinking>``, ``<reasoning>``, ``<REASONING_SCRATCHPAD>``, and
``<thought>`` (Gemma 4). Must stay in sync with
``run_agent.py::_strip_think_blocks`` and the stream consumer's
``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples.
"""
cleaned = text
for tag in _REASONING_TAGS:
# Closed pair — case-insensitive so <THINK>…</THINK> is handled too.
cleaned = re.sub(
rf"<{tag}>.*?</{tag}>\s*",
"",
cleaned,
flags=re.DOTALL | re.IGNORECASE,
)
# Unterminated open tag — strip from the tag to end of text.
cleaned = re.sub(
rf"<{tag}>.*$",
"",
cleaned,
flags=re.DOTALL | re.IGNORECASE,
)
# Stray orphan close tag left behind by partial dumps.
cleaned = re.sub(
rf"</{tag}>\s*",
"",
cleaned,
flags=re.IGNORECASE,
)
return cleaned.strip()
def _assistant_content_as_text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
str(part.get("text", ""))
for part in content
if isinstance(part, dict) and part.get("type") == "text"
]
return "\n".join(p for p in parts if p)
return str(content)
def _assistant_copy_text(content: Any) -> str:
return _strip_reasoning_tags(_assistant_content_as_text(content))
# =============================================================================
# Configuration Loading
# =============================================================================
@@ -1172,6 +1244,10 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
return None
expanded = os.path.expandvars(os.path.expanduser(token))
if os.name != "nt":
normalized = expanded.replace("\\", "/")
if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha():
expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}"
path = Path(expanded)
if not path.is_absolute():
base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd()))
@@ -1254,10 +1330,12 @@ def _detect_file_drop(user_input: str) -> "dict | None":
or stripped.startswith("~")
or stripped.startswith("./")
or stripped.startswith("../")
or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha())
or stripped.startswith('"/')
or stripped.startswith('"~')
or stripped.startswith("'/")
or stripped.startswith("'~")
or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha())
)
if not starts_like_path:
return None
@@ -1732,7 +1810,7 @@ class HermesCLI:
mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys())
invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names]
if invalid:
self.console.print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]")
self._console_print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]")
# Filesystem checkpoints: CLI flag > config
cp_cfg = CLI_CONFIG.get("checkpoints", {})
@@ -2024,20 +2102,35 @@ class HermesCLI:
def _spinner_widget_height(self, width: Optional[int] = None) -> int:
"""Return the visible height for the spinner/status text line above the status bar."""
if not getattr(self, "_spinner_text", ""):
spinner_line = self._render_spinner_text()
if not spinner_line:
return 0
if self._use_minimal_tui_chrome(width=width):
return 0
# Compute how many lines the spinner text needs when wrapped.
# The rendered text is " {emoji} {label} ({elapsed})" — about
# len(_spinner_text) + 16 chars for indent + timer suffix.
width = width or self._get_tui_terminal_width()
if width and width > 10:
import math
text_len = len(self._spinner_text) + 16 # indent + timer
return max(1, math.ceil(text_len / width))
text_width = self._status_bar_display_width(spinner_line)
return max(1, math.ceil(text_width / width))
return 1
def _render_spinner_text(self) -> str:
"""Return the live spinner/status text exactly as rendered in the TUI."""
txt = getattr(self, "_spinner_text", "")
if not txt:
return ""
t0 = getattr(self, "_tool_start_time", 0) or 0
if t0 > 0:
import time as _time
elapsed = _time.monotonic() - t0
if elapsed >= 60:
_m, _s = int(elapsed // 60), int(elapsed % 60)
elapsed_str = f"{_m}m {_s}s"
else:
elapsed_str = f"{elapsed:.1f}s"
return f" {txt} ({elapsed_str})"
return f" {txt}"
def _get_voice_status_fragments(self, width: Optional[int] = None):
"""Return the voice status bar fragments for the interactive TUI."""
width = width or self._get_tui_terminal_width()
@@ -2168,7 +2261,7 @@ class HermesCLI:
normalized_model = normalize_model_for_provider(current_model, resolved_provider)
if normalized_model and normalized_model != current_model:
if not self._model_is_default:
self.console.print(
self._console_print(
f"[yellow]⚠️ Normalized model '{current_model}' to '{normalized_model}' for {resolved_provider}.[/]"
)
self.model = normalized_model
@@ -2184,7 +2277,7 @@ class HermesCLI:
canonical = normalize_copilot_model_id(current_model, api_key=self.api_key)
if canonical and canonical != current_model:
if not self._model_is_default:
self.console.print(
self._console_print(
f"[yellow]⚠️ Normalized Copilot model '{current_model}' to '{canonical}'.[/]"
)
self.model = canonical
@@ -2206,7 +2299,7 @@ class HermesCLI:
canonical = normalize_opencode_model_id(resolved_provider, current_model)
if canonical and canonical != current_model:
if not self._model_is_default:
self.console.print(
self._console_print(
f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; using '{canonical}' for {resolved_provider}.[/]"
)
self.model = canonical
@@ -2228,7 +2321,7 @@ class HermesCLI:
if "/" in current_model:
slug = current_model.split("/", 1)[1]
if not self._model_is_default:
self.console.print(
self._console_print(
f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; "
f"using '{slug}' for OpenAI Codex.[/]"
)
@@ -2977,7 +3070,7 @@ class HermesCLI:
use_compact = self.compact or term_width < 80
if use_compact:
self.console.print(_build_compact_banner())
self._console_print(_build_compact_banner())
self._show_status()
else:
# Get tools for display
@@ -3002,25 +3095,25 @@ class HermesCLI:
# Warn about very low context lengths (common with local servers)
if ctx_len and ctx_len <= 8192:
self.console.print()
self.console.print(
self._console_print()
self._console_print(
f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — "
f"this is likely too low for agent use with tools.[/]"
)
self.console.print(
self._console_print(
"[dim] Hermes needs 16k32k minimum. Tool schemas + system prompt alone use ~4k8k.[/]"
)
base_url = getattr(self, "base_url", "") or ""
if "11434" in base_url or "ollama" in base_url.lower():
self.console.print(
self._console_print(
"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH=32768 ollama serve[/]"
)
elif "1234" in base_url:
self.console.print(
self._console_print(
"[dim] LM Studio fix: Set context length in model settings → reload model[/]"
)
else:
self.console.print(
self._console_print(
"[dim] Fix: Set model.context_length in config.yaml, or increase your server's context setting[/]"
)
@@ -3029,20 +3122,20 @@ class HermesCLI:
model_name = getattr(self, "model", "") or ""
if is_nous_hermes_non_agentic(model_name):
self.console.print()
self.console.print(
self._console_print()
self._console_print(
"[bold yellow]⚠ Nous Research Hermes 3 & 4 models are NOT agentic and are not "
"designed for use with Hermes Agent.[/]"
)
self.console.print(
self._console_print(
"[dim] They lack tool-calling capabilities required for agent workflows. "
"Consider using an agentic model (Claude, GPT, Gemini, DeepSeek, etc.).[/]"
)
self.console.print(
self._console_print(
"[dim] Switch with: /model sonnet or /model gpt5[/]"
)
self.console.print()
self._console_print()
def _preload_resumed_session(self) -> bool:
"""Load a resumed session's history from the DB early (before first chat).
@@ -3060,10 +3153,10 @@ class HermesCLI:
session_meta = self._session_db.get_session(self.session_id)
if not session_meta:
self.console.print(
self._console_print(
f"[bold red]Session not found: {self.session_id}[/]"
)
self.console.print(
self._console_print(
"[dim]Use a session ID from a previous CLI run "
"(hermes sessions list).[/]"
)
@@ -3078,7 +3171,7 @@ class HermesCLI:
if session_meta.get("title"):
title_part = f' "{session_meta["title"]}"'
accent_color = _accent_hex()
self.console.print(
self._console_print(
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
f"{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
@@ -3086,7 +3179,7 @@ class HermesCLI:
)
else:
accent_color = _accent_hex()
self.console.print(
self._console_print(
f"[{accent_color}]Session {self.session_id} found but has no "
f"messages. Starting fresh.[/]"
)
@@ -3125,21 +3218,6 @@ class HermesCLI:
MAX_ASST_LEN = 200 # truncate assistant text
MAX_ASST_LINES = 3 # max lines of assistant text
def _strip_reasoning(text: str) -> str:
"""Remove <REASONING_SCRATCHPAD>...</REASONING_SCRATCHPAD> blocks
from displayed text (reasoning model internal thoughts)."""
import re
cleaned = re.sub(
r"<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>\s*",
"", text, flags=re.DOTALL,
)
# Also strip unclosed reasoning tags at the end
cleaned = re.sub(
r"<REASONING_SCRATCHPAD>.*$",
"", cleaned, flags=re.DOTALL,
)
return cleaned.strip()
# Collect displayable entries (skip system, tool-result messages)
entries = [] # list of (role, display_text)
_last_asst_idx = None # index of last assistant entry
@@ -3171,7 +3249,7 @@ class HermesCLI:
elif role == "assistant":
text = "" if content is None else str(content)
text = _strip_reasoning(text)
text = _strip_reasoning_tags(text)
parts = []
full_parts = [] # un-truncated version
if text:
@@ -3276,7 +3354,7 @@ class HermesCLI:
padding=(0, 1),
style=_history_text_c,
)
self.console.print(panel)
self._console_print(panel)
def _try_attach_clipboard_image(self) -> bool:
"""Check clipboard for an image and attach it if found.
@@ -3510,6 +3588,26 @@ class HermesCLI:
killed = process_registry.kill_all()
print(f" ✅ Stopped {killed} process(es).")
def _handle_agents_command(self):
"""Handle /agents — show background processes and agent status."""
from tools.process_registry import format_uptime_short, process_registry
processes = process_registry.list_sessions()
running = [p for p in processes if p.get("status") == "running"]
finished = [p for p in processes if p.get("status") != "running"]
_cprint(f" Running processes: {len(running)}")
for p in running:
cmd = p.get("command", "")[:80]
up = format_uptime_short(p.get("uptime_seconds", 0))
_cprint(f" {p.get('session_id', '?')} · {up} · {cmd}")
if finished:
_cprint(f" Recently finished: {len(finished)}")
agent_running = getattr(self, "_agent_running", False)
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
def _handle_paste_command(self):
"""Handle /paste — explicitly check clipboard for an image.
@@ -3535,6 +3633,61 @@ class HermesCLI:
else:
_cprint(f" {_DIM}(._.) No image found in clipboard{_RST}")
def _write_osc52_clipboard(self, text: str) -> None:
"""Copy *text* to terminal clipboard via OSC 52."""
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
seq = f"\x1b]52;c;{payload}\x07"
out = getattr(self, "_app", None)
output = getattr(out, "output", None) if out else None
if output and hasattr(output, "write_raw"):
output.write_raw(seq)
output.flush()
return
if output and hasattr(output, "write"):
output.write(seq)
output.flush()
return
sys.stdout.write(seq)
sys.stdout.flush()
def _handle_copy_command(self, cmd_original: str) -> None:
"""Handle /copy [number] — copy assistant output to clipboard."""
parts = cmd_original.split(maxsplit=1)
arg = parts[1].strip() if len(parts) > 1 else ""
assistant = [m for m in self.conversation_history if m.get("role") == "assistant"]
if not assistant:
_cprint(" Nothing to copy yet.")
return
if arg:
try:
idx = int(arg) - 1
except ValueError:
_cprint(" Usage: /copy [number]")
return
if idx < 0 or idx >= len(assistant):
_cprint(f" Invalid response number. Use 1-{len(assistant)}.")
return
else:
idx = len(assistant) - 1
while idx >= 0 and not _assistant_copy_text(assistant[idx].get("content")):
idx -= 1
if idx < 0:
_cprint(" Nothing to copy in assistant responses yet.")
return
text = _assistant_copy_text(assistant[idx].get("content"))
if not text:
_cprint(" Nothing to copy in that assistant response.")
return
try:
self._write_osc52_clipboard(text)
_cprint(f" Copied assistant response #{idx + 1} to clipboard")
except Exception as e:
_cprint(f" Clipboard copy failed: {e}")
def _handle_image_command(self, cmd_original: str):
"""Handle /image <path> — attach a local image file for the next prompt."""
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
@@ -3637,14 +3790,14 @@ class HermesCLI:
api_key_missing = [u for u in unavailable if u["missing_vars"]]
if api_key_missing:
self.console.print()
self.console.print("[yellow]⚠️ Some tools disabled (missing API keys):[/]")
self._console_print()
self._console_print("[yellow]⚠️ Some tools disabled (missing API keys):[/]")
for item in api_key_missing:
tools_str = ", ".join(item["tools"][:2]) # Show first 2 tools
if len(item["tools"]) > 2:
tools_str += f", +{len(item['tools'])-2} more"
self.console.print(f" [dim]• {item['name']}[/] [dim italic]({', '.join(item['missing_vars'])})[/]")
self.console.print("[dim] Run 'hermes setup' to configure[/]")
self._console_print(f" [dim]• {item['name']}[/] [dim italic]({', '.join(item['missing_vars'])})[/]")
self._console_print("[dim] Run 'hermes setup' to configure[/]")
except Exception:
pass # Don't crash on import errors
@@ -3671,7 +3824,7 @@ class HermesCLI:
skin = get_active_skin()
separator_color = skin.get_color("banner_dim", "#B8860B")
accent_color = skin.get_color("ui_accent", "#FFBF00")
label_color = skin.get_color("ui_label", "#4dd0e1")
label_color = skin.get_color("ui_label", "#DAA520")
except Exception:
separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan"
toolsets_info = ""
@@ -3682,7 +3835,7 @@ class HermesCLI:
if self._provider_source:
provider_info += f" [dim {separator_color}]·[/] [dim]auth: {self._provider_source}[/]"
self.console.print(
self._console_print(
f" {api_indicator} [{accent_color}]{model_short}[/] "
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]"
f"{toolsets_info}{provider_info}"
@@ -3739,7 +3892,7 @@ class HermesCLI:
f"Tokens: {total_tokens:,}",
f"Agent Running: {'Yes' if is_running else 'No'}",
])
self.console.print("\n".join(lines), highlight=False, markup=False)
self._console_print("\n".join(lines), highlight=False, markup=False)
def _fast_command_available(self) -> bool:
try:
@@ -4937,8 +5090,15 @@ class HermesCLI:
print(" To change model or provider, use: hermes model")
def _output_console(self):
"""Use prompt_toolkit-safe Rich rendering once the TUI is live."""
if getattr(self, "_app", None):
return ChatConsole()
return self.console
def _console_print(self, *args, **kwargs):
"""Print through the active command-safe console."""
self._output_console().print(*args, **kwargs)
@staticmethod
def _resolve_personality_prompt(value) -> str:
@@ -4958,14 +5118,14 @@ class HermesCLI:
from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials
from agent.google_code_assist import retrieve_user_quota, CodeAssistError
except ImportError as exc:
self.console.print(f" [red]Gemini modules unavailable: {exc}[/]")
self._console_print(f" [red]Gemini modules unavailable: {exc}[/]")
return
try:
access_token = get_valid_access_token()
except GoogleOAuthError as exc:
self.console.print(f" [yellow]{exc}[/]")
self.console.print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.")
self._console_print(f" [yellow]{exc}[/]")
self._console_print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.")
return
creds = load_credentials()
@@ -4974,18 +5134,18 @@ class HermesCLI:
try:
buckets = retrieve_user_quota(access_token, project_id=project_id)
except CodeAssistError as exc:
self.console.print(f" [red]Quota lookup failed:[/] {exc}")
self._console_print(f" [red]Quota lookup failed:[/] {exc}")
return
if not buckets:
self.console.print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]")
self._console_print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]")
return
# Sort for stable display, group by model
buckets.sort(key=lambda b: (b.model_id, b.token_type))
self.console.print()
self.console.print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})")
self.console.print()
self._console_print()
self._console_print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})")
self._console_print()
for b in buckets:
pct = max(0.0, min(1.0, b.remaining_fraction))
width = 20
@@ -4995,8 +5155,8 @@ class HermesCLI:
header = b.model_id
if b.token_type:
header += f" [{b.token_type}]"
self.console.print(f" {header:40s} {bar} {pct_str}")
self.console.print()
self._console_print(f" {header:40s} {bar} {pct_str}")
self._console_print()
def _handle_personality_command(self, cmd: str):
"""Handle the /personality command to set predefined personalities."""
@@ -5444,7 +5604,7 @@ class HermesCLI:
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass
elif canonical == "history":
@@ -5538,7 +5698,7 @@ class HermesCLI:
elif canonical == "statusbar":
self._status_bar_visible = not self._status_bar_visible
state = "visible" if self._status_bar_visible else "hidden"
self.console.print(f" Status bar {state}")
self._console_print(f" Status bar {state}")
elif canonical == "verbose":
self._toggle_verbose()
elif canonical == "yolo":
@@ -5553,6 +5713,8 @@ class HermesCLI:
self._show_usage()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
self._handle_copy_command(cmd_original)
elif canonical == "debug":
self._handle_debug_command()
elif canonical == "paste":
@@ -5596,6 +5758,8 @@ class HermesCLI:
self._handle_snapshot_command(cmd_original)
elif canonical == "stop":
self._handle_stop_command()
elif canonical == "agents":
self._handle_agents_command()
elif canonical == "background":
self._handle_background_command(cmd_original)
elif canonical == "btw":
@@ -5612,6 +5776,30 @@ class HermesCLI:
_cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
else:
_cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}")
elif canonical == "steer":
# Inject a message after the next tool call without interrupting.
# If the agent is actively running, push the text into the agent's
# pending_steer slot — the drain hook in _execute_tool_calls_*
# will append it to the next tool result's content. If no agent
# is running, fall back to queue semantics (same as /queue).
parts = cmd_original.split(None, 1)
payload = parts[1].strip() if len(parts) > 1 else ""
if not payload:
_cprint(" Usage: /steer <prompt>")
elif self._agent_running and self.agent is not None and hasattr(self.agent, "steer"):
try:
accepted = self.agent.steer(payload)
except Exception as exc:
_cprint(f" Steer failed: {exc}")
else:
if accepted:
_cprint(f" ⏩ Steer queued — arrives after the next tool call: {payload[:80]}{'...' if len(payload) > 80 else ''}")
else:
_cprint(" Steer rejected (empty payload).")
else:
# No active run — treat as a normal next-turn message.
self._pending_input.put(payload)
_cprint(f" No agent running; queued as next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
elif canonical == "skin":
self._handle_skin_command(cmd_original)
elif canonical == "voice":
@@ -5633,15 +5821,15 @@ class HermesCLI:
)
output = result.stdout.strip() or result.stderr.strip()
if output:
self.console.print(_rich_text_from_ansi(output))
self._console_print(_rich_text_from_ansi(output))
else:
self.console.print("[dim]Command returned no output[/]")
self._console_print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
self.console.print("[bold red]Quick command timed out (30s)[/]")
self._console_print("[bold red]Quick command timed out (30s)[/]")
except Exception as e:
self.console.print(f"[bold red]Quick command error: {e}[/]")
self._console_print(f"[bold red]Quick command error: {e}[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
elif qcmd.get("type") == "alias":
target = qcmd.get("target", "").strip()
if target:
@@ -5650,9 +5838,9 @@ class HermesCLI:
aliased_command = f"{target} {user_args}".strip()
return self.process_command(aliased_command)
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
# Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import get_plugin_command_handler
@@ -6909,8 +7097,7 @@ class HermesCLI:
)
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
"Install with: pip install sounddevice numpy\n"
"Or: pip install hermes-agent[voice]"
f"Install with: {sys.executable} -m pip install sounddevice numpy"
)
if not reqs.get("stt_available", reqs.get("stt_key_set")):
raise RuntimeError(
@@ -7186,8 +7373,7 @@ class HermesCLI:
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
_cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}")
else:
_cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}")
_cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}")
_cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}")
return
with self._voice_lock:
@@ -8138,7 +8324,15 @@ class HermesCLI:
else:
print(f"\n⚡ Sending after interrupt: '{preview}'")
self._pending_input.put(combined)
# If a /steer was left over (agent finished before another tool
# batch could absorb it), deliver it as the next user turn.
_leftover_steer = result.get("pending_steer") if result else None
if _leftover_steer and hasattr(self, '_pending_input'):
preview = _leftover_steer[:60] + ("..." if len(_leftover_steer) > 60 else "")
print(f"\n⏩ Delivering leftover /steer as next turn: '{preview}'")
self._pending_input.put(_leftover_steer)
return response
except Exception as e:
@@ -8416,7 +8610,7 @@ class HermesCLI:
except Exception:
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
_welcome_color = "#FFF8DC"
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
# Show a random tip to help users discover features
try:
from hermes_cli.tips import get_random_tip
@@ -8425,16 +8619,16 @@ class HermesCLI:
_tip_color = _welcome_skin.get_color("banner_dim", "#B8860B")
except Exception:
_tip_color = "#B8860B"
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
except Exception:
pass # Tips are non-critical — never break startup
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
self.console.print(
self._console_print(
f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}"
)
self._startup_skills_line_shown = True
self.console.print()
self._console_print()
# State for async operation
self._agent_running = False
@@ -9237,21 +9431,10 @@ class HermesCLI:
return cli_ref._agent_spacer_height()
def get_spinner_text():
txt = cli_ref._spinner_text
if not txt:
spinner_line = cli_ref._render_spinner_text()
if not spinner_line:
return []
# Append live elapsed timer when a tool is running
t0 = cli_ref._tool_start_time
if t0 > 0:
import time as _time
elapsed = _time.monotonic() - t0
if elapsed >= 60:
_m, _s = int(elapsed // 60), int(elapsed % 60)
elapsed_str = f"{_m}m {_s}s"
else:
elapsed_str = f"{elapsed:.1f}s"
return [('class:hint', f' {txt} ({elapsed_str})')]
return [('class:hint', f' {txt}')]
return [('class:hint', spinner_line)]
def get_spinner_height():
return cli_ref._spinner_widget_height()
@@ -9959,8 +10142,36 @@ class HermesCLI:
# Register signal handlers for graceful shutdown on SSH disconnect / SIGTERM
def _signal_handler(signum, frame):
"""Handle SIGHUP/SIGTERM by triggering graceful cleanup."""
"""Handle SIGHUP/SIGTERM by triggering graceful cleanup.
Calls ``self.agent.interrupt()`` first so the agent daemon
thread's poll loop sees the per-thread interrupt and kills the
tool's subprocess group via ``_kill_process`` (os.killpg).
Without this, the main thread dies from KeyboardInterrupt and
the daemon thread is killed with it before it can run one
more poll iteration to clean up the subprocess, which was
spawned with ``os.setsid`` and therefore survives as an orphan
with PPID=1.
Grace window (``HERMES_SIGTERM_GRACE``, default 1.5 s) gives
the daemon time to: detect the interrupt (next 200 ms poll)
call _kill_process (SIGTERM + 1 s wait + SIGKILL if needed)
return from _wait_for_process. ``time.sleep`` releases the
GIL so the daemon actually runs during the window.
"""
logger.debug("Received signal %s, triggering graceful shutdown", signum)
try:
if getattr(self, "agent", None) and getattr(self, "_agent_running", False):
self.agent.interrupt(f"received signal {signum}")
import time as _t
try:
_grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5"))
except (TypeError, ValueError):
_grace = 1.5
if _grace > 0:
_t.sleep(_grace)
except Exception:
pass # never block signal handling
raise KeyboardInterrupt()
try:
@@ -10263,6 +10474,45 @@ def main(
# Register cleanup for single-query mode (interactive mode registers in run())
atexit.register(_run_cleanup)
# Also install signal handlers in single-query / `-q` mode. Interactive
# mode registers its own inside HermesCLI.run(), but `-q` runs
# cli.agent.run_conversation() below and AIAgent spawns worker threads
# for tools — so when SIGTERM arrives on the main thread, raising
# KeyboardInterrupt only unwinds the main thread, not the worker
# running _wait_for_process. Python then exits, the child subprocess
# (spawned with os.setsid, its own process group) is reparented to
# init and keeps running as an orphan.
#
# Fix: route SIGTERM/SIGHUP through agent.interrupt() which sets the
# per-thread interrupt flag the worker's poll loop checks every 200 ms.
# Give the worker a grace window to call _kill_process (SIGTERM to the
# process group, then SIGKILL after 1 s), then raise KeyboardInterrupt
# so main unwinds normally. HERMES_SIGTERM_GRACE overrides the 1.5 s
# default for debugging.
def _signal_handler_q(signum, frame):
logger.debug("Received signal %s in single-query mode", signum)
try:
_agent = getattr(cli, "agent", None)
if _agent is not None:
_agent.interrupt(f"received signal {signum}")
import time as _t
try:
_grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5"))
except (TypeError, ValueError):
_grace = 1.5
if _grace > 0:
_t.sleep(_grace)
except Exception:
pass # never block signal handling
raise KeyboardInterrupt()
try:
import signal as _signal
_signal.signal(_signal.SIGTERM, _signal_handler_q)
if hasattr(_signal, "SIGHUP"):
_signal.signal(_signal.SIGHUP, _signal_handler_q)
except Exception:
pass # signal handler may fail in restricted environments
# Handle single query mode
if query or image:
+85 -6
View File
@@ -65,7 +65,15 @@ _HOME_TARGET_ENV_VARS = {
"wecom": "WECOM_HOME_CHANNEL",
"weixin": "WEIXIN_HOME_CHANNEL",
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
"qqbot": "QQ_HOME_CHANNEL",
"qqbot": "QQBOT_HOME_CHANNEL",
}
# Legacy env var names kept for back-compat. Each entry is the current
# primary env var → the previous name. _get_home_target_chat_id falls
# back to the legacy name if the primary is unset, so users who set the
# old name before the rename keep working until they migrate.
_LEGACY_HOME_TARGET_ENV_VARS = {
"QQBOT_HOME_CHANNEL": "QQ_HOME_CHANNEL",
}
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
@@ -100,7 +108,12 @@ def _get_home_target_chat_id(platform_name: str) -> str:
env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower())
if not env_var:
return ""
return os.getenv(env_var, "")
value = os.getenv(env_var, "")
if not value:
legacy = _LEGACY_HOME_TARGET_ENV_VARS.get(env_var)
if legacy:
value = os.getenv(legacy, "")
return value
def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]:
@@ -551,15 +564,53 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
return False, f"Script execution failed: {exc}"
def _build_job_prompt(job: dict) -> str:
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
def _parse_wake_gate(script_output: str) -> bool:
"""Parse the last non-empty stdout line of a cron job's pre-check script
as a wake gate.
The convention (ported from nanoclaw #1232): if the last stdout line is
JSON like ``{"wakeAgent": false}``, the agent is skipped entirely no
LLM run, no delivery. Any other output (non-JSON, missing flag, gate
absent, or ``wakeAgent: true``) means wake the agent normally.
Returns True if the agent should wake, False to skip.
"""
if not script_output:
return True
stripped_lines = [line for line in script_output.splitlines() if line.strip()]
if not stripped_lines:
return True
last_line = stripped_lines[-1].strip()
try:
gate = json.loads(last_line)
except (json.JSONDecodeError, ValueError):
return True
if not isinstance(gate, dict):
return True
return gate.get("wakeAgent", True) is not False
def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
"""Build the effective prompt for a cron job, optionally loading one or more skills first.
Args:
job: The cron job dict.
prerun_script: Optional ``(success, stdout)`` from a script that has
already been executed by the caller (e.g. for a wake-gate check).
When provided, the script is not re-executed and the cached
result is used for prompt injection. When omitted, the script
(if any) runs inline as before.
"""
prompt = job.get("prompt", "")
skills = job.get("skills")
# Run data-collection script if configured, inject output as context.
script_path = job.get("script")
if script_path:
success, script_output = _run_job_script(script_path)
if prerun_script is not None:
success, script_output = prerun_script
else:
success, script_output = _run_job_script(script_path)
if success:
if script_output:
prompt = (
@@ -661,13 +712,41 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
job_id = job["id"]
job_name = job["name"]
prompt = _build_job_prompt(job)
# Wake-gate: if this job has a pre-check script, run it BEFORE building
# the prompt so a ``{"wakeAgent": false}`` response can short-circuit
# the whole agent run. We pass the result into _build_job_prompt so
# the script is only executed once.
prerun_script = None
script_path = job.get("script")
if script_path:
prerun_script = _run_job_script(script_path)
_ran_ok, _script_output = prerun_script
if _ran_ok and not _parse_wake_gate(_script_output):
logger.info(
"Job '%s' (ID: %s): wakeAgent=false, skipping agent run",
job_name, job_id,
)
silent_doc = (
f"# Cron Job: {job_name}\n\n"
f"**Job ID:** {job_id}\n"
f"**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
"Script gate returned `wakeAgent=false` — agent skipped.\n"
)
return True, silent_doc, SILENT_MARKER, None
prompt = _build_job_prompt(job, prerun_script=prerun_script)
origin = _resolve_origin(job)
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
logger.info("Prompt: %s", prompt[:100])
# Mark this as a cron session so the approval system can apply cron_mode.
# This env var is process-wide and persists for the lifetime of the
# scheduler process — every job this process runs is a cron job.
os.environ["HERMES_CRON_SESSION"] = "1"
try:
# Inject origin context so the agent's send_message tool knows the chat.
# Must be INSIDE the try block so the finally cleanup always runs.
@@ -0,0 +1,108 @@
# Ink Gateway TUI Migration — Post-mortem
Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, classic (prompt_toolkit) CLI still present
## What Shipped
Three layers, same repo, Python runtime unchanged.
```
ui-tui (Node/TS) ──stdio JSON-RPC──▶ tui_gateway (Py) ──▶ AIAgent (run_agent.py)
```
### Backend — `tui_gateway/`
```
tui_gateway/
├── entry.py # subprocess entrypoint, stdio read/write loop
├── server.py # everything: sessions dict, @method handlers, _emit
├── render.py # stream renderer, diff rendering, message rendering
├── slash_worker.py # subprocess that runs hermes_cli slash commands
└── __init__.py
```
`server.py` owns the full runtime-control surface: session store (`_sessions: dict[str, dict]`), method registry (`@method("…")` decorator), event emitter (`_emit`), agent lifecycle (`_make_agent`, `_init_session`, `_wire_callbacks`), approval/sudo/clarify round-trips, and JSON-RPC dispatch.
Protocol methods (`@method(...)` in `server.py`):
- session: `session.{create, resume, list, close, interrupt, usage, history, compress, branch, title, save, undo}`
- prompt: `prompt.{submit, background, btw}`
- tools: `tools.{list, show, configure}`
- slash: `slash.exec`, `command.{dispatch, resolve}`, `commands.catalog`, `complete.{path, slash}`
- approvals: `approval.respond`, `sudo.respond`, `clarify.respond`, `secret.respond`
- config/state: `config.{get, set, show}`, `model.options`, `reload.mcp`
- ops: `shell.exec`, `cli.exec`, `terminal.resize`, `input.detect_drop`, `clipboard.paste`, `paste.collapse`, `image.attach`, `process.stop`
- misc: `agents.list`, `skills.manage`, `plugins.list`, `cron.manage`, `insights.get`, `rollback.{list, diff, restore}`, `browser.manage`
Protocol events (`_emit(…)` → handled in `ui-tui/src/app/createGatewayEventHandler.ts`):
- lifecycle: `gateway.{ready, stderr}`, `session.info`, `skin.changed`
- stream: `message.{start, delta, complete}`, `thinking.delta`, `reasoning.{delta, available}`, `status.update`
- tools: `tool.{start, progress, complete, generating}`, `subagent.{start, thinking, tool, progress, complete}`
- interactive: `approval.request`, `sudo.request`, `clarify.request`, `secret.request`
- async: `background.complete`, `btw.complete`, `error`
### Frontend — `ui-tui/src/`
```
src/
├── entry.tsx # node bootstrap: bootBanner → spawn python → dynamic-import Ink → render(<App/>)
├── app.tsx # <GatewayProvider> wraps <AppLayout>
├── bootBanner.ts # raw-ANSI banner to stdout in ~2ms, pre-React
├── gatewayClient.ts # JSON-RPC client over child_process stdio
├── gatewayTypes.ts # typed RPC responses + GatewayEvent union
├── theme.ts # DEFAULT_THEME + fromSkin
├── app/ # hooks + stores — the orchestration layer
│ ├── uiStore.ts # nanostore: sid, info, busy, usage, theme, status…
│ ├── turnStore.ts # nanostore: per-turn activity / reasoning / tools
│ ├── turnController.ts # imperative singleton for stream-time operations
│ ├── overlayStore.ts # nanostore: modal/overlay state
│ ├── useMainApp.ts # top-level composition hook
│ ├── useSessionLifecycle.ts # session.create/resume/close/reset
│ ├── useSubmission.ts # shell/slash/prompt dispatch + interpolation
│ ├── useConfigSync.ts # config.get + mtime poll
│ ├── useComposerState.ts # input buffer, paste snippets, editor mode
│ ├── useInputHandlers.ts # key bindings
│ ├── createGatewayEventHandler.ts # event-stream dispatcher
│ ├── createSlashHandler.ts # slash command router (registry + python fallback)
│ └── slash/commands/ # core.ts, ops.ts, session.ts — TS-owned slash commands
├── components/ # AppLayout, AppChrome, AppOverlays, MessageLine, Thinking, Markdown, pickers, prompts, Banner, SessionPanel
├── config/ # env, limits, timing constants
├── content/ # charms, faces, fortunes, hotkeys, placeholders, verbs
├── domain/ # details, messages, paths, roles, slash, usage, viewport
├── protocol/ # interpolation, paste regex
├── hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory
└── lib/ # history, messages, osc52, rpc, text
```
### CLI entry points — `hermes_cli/main.py`
- `hermes --tui``node dist/entry.js` (auto-builds when `.ts`/`.tsx` newer than `dist/entry.js`)
- `hermes --tui --dev``tsx src/entry.tsx` (skip build)
- `HERMES_TUI_DIR=…` → external prebuilt dist (nix, distro packaging)
## Diverged From Original Plan
| Plan | Reality | Why |
|---|---|---|
| `tui_gateway/{controller,session_state,events,protocol}.py` | all collapsed into `server.py` | no second consumer ever emerged, keeping one file cheaper than four |
| `ui-tui/src/main.tsx` | split into `entry.tsx` (bootstrap) + `app.tsx` (shell) | boot banner + early python spawn wanted a pre-React moment |
| `ui-tui/src/state/store.ts` | three nanostores (`uiStore`, `turnStore`, `overlayStore`) | separate lifetimes: ui persists, turn resets per reply, overlay is modal |
| `approval.requested` / `sudo.requested` / `clarify.requested` | `*.request` (no `-ed`) | cosmetic |
| `session.cancel` | dropped | `session.interrupt` covers it |
| `HERMES_EXPERIMENTAL_TUI=1`, `display.experimental_tui: true`, `/tui on/off/status` | none shipped | `--tui` went from opt-in to first-class without an experimental phase |
## Post-migration Additions (not in original plan)
- **Async `session.create`** — returns sid in ~1ms, agent builds on a background thread, `session.info` broadcasts when ready; `_wait_agent()` gates every agent-touching handler via `_sess`
- **`bootBanner`** — raw-ANSI logo painted to stdout at T≈2ms, before Ink loads; `<AlternateScreen>` wipes it seamlessly when React mounts
- **Selection uniform bg**`theme.color.selectionBg` wired via `useSelection().setSelectionBgColor`; replaces SGR-inverse per-cell swap that fragmented over amber/gold fg
- **Slash command registry** — TS-owned commands in `app/slash/commands/{core,ops,session}.ts`, everything else falls through to `slash.exec` (python worker)
- **Turn store + controller split** — imperative singleton (`turnController`) holds refs/timers, nanostore (`turnStore`) holds render-visible state
## What's Still Open
- **Classic CLI not deleted.** `cli.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · prompt_toolkit removal later" hasn't happened.
- **No config-file opt-in.** `HERMES_EXPERIMENTAL_TUI` and `display.experimental_tui` were never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line in `main.py` flips it.
+36 -27
View File
@@ -6,6 +6,11 @@
# All fields are optional — missing values inherit from the default skin.
# Activate with: /skin <name> or display.skin: <name> in config.yaml
#
# Keys are marked:
# (both) — applies to both the classic CLI and the TUI
# (classic) — classic CLI only (see hermes --tui in user-guide/tui.md)
# (tui) — TUI only
#
# See hermes_cli/skin_engine.py for the full schema reference.
# ============================================================================
@@ -14,43 +19,47 @@ name: example
description: An example custom skin — copy and modify this template
# ── Colors ──────────────────────────────────────────────────────────────────
# Hex color values for Rich markup. These control the CLI's visual palette.
# Hex color values. These control the visual palette.
colors:
# Banner panel (the startup welcome box)
# Banner panel (the startup welcome box) — (both)
banner_border: "#CD7F32" # Panel border
banner_title: "#FFD700" # Panel title text
banner_accent: "#FFBF00" # Section headers (Available Tools, Skills, etc.)
banner_dim: "#B8860B" # Dim/muted text (separators, model info)
banner_text: "#FFF8DC" # Body text (tool names, skill names)
# UI elements
ui_accent: "#FFBF00" # General accent color
# UI elements — (both)
ui_accent: "#FFBF00" # General accent (falls back to banner_accent)
ui_label: "#4dd0e1" # Labels
ui_ok: "#4caf50" # Success indicators
ui_error: "#ef5350" # Error indicators
ui_warn: "#ffa726" # Warning indicators
# Input area
prompt: "#FFF8DC" # Prompt text color
input_rule: "#CD7F32" # Horizontal rule around input
prompt: "#FFF8DC" # Prompt text / `` glyph color (both)
input_rule: "#CD7F32" # Horizontal rule above input (classic)
# Response box
response_border: "#FFD700" # Response box border (ANSI color)
# Response box — (classic)
response_border: "#FFD700" # Response box border
# Session display
session_label: "#DAA520" # Session label
session_border: "#8B8682" # Session ID dim color
# Session display — (both)
session_label: "#DAA520" # "Session: " label
session_border: "#8B8682" # Session ID text
# TUI surfaces
status_bar_bg: "#1a1a2e" # Status / usage bar background
voice_status_bg: "#1a1a2e" # Voice-mode badge background
completion_menu_bg: "#1a1a2e" # Completion list background
completion_menu_current_bg: "#333355" # Active completion row background
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
completion_menu_meta_current_bg: "#333355" # Active completion meta background
# TUI / CLI surfaces — (classic: status bar, voice badge, completion meta)
status_bar_bg: "#1a1a2e" # Status / usage bar background (classic)
voice_status_bg: "#1a1a2e" # Voice-mode badge background (classic)
completion_menu_bg: "#1a1a2e" # Completion list background (both)
completion_menu_current_bg: "#333355" # Active completion row background (both)
completion_menu_meta_bg: "#1a1a2e" # Completion meta column bg (classic)
completion_menu_meta_current_bg: "#333355" # Active meta bg (classic)
# Drag-to-select background — (tui)
selection_bg: "#3a3a55" # Uniform selection highlight in the TUI
# ── Spinner ─────────────────────────────────────────────────────────────────
# Customize the animated spinner shown during API calls and tool execution.
# (classic) — the TUI uses its own animated indicators; spinner config here
# is only read by the classic prompt_toolkit CLI.
spinner:
# Faces shown while waiting for the API response
waiting_faces:
@@ -78,17 +87,17 @@ spinner:
# - ["⟪▲", "▲⟫"]
# ── Branding ────────────────────────────────────────────────────────────────
# Text strings used throughout the CLI interface.
# Text strings used throughout the interface.
branding:
agent_name: "Hermes Agent" # Banner title, about display
welcome: "Welcome! Type your message or /help for commands."
goodbye: "Goodbye! ⚕" # Exit message
response_label: " ⚕ Hermes " # Response box header label
prompt_symbol: " " # Input prompt symbol
help_header: "(^_^)? Available Commands" # /help header text
agent_name: "Hermes Agent" # (both) Banner title, about display
welcome: "Welcome! Type your message or /help for commands." # (both)
goodbye: "Goodbye! ⚕" # (both) Exit message
response_label: " ⚕ Hermes " # (classic) Response box header label
prompt_symbol: " " # (both) Input prompt glyph
help_header: "(^_^)? Available Commands" # (both) /help overlay title
# ── Tool Output ─────────────────────────────────────────────────────────────
# Character used as the prefix for tool output lines.
# Character used as the prefix for tool output lines. (both)
# Default is "┊" (thin dotted vertical line). Some alternatives:
# "╎" (light triple dash vertical)
# "▏" (left one-eighth block)
Generated
+21
View File
@@ -36,6 +36,26 @@
"type": "github"
}
},
"npm-lockfile-fix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1775903712,
"narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=",
"owner": "jeslie0",
"repo": "npm-lockfile-fix",
"rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8",
"type": "github"
},
"original": {
"owner": "jeslie0",
"repo": "npm-lockfile-fix",
"type": "github"
}
},
"pyproject-build-systems": {
"inputs": {
"nixpkgs": [
@@ -124,6 +144,7 @@
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"npm-lockfile-fix": "npm-lockfile-fix",
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix_2",
"uv2nix": "uv2nix_2"
+11 -2
View File
@@ -19,11 +19,20 @@
url = "github:pyproject-nix/build-system-pkgs";
inputs.nixpkgs.follows = "nixpkgs";
};
npm-lockfile-fix = {
url = "github:jeslie0/npm-lockfile-fix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs:
outputs =
inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
imports = [
./nix/packages.nix
+19 -1
View File
@@ -100,7 +100,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
def _build_discord(adapter) -> List[Dict[str, str]]:
"""Enumerate all text channels the Discord bot can see."""
"""Enumerate all text channels and forum channels the Discord bot can see."""
channels = []
client = getattr(adapter, "_client", None)
if not client:
@@ -119,6 +119,15 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
"guild": guild.name,
"type": "channel",
})
# Forum channels (type 15) — creating a message auto-spawns a thread post.
forums = getattr(guild, "forum_channels", None) or []
for ch in forums:
channels.append({
"id": str(ch.id),
"name": ch.name,
"guild": guild.name,
"type": "forum",
})
# Also include DM-capable users we've interacted with is not
# feasible via guild enumeration; those come from sessions.
@@ -191,6 +200,15 @@ def load_directory() -> Dict[str, Any]:
return {"updated_at": None, "platforms": {}}
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
"""Return the channel ``type`` string (e.g. ``"channel"``, ``"forum"``) for *chat_id*, or *None* if unknown."""
directory = load_directory()
for ch in directory.get("platforms", {}).get(platform_name, []):
if ch.get("id") == chat_id:
return ch.get("type")
return None
def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
"""
Resolve a human-friendly channel name to a numeric ID.
+30 -2
View File
@@ -258,6 +258,13 @@ class GatewayConfig:
# Streaming configuration
streaming: StreamingConfig = field(default_factory=StreamingConfig)
# Session store pruning: drop SessionEntry records older than this many
# days from the in-memory dict and sessions.json. Keeps the store from
# growing unbounded in gateways serving many chats/threads/users over
# months. Pruning is invisible to users — if they resume, they get a
# fresh session exactly as if the reset policy had fired. 0 = disabled.
session_store_max_age_days: int = 90
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@@ -365,6 +372,7 @@ class GatewayConfig:
"thread_sessions_per_user": self.thread_sessions_per_user,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
}
@classmethod
@@ -412,6 +420,13 @@ class GatewayConfig:
"pair",
)
try:
session_store_max_age_days = int(data.get("session_store_max_age_days", 90))
if session_store_max_age_days < 0:
session_store_max_age_days = 0
except (TypeError, ValueError):
session_store_max_age_days = 90
return cls(
platforms=platforms,
default_reset_policy=default_policy,
@@ -426,6 +441,7 @@ class GatewayConfig:
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@@ -1213,12 +1229,24 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
qq_group_allowed = os.getenv("QQ_GROUP_ALLOWED_USERS", "").strip()
if qq_group_allowed:
extra["group_allow_from"] = qq_group_allowed
qq_home = os.getenv("QQ_HOME_CHANNEL", "").strip()
qq_home = os.getenv("QQBOT_HOME_CHANNEL", "").strip()
qq_home_name_env = "QQBOT_HOME_CHANNEL_NAME"
if not qq_home:
# Back-compat: accept the pre-rename name and log a one-time warning.
legacy_home = os.getenv("QQ_HOME_CHANNEL", "").strip()
if legacy_home:
qq_home = legacy_home
qq_home_name_env = "QQ_HOME_CHANNEL_NAME"
import logging
logging.getLogger(__name__).warning(
"QQ_HOME_CHANNEL is deprecated; rename to QQBOT_HOME_CHANNEL "
"in your .env for consistency with the platform key."
)
if qq_home:
config.platforms[Platform.QQBOT].home_channel = HomeChannel(
platform=Platform.QQBOT,
chat_id=qq_home,
name=os.getenv("QQ_HOME_CHANNEL_NAME", "Home"),
name=os.getenv("QQBOT_HOME_CHANNEL_NAME") or os.getenv(qq_home_name_env, "Home"),
)
# Session settings
+76 -4
View File
@@ -669,6 +669,15 @@ class MessageEvent:
# Original platform data
raw_message: Any = None
message_id: Optional[str] = None
# Platform-specific update identifier. For Telegram this is the
# ``update_id`` from the PTB Update wrapper; other platforms currently
# ignore it. Used by ``/restart`` to record the triggering update so the
# new gateway can advance the Telegram offset past it and avoid processing
# the same ``/restart`` twice if PTB's graceful-shutdown ACK times out
# ("Error while calling `get_updates` one more time to mark all fetched
# updates" in gateway.log).
platform_update_id: Optional[int] = None
# Media attachments
# media_urls: local file paths (for vision tool access)
@@ -1045,16 +1054,40 @@ class BasePlatformAdapter(ABC):
"""
pass
# Default: the adapter treats ``finalize=True`` on edit_message as a
# no-op and is happy to have the stream consumer skip redundant final
# edits. Subclasses that *require* an explicit finalize call to close
# out the message lifecycle (e.g. rich card / AI assistant surfaces
# such as DingTalk AI Cards) override this to True (class attribute or
# property) so the stream consumer knows not to short-circuit.
REQUIRES_EDIT_FINALIZE: bool = False
async def edit_message(
self,
chat_id: str,
message_id: str,
content: str,
*,
finalize: bool = False,
) -> SendResult:
"""
Edit a previously sent message. Optional platforms that don't
support editing return success=False and callers fall back to
sending a new message.
``finalize`` signals that this is the last edit in a streaming
sequence. Most platforms (Telegram, Slack, Discord, Matrix,
etc.) treat it as a no-op because their edit APIs have no notion
of message lifecycle state an edit is an edit. Platforms that
render streaming updates with a distinct "in progress" state and
require explicit closure (e.g. rich card / AI assistant surfaces
such as DingTalk AI Cards) use it to finalize the message and
transition the UI out of the streaming indicator those should
also set ``REQUIRES_EDIT_FINALIZE = True`` so callers route a
final edit through even when content is unchanged. Callers
should set ``finalize=True`` on the final edit of a streamed
response (typically when ``got_done`` fires in the stream
consumer) and leave it ``False`` on intermediate edits.
"""
return SendResult(success=False, error="Not supported")
@@ -1579,7 +1612,9 @@ class BasePlatformAdapter(ABC):
# session lifecycle and its cleanup races with the running task
# (see PR #4926).
cmd = event.get_command()
if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background", "restart", "queue", "q"):
from hermes_cli.commands import should_bypass_active_session
if should_bypass_active_session(cmd):
logger.debug(
"[%s] Command '/%s' bypassing active-session guard for %s",
self.name, cmd, session_key,
@@ -1891,9 +1926,18 @@ class BasePlatformAdapter(ABC):
if session_key in self._pending_messages:
pending_event = self._pending_messages.pop(session_key)
logger.debug("[%s] Processing queued message from interrupt", self.name)
# Clean up current session before processing pending
if session_key in self._active_sessions:
del self._active_sessions[session_key]
# Keep the _active_sessions entry live across the turn chain
# and only CLEAR the interrupt Event — do NOT delete the entry.
# If we deleted here, a concurrent inbound message arriving
# during the awaits below would pass the Level-1 guard, spawn
# its own _process_message_background, and run simultaneously
# with the recursive drain below. Two agents on one
# session_key = duplicate responses, duplicate tool calls.
# Clearing the Event keeps the guard live so follow-ups take
# the busy-handler path (queue + interrupt) as intended.
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
typing_task.cancel()
try:
await typing_task
@@ -1951,6 +1995,34 @@ class BasePlatformAdapter(ABC):
await self.stop_typing(event.source.chat_id)
except Exception:
pass
# Late-arrival drain: a message may have arrived during the
# cleanup awaits above (typing_task cancel, stop_typing). Such
# messages passed the Level-1 guard (entry still live, Event
# possibly set) and landed in _pending_messages via the
# busy-handler path. Without this block, we would delete the
# active-session entry and the queued message would be silently
# dropped (user never gets a reply).
late_pending = self._pending_messages.pop(session_key, None)
if late_pending is not None:
logger.debug(
"[%s] Late-arrival pending message during cleanup — spawning drain task",
self.name,
)
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
drain_task = asyncio.create_task(
self._process_message_background(late_pending, session_key)
)
try:
self._background_tasks.add(drain_task)
drain_task.add_done_callback(self._background_tasks.discard)
except TypeError:
# Tests stub create_task() with non-hashable sentinels; tolerate.
pass
# Leave _active_sessions[session_key] populated — the drain
# task's own lifecycle will clean it up.
return
# Clean up session tracking
if session_key in self._active_sessions:
del self._active_sessions[session_key]
File diff suppressed because it is too large Load Diff
+195 -2
View File
@@ -857,6 +857,9 @@ class DiscordAdapter(BasePlatformAdapter):
When metadata contains a thread_id, the message is sent to that
thread instead of the parent channel identified by chat_id.
Forum channels (type 15) reject direct messages a thread post is
created automatically.
"""
if not self._client:
return SendResult(success=False, error="Not connected")
@@ -882,6 +885,10 @@ class DiscordAdapter(BasePlatformAdapter):
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
# Forum channels reject channel.send() — create a thread post instead.
if self._is_forum_parent(channel):
return await self._send_to_forum(channel, content)
# Format and split message if needed
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
@@ -945,6 +952,120 @@ class DiscordAdapter(BasePlatformAdapter):
logger.error("[%s] Failed to send Discord message: %s", self.name, e, exc_info=True)
return SendResult(success=False, error=str(e))
async def _send_to_forum(self, forum_channel: Any, content: str) -> SendResult:
"""Create a thread post in a forum channel with the message as starter content.
Forum channels (type 15) don't support direct messages. Instead we
POST to /channels/{forum_id}/threads with a thread name derived from
the first line of the message. Any follow-up chunk failures are
reported in ``raw_response['warnings']`` so the caller can surface
partial-send issues.
"""
from tools.send_message_tool import _derive_forum_thread_name
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
thread_name = _derive_forum_thread_name(content)
starter_content = chunks[0] if chunks else thread_name
try:
thread = await forum_channel.create_thread(
name=thread_name,
content=starter_content,
)
except Exception as e:
logger.error("[%s] Failed to create forum thread in %s: %s", self.name, forum_channel.id, e)
return SendResult(success=False, error=f"Forum thread creation failed: {e}")
thread_channel = thread if hasattr(thread, "send") else getattr(thread, "thread", None)
thread_id = str(getattr(thread_channel, "id", getattr(thread, "id", "")))
starter_msg = getattr(thread, "message", None)
message_id = str(getattr(starter_msg, "id", thread_id)) if starter_msg else thread_id
# Send remaining chunks into the newly created thread. Track any
# per-chunk failures so the caller sees partial-send outcomes.
message_ids = [message_id]
warnings: list[str] = []
for chunk in chunks[1:]:
try:
msg = await thread_channel.send(content=chunk)
message_ids.append(str(msg.id))
except Exception as e:
warning = f"Failed to send follow-up chunk to forum thread {thread_id}: {e}"
logger.warning("[%s] %s", self.name, warning)
warnings.append(warning)
raw_response: Dict[str, Any] = {"message_ids": message_ids, "thread_id": thread_id}
if warnings:
raw_response["warnings"] = warnings
return SendResult(
success=True,
message_id=message_ids[0],
raw_response=raw_response,
)
async def _forum_post_file(
self,
forum_channel: Any,
*,
thread_name: Optional[str] = None,
content: str = "",
file: Any = None,
files: Optional[list] = None,
) -> SendResult:
"""Create a forum thread whose starter message carries file attachments.
Used by the send_voice / send_image_file / send_document paths when
the target channel is a forum (type 15). ``create_thread`` on a
ForumChannel accepts the same file/files/content kwargs as
``channel.send``, creating the thread and starter message atomically.
"""
from tools.send_message_tool import _derive_forum_thread_name
if not thread_name:
# Prefer the text content, fall back to the first attached
# filename, fall back to the generic default.
hint = content or ""
if not hint.strip():
if file is not None:
hint = getattr(file, "filename", "") or ""
elif files:
hint = getattr(files[0], "filename", "") or ""
thread_name = _derive_forum_thread_name(hint) if hint.strip() else "New Post"
kwargs: Dict[str, Any] = {"name": thread_name}
if content:
kwargs["content"] = content
if file is not None:
kwargs["file"] = file
if files:
kwargs["files"] = files
try:
thread = await forum_channel.create_thread(**kwargs)
except Exception as e:
logger.error(
"[%s] Failed to create forum thread with file in %s: %s",
self.name,
getattr(forum_channel, "id", "?"),
e,
)
return SendResult(success=False, error=f"Forum thread creation failed: {e}")
thread_channel = thread if hasattr(thread, "send") else getattr(thread, "thread", None)
thread_id = str(getattr(thread_channel, "id", getattr(thread, "id", "")))
starter_msg = getattr(thread, "message", None)
message_id = str(getattr(starter_msg, "id", thread_id)) if starter_msg else thread_id
return SendResult(
success=True,
message_id=message_id,
raw_response={"thread_id": thread_id},
)
async def edit_message(
self,
chat_id: str,
@@ -975,7 +1096,11 @@ class DiscordAdapter(BasePlatformAdapter):
caption: Optional[str] = None,
file_name: Optional[str] = None,
) -> SendResult:
"""Send a local file as a Discord attachment."""
"""Send a local file as a Discord attachment.
Forum channels (type 15) get a new thread whose starter message
carries the file they reject direct POST /messages.
"""
if not self._client:
return SendResult(success=False, error="Not connected")
@@ -988,6 +1113,12 @@ class DiscordAdapter(BasePlatformAdapter):
filename = file_name or os.path.basename(file_path)
with open(file_path, "rb") as fh:
file = discord.File(fh, filename=filename)
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
msg = await channel.send(content=caption if caption else None, file=file)
return SendResult(success=True, message_id=str(msg.id))
@@ -1036,6 +1167,18 @@ class DiscordAdapter(BasePlatformAdapter):
with open(audio_path, "rb") as f:
file_data = f.read()
# Forum channels (type 15) reject direct POST /messages — the
# native voice flag path also targets /messages so it would fail
# too. Create a thread post with the audio as the starter
# attachment instead.
if self._is_forum_parent(channel):
forum_file = discord.File(io.BytesIO(file_data), filename=filename)
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=forum_file,
)
# Try sending as a native voice message via raw API (flags=8192).
try:
import base64
@@ -1488,6 +1631,13 @@ class DiscordAdapter(BasePlatformAdapter):
import io
file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}")
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
msg = await channel.send(
content=caption if caption else None,
file=file,
@@ -1550,6 +1700,13 @@ class DiscordAdapter(BasePlatformAdapter):
import io
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
msg = await channel.send(
content=caption if caption else None,
file=file,
@@ -1776,6 +1933,24 @@ class DiscordAdapter(BasePlatformAdapter):
the "thinking..." indicator is replaced with that text; otherwise it
is deleted so the channel isn't cluttered.
"""
# Log the invoker so ghost-command reports can be triaged. Discord
# native slash invocations are always user-initiated (no bot can fire
# them), but mobile autocomplete / keyboard shortcuts / other users
# in the same channel are easy to miss in post-mortems.
try:
_user = interaction.user
_chan_id = getattr(interaction.channel, "id", None) or getattr(interaction, "channel_id", None)
logger.info(
"[Discord] slash '%s' invoked by user=%s id=%s channel=%s guild=%s",
command_text,
getattr(_user, "name", "?"),
getattr(_user, "id", "?"),
_chan_id,
getattr(interaction, "guild_id", None),
)
except Exception:
pass # logging must never block command dispatch
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, command_text)
await self.handle_message(event)
@@ -1837,6 +2012,11 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_stop(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/stop", "Stop requested~")
@tree.command(name="steer", description="Inject a message after the next tool call (no interrupt)")
@discord.app_commands.describe(prompt="Text to inject into the agent's next tool result")
async def slash_steer(interaction: discord.Interaction, prompt: str):
await self._run_simple_slash(interaction, f"/steer {prompt}".strip())
@tree.command(name="compress", description="Compress conversation context")
async def slash_compress(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/compress")
@@ -3085,7 +3265,20 @@ class DiscordAdapter(BasePlatformAdapter):
"[Discord] Flushing text batch %s (%d chars)",
key, len(event.text or ""),
)
await self.handle_message(event)
# Shield the downstream dispatch so that a subsequent chunk
# arriving while handle_message is mid-flight cannot cancel
# the running agent turn. _enqueue_text_event always cancels
# the prior flush task when a new chunk lands; without this
# shield, CancelledError would propagate from our task down
# into handle_message → the agent's streaming request,
# aborting the response the user was waiting on. The new
# chunk is handled by the fresh flush task regardless.
await asyncio.shield(self.handle_message(event))
except asyncio.CancelledError:
# Only reached if cancel landed before the pop — the shielded
# handle_message is unaffected either way. Let the task exit
# cleanly so the finally block cleans up.
pass
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
+25
View File
@@ -1228,6 +1228,10 @@ class FeishuAdapter(BasePlatformAdapter):
.register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)
.register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered)
.register_p2_im_message_recalled_v1(self._on_message_recalled)
.register_p2_customized_event(
"drive.notice.comment_add_v1",
self._on_drive_comment_event,
)
.build()
)
@@ -1965,6 +1969,25 @@ class FeishuAdapter(BasePlatformAdapter):
def _on_message_recalled(self, data: Any) -> None:
logger.debug("[Feishu] Message recalled by user")
def _on_drive_comment_event(self, data: Any) -> None:
"""Handle drive document comment notification (drive.notice.comment_add_v1).
Delegates to :mod:`gateway.platforms.feishu_comment` for parsing,
logging, and reaction. Scheduling follows the same
``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
"""
from gateway.platforms.feishu_comment import handle_drive_comment_event
loop = self._loop
if not self._loop_accepts_callbacks(loop):
logger.warning("[Feishu] Dropping drive comment event before adapter loop is ready")
return
future = asyncio.run_coroutine_threadsafe(
handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
loop,
)
future.add_done_callback(self._log_background_failure)
def _on_reaction_event(self, event_type: str, data: Any) -> None:
"""Route user reactions on bot messages as synthetic text events."""
event = getattr(data, "event", None)
@@ -2590,6 +2613,8 @@ class FeishuAdapter(BasePlatformAdapter):
self._on_reaction_event(event_type, data)
elif event_type == "card.action.trigger":
self._on_card_action_trigger(data)
elif event_type == "drive.notice.comment_add_v1":
self._on_drive_comment_event(data)
else:
logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
return web.json_response({"code": 0, "msg": "ok"})
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
"""
Feishu document comment access-control rules.
3-tier rule resolution: exact doc > wildcard "*" > top-level > code defaults.
Each field (enabled/policy/allow_from) falls back independently.
Config: ~/.hermes/feishu_comment_rules.json (mtime-cached, hot-reload).
Pairing store: ~/.hermes/feishu_comment_pairing.json.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
#
# Uses the canonical ``get_hermes_home()`` helper (HERMES_HOME-aware and
# profile-safe). Resolved at import time; this module is lazy-imported by
# the Feishu comment event handler, which runs long after profile overrides
# have been applied, so freezing paths here is safe.
RULES_FILE = get_hermes_home() / "feishu_comment_rules.json"
PAIRING_FILE = get_hermes_home() / "feishu_comment_pairing.json"
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
_VALID_POLICIES = ("allowlist", "pairing")
@dataclass(frozen=True)
class CommentDocumentRule:
"""Per-document rule. ``None`` means 'inherit from lower tier'."""
enabled: Optional[bool] = None
policy: Optional[str] = None
allow_from: Optional[frozenset] = None
@dataclass(frozen=True)
class CommentsConfig:
"""Top-level comment access config."""
enabled: bool = True
policy: str = "pairing"
allow_from: frozenset = field(default_factory=frozenset)
documents: Dict[str, CommentDocumentRule] = field(default_factory=dict)
@dataclass(frozen=True)
class ResolvedCommentRule:
"""Fully resolved rule after field-by-field fallback."""
enabled: bool
policy: str
allow_from: frozenset
match_source: str # e.g. "exact:docx:xxx" | "wildcard" | "top" | "default"
# ---------------------------------------------------------------------------
# Mtime-cached file loading
# ---------------------------------------------------------------------------
class _MtimeCache:
"""Generic mtime-based file cache. ``stat()`` per access, re-read only on change."""
def __init__(self, path: Path):
self._path = path
self._mtime: float = 0.0
self._data: Optional[dict] = None
def load(self) -> dict:
try:
st = self._path.stat()
mtime = st.st_mtime
except FileNotFoundError:
self._mtime = 0.0
self._data = {}
return {}
if mtime == self._mtime and self._data is not None:
return self._data
try:
with open(self._path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
except (json.JSONDecodeError, OSError):
logger.warning("[Feishu-Rules] Failed to read %s, using empty config", self._path)
data = {}
self._mtime = mtime
self._data = data
return data
_rules_cache = _MtimeCache(RULES_FILE)
_pairing_cache = _MtimeCache(PAIRING_FILE)
# ---------------------------------------------------------------------------
# Config parsing
# ---------------------------------------------------------------------------
def _parse_frozenset(raw: Any) -> Optional[frozenset]:
"""Parse a list of strings into a frozenset; return None if key absent."""
if raw is None:
return None
if isinstance(raw, (list, tuple)):
return frozenset(str(u).strip() for u in raw if str(u).strip())
return None
def _parse_document_rule(raw: dict) -> CommentDocumentRule:
enabled = raw.get("enabled")
if enabled is not None:
enabled = bool(enabled)
policy = raw.get("policy")
if policy is not None:
policy = str(policy).strip().lower()
if policy not in _VALID_POLICIES:
policy = None
allow_from = _parse_frozenset(raw.get("allow_from"))
return CommentDocumentRule(enabled=enabled, policy=policy, allow_from=allow_from)
def load_config() -> CommentsConfig:
"""Load comment rules from disk (mtime-cached)."""
raw = _rules_cache.load()
if not raw:
return CommentsConfig()
documents: Dict[str, CommentDocumentRule] = {}
raw_docs = raw.get("documents", {})
if isinstance(raw_docs, dict):
for key, rule_raw in raw_docs.items():
if isinstance(rule_raw, dict):
documents[str(key)] = _parse_document_rule(rule_raw)
policy = str(raw.get("policy", "pairing")).strip().lower()
if policy not in _VALID_POLICIES:
policy = "pairing"
return CommentsConfig(
enabled=raw.get("enabled", True),
policy=policy,
allow_from=_parse_frozenset(raw.get("allow_from")) or frozenset(),
documents=documents,
)
# ---------------------------------------------------------------------------
# Rule resolution (§8.4 field-by-field fallback)
# ---------------------------------------------------------------------------
def has_wiki_keys(cfg: CommentsConfig) -> bool:
"""Check if any document rule key starts with 'wiki:'."""
return any(k.startswith("wiki:") for k in cfg.documents)
def resolve_rule(
cfg: CommentsConfig,
file_type: str,
file_token: str,
wiki_token: str = "",
) -> ResolvedCommentRule:
"""Resolve effective rule: exact doc → wiki key → wildcard → top-level → defaults."""
exact_key = f"{file_type}:{file_token}"
exact = cfg.documents.get(exact_key)
exact_src = f"exact:{exact_key}"
if exact is None and wiki_token:
wiki_key = f"wiki:{wiki_token}"
exact = cfg.documents.get(wiki_key)
exact_src = f"exact:{wiki_key}"
wildcard = cfg.documents.get("*")
layers = []
if exact is not None:
layers.append((exact, exact_src))
if wildcard is not None:
layers.append((wildcard, "wildcard"))
def _pick(field_name: str):
for layer, source in layers:
val = getattr(layer, field_name)
if val is not None:
return val, source
return getattr(cfg, field_name), "top"
enabled, en_src = _pick("enabled")
policy, pol_src = _pick("policy")
allow_from, _ = _pick("allow_from")
# match_source = highest-priority tier that contributed any field
priority_order = {"exact": 0, "wildcard": 1, "top": 2}
best_src = min(
[en_src, pol_src],
key=lambda s: priority_order.get(s.split(":")[0], 3),
)
return ResolvedCommentRule(
enabled=enabled,
policy=policy,
allow_from=allow_from,
match_source=best_src,
)
# ---------------------------------------------------------------------------
# Pairing store
# ---------------------------------------------------------------------------
def _load_pairing_approved() -> set:
"""Return set of approved user open_ids (mtime-cached)."""
data = _pairing_cache.load()
approved = data.get("approved", {})
if isinstance(approved, dict):
return set(approved.keys())
if isinstance(approved, list):
return set(str(u) for u in approved if u)
return set()
def _save_pairing(data: dict) -> None:
PAIRING_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = PAIRING_FILE.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
tmp.replace(PAIRING_FILE)
# Invalidate cache so next load picks up change
_pairing_cache._mtime = 0.0
_pairing_cache._data = None
def pairing_add(user_open_id: str) -> bool:
"""Add a user to the pairing-approved list. Returns True if newly added."""
data = _pairing_cache.load()
approved = data.get("approved", {})
if not isinstance(approved, dict):
approved = {}
if user_open_id in approved:
return False
approved[user_open_id] = {"approved_at": time.time()}
data["approved"] = approved
_save_pairing(data)
return True
def pairing_remove(user_open_id: str) -> bool:
"""Remove a user from the pairing-approved list. Returns True if removed."""
data = _pairing_cache.load()
approved = data.get("approved", {})
if not isinstance(approved, dict):
return False
if user_open_id not in approved:
return False
del approved[user_open_id]
data["approved"] = approved
_save_pairing(data)
return True
def pairing_list() -> Dict[str, Any]:
"""Return the approved dict {user_open_id: {approved_at: ...}}."""
data = _pairing_cache.load()
approved = data.get("approved", {})
return dict(approved) if isinstance(approved, dict) else {}
# ---------------------------------------------------------------------------
# Access check (public API for feishu_comment.py)
# ---------------------------------------------------------------------------
def is_user_allowed(rule: ResolvedCommentRule, user_open_id: str) -> bool:
"""Check if user passes the resolved rule's policy gate."""
if user_open_id in rule.allow_from:
return True
if rule.policy == "pairing":
return user_open_id in _load_pairing_approved()
return False
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _print_status() -> None:
cfg = load_config()
print(f"Rules file: {RULES_FILE}")
print(f" exists: {RULES_FILE.exists()}")
print(f"Pairing file: {PAIRING_FILE}")
print(f" exists: {PAIRING_FILE.exists()}")
print()
print(f"Top-level:")
print(f" enabled: {cfg.enabled}")
print(f" policy: {cfg.policy}")
print(f" allow_from: {sorted(cfg.allow_from) if cfg.allow_from else '[]'}")
print()
if cfg.documents:
print(f"Document rules ({len(cfg.documents)}):")
for key, rule in sorted(cfg.documents.items()):
parts = []
if rule.enabled is not None:
parts.append(f"enabled={rule.enabled}")
if rule.policy is not None:
parts.append(f"policy={rule.policy}")
if rule.allow_from is not None:
parts.append(f"allow_from={sorted(rule.allow_from)}")
print(f" [{key}] {', '.join(parts) if parts else '(empty — inherits all)'}")
else:
print("Document rules: (none)")
print()
approved = pairing_list()
print(f"Pairing approved ({len(approved)}):")
for uid, meta in sorted(approved.items()):
ts = meta.get("approved_at", 0)
print(f" {uid} (approved_at={ts})")
def _do_check(doc_key: str, user_open_id: str) -> None:
cfg = load_config()
parts = doc_key.split(":", 1)
if len(parts) != 2:
print(f"Error: doc_key must be 'fileType:fileToken', got '{doc_key}'")
return
file_type, file_token = parts
rule = resolve_rule(cfg, file_type, file_token)
allowed = is_user_allowed(rule, user_open_id)
print(f"Document: {doc_key}")
print(f"User: {user_open_id}")
print(f"Resolved rule:")
print(f" enabled: {rule.enabled}")
print(f" policy: {rule.policy}")
print(f" allow_from: {sorted(rule.allow_from) if rule.allow_from else '[]'}")
print(f" match_source: {rule.match_source}")
print(f"Result: {'ALLOWED' if allowed else 'DENIED'}")
def _main() -> int:
import sys
try:
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv()
except Exception:
pass
usage = (
"Usage: python -m gateway.platforms.feishu_comment_rules <command> [args]\n"
"\n"
"Commands:\n"
" status Show rules config and pairing state\n"
" check <fileType:token> <user> Simulate access check\n"
" pairing add <user_open_id> Add user to pairing-approved list\n"
" pairing remove <user_open_id> Remove user from pairing-approved list\n"
" pairing list List pairing-approved users\n"
"\n"
f"Rules config file: {RULES_FILE}\n"
" Edit this JSON file directly to configure policies and document rules.\n"
" Changes take effect on the next comment event (no restart needed).\n"
)
args = sys.argv[1:]
if not args:
print(usage)
return 1
cmd = args[0]
if cmd == "status":
_print_status()
elif cmd == "check":
if len(args) < 3:
print("Usage: check <fileType:fileToken> <user_open_id>")
return 1
_do_check(args[1], args[2])
elif cmd == "pairing":
if len(args) < 2:
print("Usage: pairing <add|remove|list> [args]")
return 1
sub = args[1]
if sub == "add":
if len(args) < 3:
print("Usage: pairing add <user_open_id>")
return 1
if pairing_add(args[2]):
print(f"Added: {args[2]}")
else:
print(f"Already approved: {args[2]}")
elif sub == "remove":
if len(args) < 3:
print("Usage: pairing remove <user_open_id>")
return 1
if pairing_remove(args[2]):
print(f"Removed: {args[2]}")
else:
print(f"Not in approved list: {args[2]}")
elif sub == "list":
approved = pairing_list()
if not approved:
print("(no approved users)")
for uid, meta in sorted(approved.items()):
print(f" {uid} approved_at={meta.get('approved_at', '?')}")
else:
print(f"Unknown pairing subcommand: {sub}")
return 1
else:
print(f"Unknown command: {cmd}\n")
print(usage)
return 1
return 0
if __name__ == "__main__":
import sys
sys.exit(_main())
+57
View File
@@ -0,0 +1,57 @@
"""
QQBot platform package.
Re-exports the main adapter symbols from ``adapter.py`` (the original
``qqbot.py``) so that **all existing import paths remain unchanged**::
from gateway.platforms.qqbot import QQAdapter # works
from gateway.platforms.qqbot import check_qq_requirements # works
New modules:
- ``constants`` shared constants (API URLs, timeouts, message types)
- ``utils`` User-Agent builder, config helpers
- ``crypto`` AES-256-GCM key generation and decryption
- ``onboard`` QR-code scan-to-configure flow
"""
# -- Adapter (original qqbot.py) ------------------------------------------
from .adapter import ( # noqa: F401
QQAdapter,
QQCloseError,
check_qq_requirements,
_coerce_list,
_ssrf_redirect_guard,
)
# -- Onboard (QR-code scan-to-configure) -----------------------------------
from .onboard import ( # noqa: F401
BindStatus,
create_bind_task,
poll_bind_result,
build_connect_url,
)
from .crypto import decrypt_secret, generate_bind_key # noqa: F401
# -- Utils -----------------------------------------------------------------
from .utils import build_user_agent, get_api_headers, coerce_list # noqa: F401
__all__ = [
# adapter
"QQAdapter",
"QQCloseError",
"check_qq_requirements",
"_coerce_list",
"_ssrf_redirect_guard",
# onboard
"BindStatus",
"create_bind_task",
"poll_bind_result",
"build_connect_url",
# crypto
"decrypt_secret",
"generate_bind_key",
# utils
"build_user_agent",
"get_api_headers",
"coerce_list",
]
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
"""QQBot package-level constants shared across adapter, onboard, and other modules."""
from __future__ import annotations
import os
# ---------------------------------------------------------------------------
# QQBot adapter version — bump on functional changes to the adapter package.
# ---------------------------------------------------------------------------
QQBOT_VERSION = "1.1.0"
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
# The portal domain is configurable via QQ_API_HOST for corporate proxies
# or test environments. Default: q.qq.com (production).
PORTAL_HOST = os.getenv("QQ_PORTAL_HOST", "q.qq.com")
API_BASE = "https://api.sgroup.qq.com"
TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"
GATEWAY_URL_PATH = "/gateway"
# QR-code onboard endpoints (on the portal host)
ONBOARD_CREATE_PATH = "/lite/create_bind_task"
ONBOARD_POLL_PATH = "/lite/poll_bind_result"
QR_URL_TEMPLATE = (
"https://q.qq.com/qqbot/openclaw/connect.html"
"?task_id={task_id}&_wv=2&source=hermes"
)
# ---------------------------------------------------------------------------
# Timeouts & retry
# ---------------------------------------------------------------------------
DEFAULT_API_TIMEOUT = 30.0
FILE_UPLOAD_TIMEOUT = 120.0
CONNECT_TIMEOUT_SECONDS = 20.0
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
MAX_RECONNECT_ATTEMPTS = 100
RATE_LIMIT_DELAY = 60 # seconds
QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds
MAX_QUICK_DISCONNECT_COUNT = 3
ONBOARD_POLL_INTERVAL = 2.0 # seconds between poll_bind_result calls
ONBOARD_API_TIMEOUT = 10.0
# ---------------------------------------------------------------------------
# Message limits
# ---------------------------------------------------------------------------
MAX_MESSAGE_LENGTH = 4000
DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
# ---------------------------------------------------------------------------
# QQ Bot message types
# ---------------------------------------------------------------------------
MSG_TYPE_TEXT = 0
MSG_TYPE_MARKDOWN = 2
MSG_TYPE_MEDIA = 7
MSG_TYPE_INPUT_NOTIFY = 6
# ---------------------------------------------------------------------------
# QQ Bot file media types
# ---------------------------------------------------------------------------
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VIDEO = 2
MEDIA_TYPE_VOICE = 3
MEDIA_TYPE_FILE = 4
+45
View File
@@ -0,0 +1,45 @@
"""AES-256-GCM utilities for QQBot scan-to-configure credential decryption."""
from __future__ import annotations
import base64
import os
def generate_bind_key() -> str:
"""Generate a 256-bit random AES key and return it as base64.
The key is passed to ``create_bind_task`` so the server can encrypt
the bot's *client_secret* before returning it. Only this CLI holds
the key, ensuring the secret never travels in plaintext.
"""
return base64.b64encode(os.urandom(32)).decode()
def decrypt_secret(encrypted_base64: str, key_base64: str) -> str:
"""Decrypt a base64-encoded AES-256-GCM ciphertext.
Ciphertext layout (after base64-decoding)::
IV (12 bytes) ciphertext (N bytes) AuthTag (16 bytes)
Args:
encrypted_base64: The ``bot_encrypt_secret`` value from
``poll_bind_result``.
key_base64: The base64 AES key generated by
:func:`generate_bind_key`.
Returns:
The decrypted *client_secret* as a UTF-8 string.
"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = base64.b64decode(key_base64)
raw = base64.b64decode(encrypted_base64)
iv = raw[:12]
ciphertext_with_tag = raw[12:] # AESGCM expects ciphertext + tag concatenated
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(iv, ciphertext_with_tag, None)
return plaintext.decode("utf-8")
+124
View File
@@ -0,0 +1,124 @@
"""
QQBot scan-to-configure (QR code onboard) module.
Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to
generate a QR-code URL and poll for scan completion. On success the caller
receives the bot's *app_id*, *client_secret* (decrypted locally), and the
scanner's *user_openid* — enough to fully configure the QQBot gateway.
Reference: https://bot.q.qq.com/wiki/develop/api-v2/
"""
from __future__ import annotations
import logging
from enum import IntEnum
from typing import Tuple
from urllib.parse import quote
from .constants import (
ONBOARD_API_TIMEOUT,
ONBOARD_CREATE_PATH,
ONBOARD_POLL_PATH,
PORTAL_HOST,
QR_URL_TEMPLATE,
)
from .crypto import generate_bind_key
from .utils import get_api_headers
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Bind status
# ---------------------------------------------------------------------------
class BindStatus(IntEnum):
"""Status codes returned by ``poll_bind_result``."""
NONE = 0
PENDING = 1
COMPLETED = 2
EXPIRED = 3
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def create_bind_task(
timeout: float = ONBOARD_API_TIMEOUT,
) -> Tuple[str, str]:
"""Create a bind task and return *(task_id, aes_key_base64)*.
The AES key is generated locally and sent to the server so it can
encrypt the bot credentials before returning them.
Raises:
RuntimeError: If the API returns a non-zero ``retcode``.
"""
import httpx
url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}"
key = generate_bind_key()
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.post(url, json={"key": key}, headers=get_api_headers())
resp.raise_for_status()
data = resp.json()
if data.get("retcode") != 0:
raise RuntimeError(data.get("msg", "create_bind_task failed"))
task_id = data.get("data", {}).get("task_id")
if not task_id:
raise RuntimeError("create_bind_task: missing task_id in response")
logger.debug("create_bind_task ok: task_id=%s", task_id)
return task_id, key
async def poll_bind_result(
task_id: str,
timeout: float = ONBOARD_API_TIMEOUT,
) -> Tuple[BindStatus, str, str, str]:
"""Poll the bind result for *task_id*.
Returns:
A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``.
* ``bot_encrypt_secret`` is AES-256-GCM encrypted decrypt it with
:func:`~gateway.platforms.qqbot.crypto.decrypt_secret` using the
key from :func:`create_bind_task`.
* ``user_openid`` is the OpenID of the person who scanned the code
(available when ``status == COMPLETED``).
Raises:
RuntimeError: If the API returns a non-zero ``retcode``.
"""
import httpx
url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}"
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.post(url, json={"task_id": task_id}, headers=get_api_headers())
resp.raise_for_status()
data = resp.json()
if data.get("retcode") != 0:
raise RuntimeError(data.get("msg", "poll_bind_result failed"))
d = data.get("data", {})
return (
BindStatus(d.get("status", 0)),
str(d.get("bot_appid", "")),
d.get("bot_encrypt_secret", ""),
d.get("user_openid", ""),
)
def build_connect_url(task_id: str) -> str:
"""Build the QR-code target URL for a given *task_id*."""
return QR_URL_TEMPLATE.format(task_id=quote(task_id))
+71
View File
@@ -0,0 +1,71 @@
"""QQBot shared utilities — User-Agent, HTTP helpers, config coercion."""
from __future__ import annotations
import platform
import sys
from typing import Any, Dict, List
from .constants import QQBOT_VERSION
# ---------------------------------------------------------------------------
# User-Agent
# ---------------------------------------------------------------------------
def _get_hermes_version() -> str:
"""Return the hermes-agent package version, or 'dev' if unavailable."""
try:
from importlib.metadata import version
return version("hermes-agent")
except Exception:
return "dev"
def build_user_agent() -> str:
"""Build a descriptive User-Agent string.
Format::
QQBotAdapter/<qqbot_version> (Python/<py_version>; <os>; Hermes/<hermes_version>)
Example::
QQBotAdapter/1.0.0 (Python/3.11.15; darwin; Hermes/0.9.0)
"""
py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
os_name = platform.system().lower()
hermes_version = _get_hermes_version()
return f"QQBotAdapter/{QQBOT_VERSION} (Python/{py_version}; {os_name}; Hermes/{hermes_version})"
def get_api_headers() -> Dict[str, str]:
"""Return standard HTTP headers for QQBot API requests.
Includes ``Content-Type``, ``Accept``, and a dynamic ``User-Agent``.
``q.qq.com`` requires ``Accept: application/json`` without it,
the server returns a JavaScript anti-bot challenge page.
"""
return {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": build_user_agent(),
}
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def coerce_list(value: Any) -> List[str]:
"""Coerce config values into a trimmed string list.
Accepts comma-separated strings, lists, tuples, sets, or single values.
"""
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
+78 -6
View File
@@ -160,6 +160,14 @@ class SignalAdapter(BasePlatformAdapter):
self._sse_task: Optional[asyncio.Task] = None
self._health_monitor_task: Optional[asyncio.Task] = None
self._typing_tasks: Dict[str, asyncio.Task] = {}
# Per-chat typing-indicator backoff. When signal-cli reports
# NETWORK_FAILURE (recipient offline / unroutable), base.py's
# _keep_typing refresh loop would otherwise hammer sendTyping every
# ~2s indefinitely, producing WARNING-level log spam and pointless
# RPC traffic. We track consecutive failures per chat and skip the
# RPC during a cooldown window instead.
self._typing_failures: Dict[str, int] = {}
self._typing_skip_until: Dict[str, float] = {}
self._running = False
self._last_sse_activity = 0.0
self._sse_response: Optional[httpx.Response] = None
@@ -548,8 +556,22 @@ class SignalAdapter(BasePlatformAdapter):
# JSON-RPC Communication
# ------------------------------------------------------------------
async def _rpc(self, method: str, params: dict, rpc_id: str = None) -> Any:
"""Send a JSON-RPC 2.0 request to signal-cli daemon."""
async def _rpc(
self,
method: str,
params: dict,
rpc_id: str = None,
*,
log_failures: bool = True,
) -> Any:
"""Send a JSON-RPC 2.0 request to signal-cli daemon.
When ``log_failures=False``, error and exception paths log at DEBUG
instead of WARNING used by the typing-indicator path to silence
repeated NETWORK_FAILURE spam for unreachable recipients while
still preserving visibility for the first occurrence and for
unrelated RPCs.
"""
if not self.client:
logger.warning("Signal: RPC called but client not connected")
return None
@@ -574,13 +596,19 @@ class SignalAdapter(BasePlatformAdapter):
data = resp.json()
if "error" in data:
logger.warning("Signal RPC error (%s): %s", method, data["error"])
if log_failures:
logger.warning("Signal RPC error (%s): %s", method, data["error"])
else:
logger.debug("Signal RPC error (%s): %s", method, data["error"])
return None
return data.get("result")
except Exception as e:
logger.warning("Signal RPC %s failed: %s", method, e)
if log_failures:
logger.warning("Signal RPC %s failed: %s", method, e)
else:
logger.debug("Signal RPC %s failed: %s", method, e)
return None
# ------------------------------------------------------------------
@@ -627,7 +655,28 @@ class SignalAdapter(BasePlatformAdapter):
self._recent_sent_timestamps.pop()
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Send a typing indicator."""
"""Send a typing indicator.
base.py's ``_keep_typing`` refresh loop calls this every ~2s while
the agent is processing. If signal-cli returns NETWORK_FAILURE for
this recipient (offline, unroutable, group membership lost, etc.)
the unmitigated behaviour is: a WARNING log every 2 seconds for as
long as the agent keeps running. Instead we:
- silence the WARNING after the first consecutive failure (subsequent
attempts log at DEBUG) so transport issues are still visible once
but don't flood the log,
- skip the RPC entirely during an exponential cooldown window once
three consecutive failures have happened, so we stop hammering
signal-cli with requests it can't deliver.
A successful sendTyping clears the counters.
"""
now = time.monotonic()
skip_until = self._typing_skip_until.get(chat_id, 0.0)
if now < skip_until:
return
params: Dict[str, Any] = {
"account": self.account,
}
@@ -637,7 +686,26 @@ class SignalAdapter(BasePlatformAdapter):
else:
params["recipient"] = [chat_id]
await self._rpc("sendTyping", params, rpc_id="typing")
fails = self._typing_failures.get(chat_id, 0)
result = await self._rpc(
"sendTyping",
params,
rpc_id="typing",
log_failures=(fails == 0),
)
if result is None:
fails += 1
self._typing_failures[chat_id] = fails
# After 3 consecutive failures, back off exponentially (16s,
# 32s, 60s cap) to stop spamming signal-cli for a recipient
# that clearly isn't reachable right now.
if fails >= 3:
backoff = min(60.0, 16.0 * (2 ** (fails - 3)))
self._typing_skip_until[chat_id] = now + backoff
else:
self._typing_failures.pop(chat_id, None)
self._typing_skip_until.pop(chat_id, None)
async def send_image(
self,
@@ -789,6 +857,10 @@ class SignalAdapter(BasePlatformAdapter):
await task
except asyncio.CancelledError:
pass
# Reset per-chat typing backoff state so the next agent turn starts
# fresh rather than inheriting a cooldown from a prior conversation.
self._typing_failures.pop(chat_id, None)
self._typing_skip_until.pop(chat_id, None)
async def stop_typing(self, chat_id: str) -> None:
"""Public interface for stopping typing — called by base adapter's
+104 -8
View File
@@ -118,6 +118,84 @@ def _strip_mdv2(text: str) -> str:
return cleaned
# ---------------------------------------------------------------------------
# Markdown table → code block conversion
# ---------------------------------------------------------------------------
# Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
# so pipe tables render as noisy backslash-pipe text with no alignment.
# Wrapping the table in a fenced code block makes Telegram render it as
# monospace preformatted text with columns intact.
# Matches a GFM table delimiter row: optional outer pipes, cells containing
# only dashes (with optional leading/trailing colons for alignment) separated
# by '|'. Requires at least one internal '|' so lone '---' horizontal rules
# are NOT matched.
_TABLE_SEPARATOR_RE = re.compile(
r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
)
def _is_table_row(line: str) -> bool:
"""Return True if *line* could plausibly be a table data row."""
stripped = line.strip()
return bool(stripped) and '|' in stripped
def _wrap_markdown_tables(text: str) -> str:
"""Wrap GFM-style pipe tables in ``` fences so Telegram renders them.
Detected by a row containing '|' immediately followed by a delimiter
row matching :data:`_TABLE_SEPARATOR_RE`. Subsequent pipe-containing
non-blank lines are consumed as the table body and included in the
wrapped block. Tables inside existing fenced code blocks are left
alone.
"""
if '|' not in text or '-' not in text:
return text
lines = text.split('\n')
out: list[str] = []
in_fence = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip()
# Track existing fenced code blocks — never touch content inside.
if stripped.startswith('```'):
in_fence = not in_fence
out.append(line)
i += 1
continue
if in_fence:
out.append(line)
i += 1
continue
# Look for a header row (contains '|') immediately followed by a
# delimiter row.
if (
'|' in line
and i + 1 < len(lines)
and _TABLE_SEPARATOR_RE.match(lines[i + 1])
):
table_block = [line, lines[i + 1]]
j = i + 2
while j < len(lines) and _is_table_row(lines[j]):
table_block.append(lines[j])
j += 1
out.append('```')
out.extend(table_block)
out.append('```')
i = j
continue
out.append(line)
i += 1
return '\n'.join(out)
class TelegramAdapter(BasePlatformAdapter):
"""
Telegram bot adapter.
@@ -1916,6 +1994,12 @@ class TelegramAdapter(BasePlatformAdapter):
text = content
# 0) Pre-wrap GFM-style pipe tables in ``` fences. Telegram can't
# render tables natively, but fenced code blocks render as
# monospace preformatted text with columns intact. The wrapped
# tables then flow through step (1) below as protected regions.
text = _wrap_markdown_tables(text)
# 1) Protect fenced code blocks (``` ... ```)
# Per MarkdownV2 spec, \ and ` inside pre/code must be escaped.
def _protect_fenced(m):
@@ -2242,7 +2326,7 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._should_process_message(update.message):
return
event = self._build_message_event(update.message, MessageType.TEXT)
event = self._build_message_event(update.message, MessageType.TEXT, update_id=update.update_id)
event.text = self._clean_bot_trigger_text(event.text)
self._enqueue_text_event(event)
@@ -2253,7 +2337,7 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._should_process_message(update.message, is_command=True):
return
event = self._build_message_event(update.message, MessageType.COMMAND)
event = self._build_message_event(update.message, MessageType.COMMAND, update_id=update.update_id)
await self.handle_message(event)
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -2289,7 +2373,7 @@ class TelegramAdapter(BasePlatformAdapter):
parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}")
parts.append("Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences.")
event = self._build_message_event(msg, MessageType.LOCATION)
event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
event.text = "\n".join(parts)
await self.handle_message(event)
@@ -2440,7 +2524,7 @@ class TelegramAdapter(BasePlatformAdapter):
else:
msg_type = MessageType.DOCUMENT
event = self._build_message_event(msg, msg_type)
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
# Add caption as text
if msg.caption:
@@ -2779,8 +2863,19 @@ class TelegramAdapter(BasePlatformAdapter):
self.name, cache_key, thread_id,
)
def _build_message_event(self, message: Message, msg_type: MessageType) -> MessageEvent:
"""Build a MessageEvent from a Telegram message."""
def _build_message_event(
self,
message: Message,
msg_type: MessageType,
update_id: Optional[int] = None,
) -> MessageEvent:
"""Build a MessageEvent from a Telegram message.
``update_id`` is the ``Update.update_id`` from PTB; passing it through
lets ``/restart`` record the triggering offset so the new gateway
process can advance past it (prevents ``/restart`` being re-delivered
when PTB's graceful-shutdown ACK fails).
"""
chat = message.chat
user = message.from_user
@@ -2831,8 +2926,8 @@ class TelegramAdapter(BasePlatformAdapter):
chat_id=str(chat.id),
chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None),
chat_type=chat_type,
user_id=str(user.id) if user else None,
user_name=user.full_name if user else None,
user_id=str(user.id) if user else (str(chat.id) if chat_type == "dm" else None),
user_name=user.full_name if user else (chat.full_name if hasattr(chat, "full_name") and chat_type == "dm" else None),
thread_id=thread_id_str,
chat_topic=chat_topic,
)
@@ -2859,6 +2954,7 @@ class TelegramAdapter(BasePlatformAdapter):
source=source,
raw_message=message,
message_id=str(message.message_id),
platform_update_id=update_id,
reply_to_message_id=reply_to_id,
reply_to_text=reply_to_text,
auto_skill=topic_skill,
+41 -10
View File
@@ -180,6 +180,8 @@ class WeComAdapter(BasePlatformAdapter):
self._text_batch_split_delay_seconds = float(os.getenv("HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
self._device_id = uuid.uuid4().hex
self._last_chat_req_ids: Dict[str, str] = {}
# ------------------------------------------------------------------
# Connection lifecycle
@@ -277,7 +279,11 @@ class WeComAdapter(BasePlatformAdapter):
{
"cmd": APP_CMD_SUBSCRIBE,
"headers": {"req_id": req_id},
"body": {"bot_id": self._bot_id, "secret": self._secret},
"body": {
"bot_id": self._bot_id,
"secret": self._secret,
"device_id": self._device_id,
},
}
)
@@ -496,6 +502,11 @@ class WeComAdapter(BasePlatformAdapter):
logger.debug("[%s] DM sender %s blocked by policy", self.name, sender_id)
return
# Cache the inbound req_id after policy checks so proactive sends to
# this chat can fall back to APP_CMD_RESPONSE (required for groups —
# WeCom AI Bots cannot initiate APP_CMD_SEND in group chats).
self._remember_chat_req_id(chat_id, self._payload_req_id(payload))
text, reply_text = self._extract_text(body)
media_urls, media_types = await self._extract_media(body)
message_type = self._derive_message_type(body, text, media_types)
@@ -847,6 +858,23 @@ class WeComAdapter(BasePlatformAdapter):
while len(self._reply_req_ids) > DEDUP_MAX_SIZE:
self._reply_req_ids.pop(next(iter(self._reply_req_ids)))
def _remember_chat_req_id(self, chat_id: str, req_id: str) -> None:
"""Cache the most recent inbound req_id per chat.
Used as a fallback reply target when we need to send into a group
without an explicit ``reply_to`` WeCom AI Bots are blocked from
APP_CMD_SEND in groups and must use APP_CMD_RESPONSE bound to some
prior req_id. Bounded like _reply_req_ids so long-running gateways
don't leak memory across many chats.
"""
normalized_chat_id = str(chat_id or "").strip()
normalized_req_id = str(req_id or "").strip()
if not normalized_chat_id or not normalized_req_id:
return
self._last_chat_req_ids[normalized_chat_id] = normalized_req_id
while len(self._last_chat_req_ids) > DEDUP_MAX_SIZE:
self._last_chat_req_ids.pop(next(iter(self._last_chat_req_ids)))
def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]:
normalized = str(reply_to or "").strip()
if not normalized or normalized.startswith("quote:"):
@@ -1163,19 +1191,15 @@ class WeComAdapter(BasePlatformAdapter):
self._raise_for_wecom_error(response, "send media message")
return response
async def _send_reply_stream(self, reply_req_id: str, content: str) -> Dict[str, Any]:
async def _send_reply_markdown(self, reply_req_id: str, content: str) -> Dict[str, Any]:
response = await self._send_reply_request(
reply_req_id,
{
"msgtype": "stream",
"stream": {
"id": self._new_req_id("stream"),
"finish": True,
"content": content[:self.MAX_MESSAGE_LENGTH],
},
"msgtype": "markdown",
"markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]},
},
)
self._raise_for_wecom_error(response, "send reply stream")
self._raise_for_wecom_error(response, "send reply markdown")
return response
async def _send_reply_media_message(
@@ -1235,6 +1259,9 @@ class WeComAdapter(BasePlatformAdapter):
return SendResult(success=False, error=prepared["reject_reason"])
reply_req_id = self._reply_req_id_for_message(reply_to)
if not reply_req_id and chat_id in self._last_chat_req_ids:
reply_req_id = self._last_chat_req_ids[chat_id]
try:
upload_result = await self._upload_media_bytes(
prepared["data"],
@@ -1302,8 +1329,12 @@ class WeComAdapter(BasePlatformAdapter):
try:
reply_req_id = self._reply_req_id_for_message(reply_to)
if not reply_req_id and chat_id in self._last_chat_req_ids:
reply_req_id = self._last_chat_req_ids[chat_id]
if reply_req_id:
response = await self._send_reply_stream(reply_req_id, content)
response = await self._send_reply_markdown(reply_req_id, content)
else:
response = await self._send_request(
APP_CMD_SEND,
+521 -27
View File
@@ -752,6 +752,26 @@ class GatewayRunner:
chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
)
async def _safe_adapter_disconnect(self, adapter, platform) -> None:
"""Call adapter.disconnect() defensively, swallowing any error.
Used when adapter.connect() failed or raised the adapter may
have allocated partial resources (aiohttp.ClientSession, poll
tasks, child subprocesses) that would otherwise leak and surface
as "Unclosed client session" warnings at process exit.
Must tolerate partial-init state and never raise, since callers
use it inside error-handling blocks.
"""
try:
await adapter.disconnect()
except Exception as e:
logger.debug(
"Defensive %s disconnect after failed connect raised: %s",
platform.value if platform is not None else "adapter",
e,
)
# -----------------------------------------------------------------
def _flush_memories_for_session(
@@ -1539,7 +1559,7 @@ class GatewayRunner:
action = "restarting" if self._restart_requested else "shutting down"
hint = (
"Your current task will be interrupted. "
"Send any message after restart to resume where it left off."
"Send any message after restart and I'll try to resume where you left off."
if self._restart_requested
else "Your current task will be interrupted."
)
@@ -1913,6 +1933,15 @@ class GatewayRunner:
logger.info("%s connected", platform.value)
else:
logger.warning("%s failed to connect", platform.value)
# Defensive cleanup: a failed connect() may have
# allocated resources (aiohttp.ClientSession, poll
# tasks, bridge subprocesses) before giving up.
# Without this call, those resources are orphaned
# and Python logs "Unclosed client session" at
# process exit. Adapter disconnect() implementations
# are expected to be idempotent and tolerate
# partial-init state.
await self._safe_adapter_disconnect(adapter, platform)
if adapter.has_fatal_error:
self._update_platform_runtime_status(
platform.value,
@@ -1953,6 +1982,10 @@ class GatewayRunner:
}
except Exception as e:
logger.error("%s error: %s", platform.value, e)
# Same defensive cleanup path for exceptions — an adapter
# that raised mid-connect may still have a live
# aiohttp.ClientSession or child subprocess.
await self._safe_adapter_disconnect(adapter, platform)
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
@@ -2178,6 +2211,30 @@ class GatewayRunner:
)
except Exception as _e:
logger.debug("Idle agent sweep failed: %s", _e)
# Periodically prune stale SessionStore entries. The
# in-memory dict (and sessions.json) would otherwise grow
# unbounded in gateways serving many rotating chats /
# threads / users over long time windows. Pruning is
# invisible to users — a resumed session just gets a
# fresh session_id, exactly as if the reset policy fired.
_last_prune_ts = getattr(self, "_last_session_store_prune_ts", 0.0)
_prune_interval = 3600.0 # once per hour
if time.time() - _last_prune_ts > _prune_interval:
try:
_max_age = int(
getattr(self.config, "session_store_max_age_days", 0) or 0
)
if _max_age > 0:
_pruned = self.session_store.prune_old_entries(_max_age)
if _pruned:
logger.info(
"SessionStore prune: dropped %d stale entries",
_pruned,
)
except Exception as _e:
logger.debug("SessionStore prune failed: %s", _e)
self._last_session_store_prune_ts = time.time()
except Exception as e:
logger.debug("Session expiry watcher error: %s", e)
# Sleep in small increments so we can stop quickly
@@ -2349,6 +2406,40 @@ class GatewayRunner:
timeout,
self._running_agent_count(),
)
# Mark forcibly-interrupted sessions as resume_pending BEFORE
# interrupting the agents. This preserves each session's
# session_id + transcript so the next message on the same
# session_key auto-resumes from the existing conversation
# instead of getting routed through suspend_recently_active()
# and converted into a fresh session. Terminal escalation
# for genuinely stuck sessions still flows through the
# existing ``.restart_failure_counts`` stuck-loop counter
# (incremented below, threshold 3), which sets
# ``suspended=True`` and overrides resume_pending.
#
# Iterate self._running_agents (current) rather than the
# drain-start ``active_agents`` snapshot — the snapshot
# may include sessions that finished gracefully during
# the drain window, and marking those falsely would give
# them a stray restart-interruption system note on their
# next turn even though their previous turn completed
# cleanly. Skip pending sentinels for the same reason
# _interrupt_running_agents() does: their agent hasn't
# started yet, there's nothing to interrupt, and the
# session shouldn't carry a misleading resume flag.
_resume_reason = (
"restart_timeout" if self._restart_requested else "shutdown_timeout"
)
for _sk, _agent in list(self._running_agents.items()):
if _agent is _AGENT_PENDING_SENTINEL:
continue
try:
self.session_store.mark_resume_pending(_sk, _resume_reason)
except Exception as _e:
logger.debug(
"mark_resume_pending failed for %s: %s",
_sk[:20], _e,
)
self._interrupt_running_agents(
"Gateway restarting" if self._restart_requested else "Gateway shutting down"
)
@@ -2384,6 +2475,7 @@ class GatewayRunner:
self.adapters.clear()
self._running_agents.clear()
self._running_agents_ts.clear()
self._pending_messages.clear()
self._pending_approvals.clear()
if hasattr(self, '_busy_ack_ts'):
@@ -2408,6 +2500,20 @@ class GatewayRunner:
except Exception:
pass
# Close SQLite session DBs so the WAL write lock is released.
# Without this, --replace and similar restart flows leave the
# old gateway's connection holding the WAL lock until Python
# actually exits — causing 'database is locked' errors when
# the new gateway tries to open the same file.
for _db_holder in (self, getattr(self, "session_store", None)):
_db = getattr(_db_holder, "_db", None) if _db_holder else None
if _db is None or not hasattr(_db, "close"):
continue
try:
_db.close()
except Exception as _e:
logger.debug("SessionDB close error: %s", _e)
from gateway.status import remove_pid_file
remove_pid_file()
@@ -2906,16 +3012,17 @@ class GatewayRunner:
_quick_key[:30], _stale_age, _stale_idle,
_raw_stale_timeout, _stale_detail,
)
del self._running_agents[_quick_key]
self._running_agents_ts.pop(_quick_key, None)
self._busy_ack_ts.pop(_quick_key, None)
self._release_running_agent_state(_quick_key)
if _quick_key in self._running_agents:
if event.get_command() == "status":
return await self._handle_status_command(event)
# Resolve the command once for all early-intercept checks below.
from hermes_cli.commands import resolve_command as _resolve_cmd_inner
from hermes_cli.commands import (
ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS,
resolve_command as _resolve_cmd_inner,
)
_evt_cmd = event.get_command()
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
@@ -2936,8 +3043,7 @@ class GatewayRunner:
if adapter and hasattr(adapter, 'get_pending_message'):
adapter.get_pending_message(_quick_key) # consume and discard
self._pending_messages.pop(_quick_key, None)
if _quick_key in self._running_agents:
del self._running_agents[_quick_key]
self._release_running_agent_state(_quick_key)
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
return "⚡ Stopped. You can continue this session."
@@ -2959,8 +3065,7 @@ class GatewayRunner:
self._pending_messages.pop(_quick_key, None)
# Clean up the running agent entry so the reset handler
# doesn't think an agent is still active.
if _quick_key in self._running_agents:
del self._running_agents[_quick_key]
self._release_running_agent_state(_quick_key)
return await self._handle_reset_command(event)
# /queue <prompt> — queue without interrupting
@@ -2981,6 +3086,54 @@ class GatewayRunner:
adapter._pending_messages[_quick_key] = queued_event
return "Queued for the next turn."
# /steer <prompt> — inject mid-run after the next tool call.
# Unlike /queue (turn boundary), /steer lands BETWEEN tool-call
# iterations inside the same agent run, by appending to the
# last tool result's content. No interrupt, no new user turn,
# no role-alternation violation.
if _cmd_def_inner and _cmd_def_inner.name == "steer":
steer_text = event.get_command_args().strip()
if not steer_text:
return "Usage: /steer <prompt>"
running_agent = self._running_agents.get(_quick_key)
if running_agent is _AGENT_PENDING_SENTINEL:
# Agent hasn't started yet — queue as turn-boundary fallback.
adapter = self.adapters.get(source.platform)
if adapter:
from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT
queued_event = _ME(
text=steer_text,
message_type=_MT.TEXT,
source=event.source,
message_id=event.message_id,
channel_prompt=event.channel_prompt,
)
adapter._pending_messages[_quick_key] = queued_event
return "Agent still starting — /steer queued for the next turn."
if running_agent and hasattr(running_agent, "steer"):
try:
accepted = running_agent.steer(steer_text)
except Exception as exc:
logger.warning("Steer failed for session %s: %s", _quick_key[:20], exc)
return f"⚠️ Steer failed: {exc}"
if accepted:
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
return f"⏩ Steer queued — arrives after the next tool call: '{preview}'"
return "Steer rejected (empty payload)."
# Running agent is missing or lacks steer() — fall back to queue.
adapter = self.adapters.get(source.platform)
if adapter:
from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT
queued_event = _ME(
text=steer_text,
message_type=_MT.TEXT,
source=event.source,
message_id=event.message_id,
channel_prompt=event.channel_prompt,
)
adapter._pending_messages[_quick_key] = queued_event
return "No active agent — /steer queued for the next turn."
# /model must not be used while the agent is running.
if _cmd_def_inner and _cmd_def_inner.name == "model":
return "Agent is running — wait or /stop first, then switch models."
@@ -2994,11 +3147,42 @@ class GatewayRunner:
return await self._handle_approve_command(event)
return await self._handle_deny_command(event)
# /agents (/tasks alias) should be query-only and never interrupt.
if _cmd_def_inner and _cmd_def_inner.name == "agents":
return await self._handle_agents_command(event)
# /background must bypass the running-agent guard — it starts a
# parallel task and must never interrupt the active conversation.
if _cmd_def_inner and _cmd_def_inner.name == "background":
return await self._handle_background_command(event)
# Gateway-handled info/control commands with dedicated
# running-agent handlers.
if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:
if _cmd_def_inner.name == "help":
return await self._handle_help_command(event)
if _cmd_def_inner.name == "commands":
return await self._handle_commands_command(event)
if _cmd_def_inner.name == "profile":
return await self._handle_profile_command(event)
if _cmd_def_inner.name == "update":
return await self._handle_update_command(event)
# Catch-all: any other recognized slash command reached the
# running-agent guard. Reject gracefully rather than falling
# through to interrupt + discard. Without this, commands
# like /model, /reasoning, /voice, /insights, /title,
# /resume, /retry, /undo, /compress, /usage, /provider,
# /reload-mcp, /sethome, /reset (all registered as Discord
# slash commands) would interrupt the agent AND get
# silently discarded by the slash-command safety net,
# producing a zero-char response. See #5057, #6252, #10370.
if _cmd_def_inner:
return (
f"⏳ Agent is running — `/{_cmd_def_inner.name}` can't run "
f"mid-turn. Wait for the current response or `/stop` first."
)
if event.message_type == MessageType.PHOTO:
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
adapter = self.adapters.get(source.platform)
@@ -3037,8 +3221,7 @@ class GatewayRunner:
# Agent is being set up but not ready yet.
if event.get_command() == "stop":
# Force-clean the sentinel so the session is unlocked.
if _quick_key in self._running_agents:
del self._running_agents[_quick_key]
self._release_running_agent_state(_quick_key)
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key[:20])
return "⚡ Force-stopped. The agent was still starting — session unlocked."
# Queue the message so it will be picked up after the
@@ -3102,6 +3285,9 @@ class GatewayRunner:
if canonical == "status":
return await self._handle_status_command(event)
if canonical == "agents":
return await self._handle_agents_command(event)
if canonical == "restart":
return await self._handle_restart_command(event)
@@ -3202,6 +3388,21 @@ class GatewayRunner:
if canonical == "btw":
return await self._handle_btw_command(event)
if canonical == "steer":
# No active agent — /steer has no tool call to inject into.
# Strip the prefix so downstream treats it as a normal user
# message. If the payload is empty, surface the usage hint.
steer_payload = event.get_command_args().strip()
if not steer_payload:
return "Usage: /steer <prompt> (no agent is running; sending as a normal message)"
try:
event.text = steer_payload
except Exception:
pass
# Do NOT return — fall through to _handle_message_with_agent
# at the end of this function so the rewritten text is sent
# to the agent as a regular user turn.
if canonical == "voice":
return await self._handle_voice_command(event)
@@ -3354,8 +3555,13 @@ class GatewayRunner:
# (exception, command fallthrough, etc.) the sentinel must
# not linger or the session would be permanently locked out.
if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL:
del self._running_agents[_quick_key]
self._running_agents_ts.pop(_quick_key, None)
self._release_running_agent_state(_quick_key)
else:
# Agent path already cleaned _running_agents; make sure
# the paired metadata dicts are gone too.
self._running_agents_ts.pop(_quick_key, None)
if hasattr(self, "_busy_ack_ts"):
self._busy_ack_ts.pop(_quick_key, None)
async def _prepare_inbound_message_text(
self,
@@ -4026,8 +4232,20 @@ class GatewayRunner:
# Successful turn — clear any stuck-loop counter for this session.
# This ensures the counter only accumulates across CONSECUTIVE
# restarts where the session was active (never completed).
#
# Also clear the resume_pending flag (set by drain-timeout
# shutdown) — the turn ran to completion, so recovery
# succeeded and subsequent messages should no longer receive
# the restart-interruption system note.
if session_key:
self._clear_restart_failure_count(session_key)
try:
self.session_store.clear_resume_pending(session_key)
except Exception as _e:
logger.debug(
"clear_resume_pending failed for %s: %s",
session_key[:20], _e,
)
# Surface error details when the agent failed silently (final_response=None)
if not response and agent_result.get("failed"):
@@ -4552,6 +4770,96 @@ class GatewayRunner:
])
return "\n".join(lines)
async def _handle_agents_command(self, event: MessageEvent) -> str:
"""Handle /agents command - list active agents and running tasks."""
from tools.process_registry import format_uptime_short, process_registry
now = time.time()
current_session_key = self._session_key_for_source(event.source)
running_agents: dict = getattr(self, "_running_agents", {}) or {}
running_started: dict = getattr(self, "_running_agents_ts", {}) or {}
agent_rows: list[dict] = []
for session_key, agent in running_agents.items():
started = float(running_started.get(session_key, now))
elapsed = max(0, int(now - started))
is_pending = agent is _AGENT_PENDING_SENTINEL
agent_rows.append(
{
"session_key": session_key,
"elapsed": elapsed,
"state": "starting" if is_pending else "running",
"session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""),
"model": "" if is_pending else str(getattr(agent, "model", "") or ""),
}
)
agent_rows.sort(key=lambda row: row["elapsed"], reverse=True)
running_processes: list[dict] = []
try:
running_processes = [
p for p in process_registry.list_sessions()
if p.get("status") == "running"
]
except Exception:
running_processes = []
background_tasks = [
t for t in (getattr(self, "_background_tasks", set()) or set())
if hasattr(t, "done") and not t.done()
]
lines = [
"🤖 **Active Agents & Tasks**",
"",
f"**Active agents:** {len(agent_rows)}",
]
if agent_rows:
for idx, row in enumerate(agent_rows[:12], 1):
current = " · this chat" if row["session_key"] == current_session_key else ""
sid = f" · `{row['session_id']}`" if row["session_id"] else ""
model = f" · `{row['model']}`" if row["model"] else ""
lines.append(
f"{idx}. `{row['session_key']}` · {row['state']} · "
f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}"
)
if len(agent_rows) > 12:
lines.append(f"... and {len(agent_rows) - 12} more")
lines.extend(
[
"",
f"**Running background processes:** {len(running_processes)}",
]
)
if running_processes:
for proc in running_processes[:12]:
cmd = " ".join(str(proc.get("command", "")).split())
if len(cmd) > 90:
cmd = cmd[:87] + "..."
lines.append(
f"- `{proc.get('session_id', '?')}` · "
f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`"
)
if len(running_processes) > 12:
lines.append(f"... and {len(running_processes) - 12} more")
lines.extend(
[
"",
f"**Gateway async jobs:** {len(background_tasks)}",
]
)
if not agent_rows and not running_processes and not background_tasks:
lines.append("")
lines.append("No active agents or running tasks.")
return "\n".join(lines)
async def _handle_stop_command(self, event: MessageEvent) -> str:
"""Handle /stop command - interrupt a running agent.
@@ -4571,22 +4879,40 @@ class GatewayRunner:
agent = self._running_agents.get(session_key)
if agent is _AGENT_PENDING_SENTINEL:
# Force-clean the sentinel so the session is unlocked.
if session_key in self._running_agents:
del self._running_agents[session_key]
self._release_running_agent_state(session_key)
logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20])
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
if agent:
agent.interrupt("Stop requested")
# Force-clean the session lock so a truly hung agent doesn't
# keep it locked forever.
if session_key in self._running_agents:
del self._running_agents[session_key]
self._release_running_agent_state(session_key)
return "⚡ Stopped. You can continue this session."
else:
return "No active task to stop."
async def _handle_restart_command(self, event: MessageEvent) -> str:
"""Handle /restart command - drain active work, then restart the gateway."""
# Defensive idempotency check: if the previous gateway process
# recorded this same /restart (same platform + update_id) and the new
# process is seeing it *again*, this is a re-delivery caused by PTB's
# graceful-shutdown `get_updates` ACK failing on the way out ("Error
# while calling `get_updates` one more time to mark all fetched
# updates. Suppressing error to ensure graceful shutdown. When
# polling for updates is restarted, updates may be received twice."
# in gateway.log). Ignoring the stale redelivery prevents a
# self-perpetuating restart loop where every fresh gateway
# re-processes the same /restart command and immediately restarts
# again.
if self._is_stale_restart_redelivery(event):
logger.info(
"Ignoring redelivered /restart (platform=%s, update_id=%s) — "
"already processed by a previous gateway instance.",
event.source.platform.value if event.source and event.source.platform else "?",
event.platform_update_id,
)
return ""
if self._restart_requested or self._draining:
count = self._running_agent_count()
if count:
@@ -4609,6 +4935,26 @@ class GatewayRunner:
except Exception as e:
logger.debug("Failed to write restart notify file: %s", e)
# Record the triggering platform + update_id in a dedicated dedup
# marker. Unlike .restart_notify.json (which gets unlinked once the
# new gateway sends the "gateway restarted" notification), this
# marker persists so the new gateway can still detect a delayed
# /restart redelivery from Telegram. Overwritten on every /restart.
try:
import json as _json
import time as _time
dedup_data = {
"platform": event.source.platform.value if event.source.platform else None,
"requested_at": _time.time(),
}
if event.platform_update_id is not None:
dedup_data["update_id"] = event.platform_update_id
(_hermes_home / ".restart_last_processed.json").write_text(
_json.dumps(dedup_data)
)
except Exception as e:
logger.debug("Failed to write restart dedup marker: %s", e)
active_agents = self._running_agent_count()
# When running under a service manager (systemd/launchd), use the
# service restart path: exit with code 75 so the service manager
@@ -4624,6 +4970,58 @@ class GatewayRunner:
return f"⏳ Draining {active_agents} active agent(s) before restart..."
return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`."
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
"""Return True if this /restart is a Telegram re-delivery we already handled.
The previous gateway wrote ``.restart_last_processed.json`` with the
triggering platform + update_id when it processed the /restart. If
we now see a /restart on the same platform with an update_id <= that
recorded value AND the marker is recent (< 5 minutes), it's a
redelivery and should be ignored.
Only applies to Telegram today (the only platform that exposes a
numeric cross-session update ordering); other platforms return False.
"""
if event is None or event.source is None:
return False
if event.platform_update_id is None:
return False
if event.source.platform is None:
return False
# Only Telegram populates platform_update_id currently; be explicit
# so future platforms aren't accidentally gated by this check.
try:
platform_value = event.source.platform.value
except Exception:
return False
if platform_value != "telegram":
return False
try:
import json as _json
import time as _time
marker_path = _hermes_home / ".restart_last_processed.json"
if not marker_path.exists():
return False
data = _json.loads(marker_path.read_text())
except Exception:
return False
if data.get("platform") != platform_value:
return False
recorded_uid = data.get("update_id")
if not isinstance(recorded_uid, int):
return False
# Staleness guard: ignore markers older than 5 minutes. A legitimately
# old marker (e.g. crash recovery where notify never fired) should not
# swallow a fresh /restart from the user.
requested_at = data.get("requested_at")
if isinstance(requested_at, (int, float)):
if _time.time() - requested_at > 300:
return False
return event.platform_update_id <= recorded_uid
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
from hermes_cli.commands import gateway_help_lines
@@ -5369,8 +5767,7 @@ class GatewayRunner:
if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower:
return (
"Voice dependencies are missing (PyNaCl / davey). "
"Install or reinstall Hermes with the messaging extra, e.g. "
"`pip install hermes-agent[messaging]`."
f"Install with: `{sys.executable} -m pip install PyNaCl`"
)
return f"Failed to join voice channel: {e}"
@@ -6496,8 +6893,7 @@ class GatewayRunner:
logger.debug("Memory flush on resume failed: %s", e)
# Clear any running agent for this session key
if session_key in self._running_agents:
del self._running_agents[session_key]
self._release_running_agent_state(session_key)
# Switch the session entry to point at the old session
new_entry = self.session_store.switch_session(session_key, target_id)
@@ -7913,6 +8309,30 @@ class GatewayRunner:
override = self._session_model_overrides.get(session_key)
return override is not None and override.get("model") == agent_model
def _release_running_agent_state(self, session_key: str) -> None:
"""Pop ALL per-running-agent state entries for ``session_key``.
Replaces ad-hoc ``del self._running_agents[key]`` calls scattered
across the gateway. Those sites had drifted: some popped only
``_running_agents``; some also ``_running_agents_ts``; only one
path also cleared ``_busy_ack_ts``. Each missed entry was a
small, persistent leak a (str_key float) tuple per session
per gateway lifetime.
Use this at every site that ends a running turn, regardless of
cause (normal completion, /stop, /reset, /resume, sentinel
cleanup, stale-eviction). Per-session state that PERSISTS
across turns (``_session_model_overrides``, ``_voice_mode``,
``_pending_approvals``, ``_update_prompt_pending``) is NOT
touched here those have their own lifecycles.
"""
if not session_key:
return
self._running_agents.pop(session_key, None)
self._running_agents_ts.pop(session_key, None)
if hasattr(self, "_busy_ack_ts"):
self._busy_ack_ts.pop(session_key, None)
def _evict_cached_agent(self, session_key: str) -> None:
"""Remove a cached agent for a session (called on /new, /model, etc)."""
_lock = getattr(self, "_agent_cache_lock", None)
@@ -9099,7 +9519,40 @@ class GatewayRunner:
# restart, crash, SIGTERM). Prepend a system note so the model
# finishes processing the pending tool results before addressing
# the user's new message. (#4493)
if agent_history and agent_history[-1].get("role") == "tool":
#
# Session-level resume_pending (set on drain-timeout shutdown)
# escalates the wording — the transcript's last role may be
# anything (tool, assistant with unfinished work, etc.), so we
# give a stronger, reason-aware instruction that subsumes the
# tool-tail case.
_resume_entry = None
if session_key:
try:
_resume_entry = self.session_store._entries.get(session_key)
except Exception:
_resume_entry = None
_is_resume_pending = bool(
_resume_entry is not None and getattr(_resume_entry, "resume_pending", False)
)
if _is_resume_pending:
_reason = getattr(_resume_entry, "resume_reason", None) or "restart_timeout"
_reason_phrase = (
"a gateway restart"
if _reason == "restart_timeout"
else "a gateway shutdown"
if _reason == "shutdown_timeout"
else "a gateway interruption"
)
message = (
f"[System note: Your previous turn in this session was interrupted "
f"by {_reason_phrase}. The conversation history below is intact. "
f"If it contains unfinished tool result(s), process them first and "
f"summarize what was accomplished, then address the user's new "
f"message below.]\n\n"
+ message
)
elif agent_history and agent_history[-1].get("role") == "tool":
message = (
"[System note: Your previous turn was interrupted before you could "
"process the last tool result(s). The conversation history contains "
@@ -9748,10 +10201,8 @@ class GatewayRunner:
# Clean up tracking
tracking_task.cancel()
if session_key and session_key in self._running_agents:
del self._running_agents[session_key]
if session_key:
self._running_agents_ts.pop(session_key, None)
self._release_running_agent_state(session_key)
if self._draining:
self._update_runtime_status("draining")
@@ -9880,6 +10331,16 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
"Replacing existing gateway instance (PID %d) with --replace.",
existing_pid,
)
# Record a takeover marker so the target's shutdown handler
# recognises its SIGTERM as a planned takeover and exits 0
# (rather than exit 1, which would trigger systemd's
# Restart=on-failure and start a flap loop against us).
# Best-effort — proceed even if the write fails.
try:
from gateway.status import write_takeover_marker
write_takeover_marker(existing_pid)
except Exception as e:
logger.debug("Could not write takeover marker: %s", e)
try:
terminate_pid(existing_pid, force=False)
except ProcessLookupError:
@@ -9889,6 +10350,13 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
"Permission denied killing PID %d. Cannot replace.",
existing_pid,
)
# Marker is scoped to a specific target; clean it up on
# give-up so it doesn't grief an unrelated future shutdown.
try:
from gateway.status import clear_takeover_marker
clear_takeover_marker()
except Exception:
pass
return False
# Wait up to 10 seconds for the old process to exit
for _ in range(20):
@@ -9909,6 +10377,13 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
except (ProcessLookupError, PermissionError, OSError):
pass
remove_pid_file()
# Clean up any takeover marker the old process didn't consume
# (e.g. SIGKILL'd before its shutdown handler could read it).
try:
from gateway.status import clear_takeover_marker
clear_takeover_marker()
except Exception:
pass
# Also release all scoped locks left by the old process.
# Stopped (Ctrl+Z) processes don't release locks on exit,
# leaving stale lock files that block the new gateway from starting.
@@ -9976,8 +10451,27 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
# Set up signal handlers
def shutdown_signal_handler():
nonlocal _signal_initiated_shutdown
_signal_initiated_shutdown = True
logger.info("Received SIGTERM/SIGINT — initiating shutdown")
# Planned --replace takeover check: when a sibling gateway is
# taking over via --replace, it wrote a marker naming this PID
# before sending SIGTERM. If present, treat the signal as a
# planned shutdown and exit 0 so systemd's Restart=on-failure
# doesn't revive us (which would flap-fight the replacer when
# both services are enabled, e.g. hermes.service + hermes-
# gateway.service from pre-rename installs).
planned_takeover = False
try:
from gateway.status import consume_takeover_marker_for_self
planned_takeover = consume_takeover_marker_for_self()
except Exception as e:
logger.debug("Takeover marker check failed: %s", e)
if planned_takeover:
logger.info(
"Received SIGTERM as a planned --replace takeover — exiting cleanly"
)
else:
_signal_initiated_shutdown = True
logger.info("Received SIGTERM/SIGINT — initiating shutdown")
# Diagnostic: log all hermes-related processes so we can identify
# what triggered the signal (hermes update, hermes gateway restart,
# a stale detached subprocess, etc.).
+155 -3
View File
@@ -377,7 +377,19 @@ class SessionEntry:
# this session (create a new session_id) so the user starts fresh.
# Set by /stop to break stuck-resume loops (#7536).
suspended: bool = False
# When True the session was interrupted by a gateway restart/shutdown
# drain timeout, but recovery is still expected. Unlike ``suspended``,
# ``resume_pending`` preserves the existing session_id on next access —
# the user stays on the same transcript and the agent auto-continues
# from where it left off. Cleared after the next successful turn.
# Escalation to ``suspended`` is handled by the existing
# ``.restart_failure_counts`` stuck-loop counter (#7536), not by a
# parallel counter on this entry.
resume_pending: bool = False
resume_reason: Optional[str] = None # e.g. "restart_timeout"
last_resume_marked_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
result = {
"session_key": self.session_key,
@@ -397,6 +409,13 @@ class SessionEntry:
"cost_status": self.cost_status,
"memory_flushed": self.memory_flushed,
"suspended": self.suspended,
"resume_pending": self.resume_pending,
"resume_reason": self.resume_reason,
"last_resume_marked_at": (
self.last_resume_marked_at.isoformat()
if self.last_resume_marked_at
else None
),
}
if self.origin:
result["origin"] = self.origin.to_dict()
@@ -414,7 +433,15 @@ class SessionEntry:
platform = Platform(data["platform"])
except ValueError as e:
logger.debug("Unknown platform value %r: %s", data["platform"], e)
last_resume_marked_at = None
_lrma = data.get("last_resume_marked_at")
if _lrma:
try:
last_resume_marked_at = datetime.fromisoformat(_lrma)
except (TypeError, ValueError):
last_resume_marked_at = None
return cls(
session_key=data["session_key"],
session_id=data["session_id"],
@@ -434,6 +461,9 @@ class SessionEntry:
cost_status=data.get("cost_status", "unknown"),
memory_flushed=data.get("memory_flushed", False),
suspended=data.get("suspended", False),
resume_pending=data.get("resume_pending", False),
resume_reason=data.get("resume_reason"),
last_resume_marked_at=last_resume_marked_at,
)
@@ -710,9 +740,23 @@ class SessionStore:
entry = self._entries[session_key]
# Auto-reset sessions marked as suspended (e.g. after /stop
# broke a stuck loop — #7536).
# broke a stuck loop — #7536). ``suspended`` is the hard
# forced-wipe signal and always wins over ``resume_pending``,
# so repeated interrupted restarts that escalate via the
# existing ``.restart_failure_counts`` stuck-loop counter
# still converge to a clean slate.
if entry.suspended:
reset_reason = "suspended"
elif entry.resume_pending:
# Restart-interrupted session: preserve the session_id
# and return the existing entry so the transcript
# reloads intact. ``resume_pending`` is cleared after
# the NEXT successful turn completes (not here), which
# means a re-interrupted retry keeps trying — the
# stuck-loop counter handles terminal escalation.
entry.updated_at = now
self._save()
return entry
else:
reset_reason = self._should_reset(entry, source)
if not reset_reason:
@@ -802,6 +846,106 @@ class SessionStore:
return True
return False
def mark_resume_pending(
self,
session_key: str,
reason: str = "restart_timeout",
) -> bool:
"""Mark a session as resumable after a restart interruption.
Unlike ``suspend_session()``, this preserves the existing
``session_id`` and the transcript. The next call to
``get_or_create_session()`` for this key returns the same entry
so the user auto-resumes on the same conversation lane.
Returns True if the session existed and was marked.
"""
with self._lock:
self._ensure_loaded_locked()
if session_key in self._entries:
entry = self._entries[session_key]
# Never override an explicit ``suspended`` — that is a hard
# forced-wipe signal (from /stop or stuck-loop escalation).
if entry.suspended:
return False
entry.resume_pending = True
entry.resume_reason = reason
entry.last_resume_marked_at = _now()
self._save()
return True
return False
def clear_resume_pending(self, session_key: str) -> bool:
"""Clear the resume-pending flag after a successful resumed turn.
Called from the gateway after ``run_conversation()`` returns a
final response for a session that had ``resume_pending=True``,
signalling that recovery succeeded.
Returns True if a flag was cleared.
"""
with self._lock:
self._ensure_loaded_locked()
entry = self._entries.get(session_key)
if entry is None or not entry.resume_pending:
return False
entry.resume_pending = False
entry.resume_reason = None
entry.last_resume_marked_at = None
self._save()
return True
def prune_old_entries(self, max_age_days: int) -> int:
"""Drop SessionEntry records older than max_age_days.
Pruning is based on ``updated_at`` (last activity), not ``created_at``.
A session that's been active within the window is kept regardless of
how old it is. Entries marked ``suspended`` are kept the user
explicitly paused them for later resume. Entries held by an active
process (via has_active_processes_fn) are also kept so long-running
background work isn't orphaned.
Pruning is functionally identical to a natural reset-policy expiry:
the transcript in SQLite stays, but the session_key session_id
mapping is dropped and the user starts a fresh session on return.
``max_age_days <= 0`` disables pruning; returns 0 immediately.
Returns the number of entries removed.
"""
if max_age_days is None or max_age_days <= 0:
return 0
from datetime import timedelta
cutoff = _now() - timedelta(days=max_age_days)
removed_keys: list[str] = []
with self._lock:
self._ensure_loaded_locked()
for key, entry in list(self._entries.items()):
if entry.suspended:
continue
# Never prune sessions with an active background process
# attached — the user may still be waiting on output.
if self._has_active_processes_fn is not None:
try:
if self._has_active_processes_fn(entry.session_id):
continue
except Exception:
pass
if entry.updated_at < cutoff:
removed_keys.append(key)
for key in removed_keys:
self._entries.pop(key, None)
if removed_keys:
self._save()
if removed_keys:
logger.info(
"SessionStore pruned %d entries older than %d days",
len(removed_keys), max_age_days,
)
return len(removed_keys)
def suspend_recently_active(self, max_age_seconds: int = 120) -> int:
"""Mark recently-active sessions as suspended.
@@ -810,6 +954,12 @@ class SessionStore:
(#7536). Only suspends sessions updated within *max_age_seconds*
to avoid resetting long-idle sessions that are harmless to resume.
Returns the number of sessions that were suspended.
Entries flagged ``resume_pending=True`` are skipped those were
marked intentionally by the drain-timeout path as recoverable.
Terminal escalation for genuinely stuck ``resume_pending`` sessions
is handled by the existing ``.restart_failure_counts`` stuck-loop
counter, which runs after this method on startup.
"""
from datetime import timedelta
@@ -818,6 +968,8 @@ class SessionStore:
with self._lock:
self._ensure_loaded_locked()
for entry in self._entries.values():
if entry.resume_pending:
continue
if not entry.suspended and entry.updated_at >= cutoff:
entry.suspended = True
count += 1
+159 -11
View File
@@ -188,8 +188,8 @@ def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload))
def _read_pid_record() -> Optional[dict]:
pid_path = _get_pid_path()
def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
pid_path = pid_path or _get_pid_path()
if not pid_path.exists():
return None
@@ -212,6 +212,18 @@ def _read_pid_record() -> Optional[dict]:
return None
def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None:
if not cleanup_stale:
return
try:
if pid_path == _get_pid_path():
remove_pid_file()
else:
pid_path.unlink(missing_ok=True)
except Exception:
pass
def write_pid_file() -> None:
"""Write the current process PID and metadata to the gateway PID file."""
_write_json_file(_get_pid_path(), _build_pid_record())
@@ -413,43 +425,179 @@ def release_all_scoped_locks() -> int:
return removed
def get_running_pid() -> Optional[int]:
# ── --replace takeover marker ─────────────────────────────────────────
#
# When a new gateway starts with ``--replace``, it SIGTERMs the existing
# gateway so it can take over the bot token. PR #5646 made SIGTERM exit
# the gateway with code 1 so ``Restart=on-failure`` can revive it after
# unexpected kills — but that also means a --replace takeover target
# exits 1, which tricks systemd into reviving it 30 seconds later,
# starting a flap loop against the replacer when both services are
# enabled in the user's systemd (e.g. ``hermes.service`` + ``hermes-
# gateway.service``).
#
# The takeover marker breaks the loop: the replacer writes a short-lived
# file naming the target PID + start_time BEFORE sending SIGTERM.
# The target's shutdown handler reads the marker and, if it names
# this process, treats the SIGTERM as a planned takeover and exits 0.
# The marker is unlinked after the target has consumed it, so a stale
# marker left by a crashed replacer can grief at most one future
# shutdown on the same PID — and only within _TAKEOVER_MARKER_TTL_S.
_TAKEOVER_MARKER_FILENAME = ".gateway-takeover.json"
_TAKEOVER_MARKER_TTL_S = 60 # Marker older than this is treated as stale
def _get_takeover_marker_path() -> Path:
"""Return the path to the --replace takeover marker file."""
home = get_hermes_home()
return home / _TAKEOVER_MARKER_FILENAME
def write_takeover_marker(target_pid: int) -> bool:
"""Record that ``target_pid`` is being replaced by the current process.
Captures the target's ``start_time`` so that PID reuse after the
target exits cannot later match the marker. Also records the
replacer's PID and a UTC timestamp for TTL-based staleness checks.
Returns True on successful write, False on any failure. The caller
should proceed with the SIGTERM even if the write fails (the marker
is a best-effort signal, not a correctness requirement).
"""
try:
target_start_time = _get_process_start_time(target_pid)
record = {
"target_pid": target_pid,
"target_start_time": target_start_time,
"replacer_pid": os.getpid(),
"written_at": _utc_now_iso(),
}
_write_json_file(_get_takeover_marker_path(), record)
return True
except (OSError, PermissionError):
return False
def consume_takeover_marker_for_self() -> bool:
"""Check & unlink the takeover marker if it names the current process.
Returns True only when a valid (non-stale) marker names this PID +
start_time. A returning True indicates the current SIGTERM is a
planned --replace takeover; the caller should exit 0 instead of
signalling ``_signal_initiated_shutdown``.
Always unlinks the marker on match (and on detected staleness) so
subsequent unrelated signals don't re-trigger.
"""
path = _get_takeover_marker_path()
record = _read_json_file(path)
if not record:
return False
# Any malformed or stale marker → drop it and return False
try:
target_pid = int(record["target_pid"])
target_start_time = record.get("target_start_time")
written_at = record.get("written_at") or ""
except (KeyError, TypeError, ValueError):
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
# TTL guard: a stale marker older than _TAKEOVER_MARKER_TTL_S is ignored.
stale = False
try:
written_dt = datetime.fromisoformat(written_at)
age = (datetime.now(timezone.utc) - written_dt).total_seconds()
if age > _TAKEOVER_MARKER_TTL_S:
stale = True
except (TypeError, ValueError):
stale = True # Unparseable timestamp — treat as stale
if stale:
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
# Does the marker name THIS process?
our_pid = os.getpid()
our_start_time = _get_process_start_time(our_pid)
matches = (
target_pid == our_pid
and target_start_time is not None
and our_start_time is not None
and target_start_time == our_start_time
)
# Consume the marker whether it matched or not — a marker that doesn't
# match our identity is stale-for-us anyway.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return matches
def clear_takeover_marker() -> None:
"""Remove the takeover marker unconditionally. Safe to call repeatedly."""
try:
_get_takeover_marker_path().unlink(missing_ok=True)
except OSError:
pass
def get_running_pid(
pid_path: Optional[Path] = None,
*,
cleanup_stale: bool = True,
) -> Optional[int]:
"""Return the PID of a running gateway instance, or ``None``.
Checks the PID file and verifies the process is actually alive.
Cleans up stale PID files automatically.
"""
record = _read_pid_record()
resolved_pid_path = pid_path or _get_pid_path()
record = _read_pid_record(resolved_pid_path)
if not record:
remove_pid_file()
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return None
try:
pid = int(record["pid"])
except (KeyError, TypeError, ValueError):
remove_pid_file()
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return None
try:
os.kill(pid, 0) # signal 0 = existence check, no actual signal sent
except (ProcessLookupError, PermissionError):
remove_pid_file()
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return None
recorded_start = record.get("start_time")
current_start = _get_process_start_time(pid)
if recorded_start is not None and current_start is not None and current_start != recorded_start:
remove_pid_file()
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return None
if not _looks_like_gateway_process(pid):
if not _record_looks_like_gateway(record):
remove_pid_file()
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return None
return pid
def is_gateway_running() -> bool:
def is_gateway_running(
pid_path: Optional[Path] = None,
*,
cleanup_stale: bool = True,
) -> bool:
"""Check if the gateway daemon is currently running."""
return get_running_pid() is not None
return get_running_pid(pid_path, cleanup_stale=cleanup_stale) is not None
+94 -6
View File
@@ -100,6 +100,14 @@ class GatewayStreamConsumer:
self._flood_strikes = 0 # Consecutive flood-control edit failures
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
self._final_response_sent = False
# Cache adapter lifecycle capability: only platforms that need an
# explicit finalize call (e.g. DingTalk AI Cards) force us to make
# a redundant final edit. Everyone else keeps the fast path.
# Use ``is True`` (not ``bool(...)``) so MagicMock attribute access
# in tests doesn't incorrectly enable this path.
self._adapter_requires_finalize: bool = (
getattr(adapter, "REQUIRES_EDIT_FINALIZE", False) is True
)
# Think-block filter state (mirrors CLI's _stream_delta tag suppression)
self._in_think_block = False
@@ -361,7 +369,16 @@ class GatewayStreamConsumer:
if not got_done and not got_segment_break and commentary_text is None:
display_text += self.cfg.cursor
current_update_visible = await self._send_or_edit(display_text)
# Segment break: finalize the current message so platforms
# that need explicit closure (e.g. DingTalk AI Cards) don't
# leave the previous segment stuck in a loading state when
# the next segment (tool progress, next chunk) creates a
# new message below it. got_done has its own finalize
# path below so we don't finalize here for it.
current_update_visible = await self._send_or_edit(
display_text,
finalize=got_segment_break,
)
self._last_edit_time = time.monotonic()
if got_done:
@@ -372,10 +389,22 @@ class GatewayStreamConsumer:
if self._accumulated:
if self._fallback_final_send:
await self._send_fallback_final(self._accumulated)
elif current_update_visible:
elif (
current_update_visible
and not self._adapter_requires_finalize
):
# Mid-stream edit above already delivered the
# final accumulated content. Skip the redundant
# final edit — but only for adapters that don't
# need an explicit finalize signal.
self._final_response_sent = True
elif self._message_id:
self._final_response_sent = await self._send_or_edit(self._accumulated)
# Either the mid-stream edit didn't run (no
# visible update this tick) OR the adapter needs
# explicit finalize=True to close the stream.
self._final_response_sent = await self._send_or_edit(
self._accumulated, finalize=True,
)
elif not self._already_sent:
self._final_response_sent = await self._send_or_edit(self._accumulated)
return
@@ -401,6 +430,21 @@ class GatewayStreamConsumer:
# a real string like "msg_1", not "__no_edit__", so that case
# still resets and creates a fresh segment as intended.)
if got_segment_break:
# If the segment-break edit failed to deliver the
# accumulated content (flood control that has not yet
# promoted to fallback mode, or fallback mode itself),
# _accumulated still holds pre-boundary text the user
# never saw. Flush that tail as a continuation message
# before the reset below wipes _accumulated — otherwise
# text generated before the tool boundary is silently
# dropped (issue #8124).
if (
self._accumulated
and not current_update_visible
and self._message_id
and self._message_id != "__no_edit__"
):
await self._flush_segment_tail_on_edit_failure()
self._reset_segment_state(preserve_no_edit=True)
await asyncio.sleep(0.05) # Small yield to not busy-loop
@@ -591,6 +635,39 @@ class GatewayStreamConsumer:
err_lower = err.lower()
return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower
async def _flush_segment_tail_on_edit_failure(self) -> None:
"""Deliver un-sent tail content before a segment-break reset.
When an edit fails (flood control, transport error) and a tool
boundary arrives before the next retry, ``_accumulated`` holds text
that was generated but never shown to the user. Without this flush,
the segment reset would discard that tail and leave a frozen cursor
in the partial message.
Sends the tail that sits after the last successfully-delivered
prefix as a new message, and best-effort strips the stuck cursor
from the previous partial message.
"""
if not self._fallback_final_send:
await self._try_strip_cursor()
visible = self._fallback_prefix or self._visible_prefix()
tail = self._accumulated
if visible and tail.startswith(visible):
tail = tail[len(visible):].lstrip()
tail = self._clean_for_display(tail)
if not tail.strip():
return
try:
result = await self.adapter.send(
chat_id=self.chat_id,
content=tail,
metadata=self.metadata,
)
if result.success:
self._already_sent = True
except Exception as e:
logger.error("Segment-break tail flush error: %s", e)
async def _try_strip_cursor(self) -> None:
"""Best-effort edit to remove the cursor from the last visible message.
@@ -633,12 +710,15 @@ class GatewayStreamConsumer:
logger.error("Commentary send error: %s", e)
return False
async def _send_or_edit(self, text: str) -> bool:
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
"""Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited),
False otherwise. Callers like the overflow split loop use this to
decide whether to advance past the delivered chunk.
``finalize`` is True when this is the last edit in a streaming
sequence.
"""
# Strip MEDIA: directives so they don't appear as visible text.
# Media files are delivered as native attachments after the stream
@@ -672,14 +752,22 @@ class GatewayStreamConsumer:
try:
if self._message_id is not None:
if self._edit_supported:
# Skip if text is identical to what we last sent
if text == self._last_sent_text:
# Skip if text is identical to what we last sent.
# Exception: adapters that require an explicit finalize
# call (REQUIRES_EDIT_FINALIZE) must still receive the
# finalize=True edit even when content is unchanged, so
# their streaming UI can transition out of the in-
# progress state. Everyone else short-circuits.
if text == self._last_sent_text and not (
finalize and self._adapter_requires_finalize
):
return True
# Edit existing message
result = await self.adapter.edit_message(
chat_id=self.chat_id,
message_id=self._message_id,
content=text,
finalize=finalize,
)
if result.success:
self._already_sent = True
+70 -68
View File
@@ -233,6 +233,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("XAI_API_KEY",),
base_url_env_var="XAI_BASE_URL",
),
"nvidia": ProviderConfig(
id="nvidia",
name="NVIDIA NIM",
auth_type="api_key",
inference_base_url="https://integrate.api.nvidia.com/v1",
api_key_env_vars=("NVIDIA_API_KEY",),
base_url_env_var="NVIDIA_BASE_URL",
),
"ai-gateway": ProviderConfig(
id="ai-gateway",
name="Vercel AI Gateway",
@@ -1426,49 +1434,6 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
}
def _write_codex_cli_tokens(
access_token: str,
refresh_token: str,
*,
last_refresh: Optional[str] = None,
) -> None:
"""Write refreshed tokens back to ~/.codex/auth.json.
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When Hermes refreshes a token it consumes the old refresh_token; if we
don't write the new pair back, the Codex CLI (or VS Code extension) will
fail with ``refresh_token_reused`` on its next refresh attempt.
This mirrors the Anthropic write-back to ~/.claude/.credentials.json
via ``_write_claude_code_credentials()``.
"""
codex_home = os.getenv("CODEX_HOME", "").strip()
if not codex_home:
codex_home = str(Path.home() / ".codex")
auth_path = Path(codex_home).expanduser() / "auth.json"
try:
existing: Dict[str, Any] = {}
if auth_path.is_file():
existing = json.loads(auth_path.read_text(encoding="utf-8"))
if not isinstance(existing, dict):
existing = {}
tokens_dict = existing.get("tokens")
if not isinstance(tokens_dict, dict):
tokens_dict = {}
tokens_dict["access_token"] = access_token
tokens_dict["refresh_token"] = refresh_token
existing["tokens"] = tokens_dict
if last_refresh is not None:
existing["last_refresh"] = last_refresh
auth_path.parent.mkdir(parents=True, exist_ok=True)
auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
auth_path.chmod(0o600)
except (OSError, IOError) as exc:
logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc)
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
if last_refresh is None:
@@ -1536,6 +1501,11 @@ def refresh_codex_oauth_pure(
"then run `hermes auth` to re-authenticate."
)
relogin_required = True
# A 401/403 from the token endpoint always means the refresh token
# is invalid/expired — force relogin even if the body error code
# wasn't one of the known strings above.
if response.status_code in (401, 403) and not relogin_required:
relogin_required = True
raise AuthError(
message,
provider="openai-codex",
@@ -1591,12 +1561,6 @@ def _refresh_codex_auth_tokens(
updated_tokens["refresh_token"] = refreshed["refresh_token"]
_save_codex_tokens(updated_tokens)
# Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync.
_write_codex_cli_tokens(
refreshed["access_token"],
refreshed["refresh_token"],
last_refresh=refreshed.get("last_refresh"),
)
return updated_tokens
@@ -1641,25 +1605,7 @@ def resolve_codex_runtime_credentials(
refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
"""Resolve runtime credentials from Hermes's own Codex token store."""
try:
data = _read_codex_tokens()
except AuthError as orig_err:
# Only attempt migration when there are NO tokens stored at all
# (code == "codex_auth_missing"), not when tokens exist but are invalid.
if orig_err.code != "codex_auth_missing":
raise
# Migration: user had Codex as active provider with old storage (~/.codex/).
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store")
print("⚠️ Migrating Codex credentials to Hermes's own auth store.")
print(" This avoids conflicts with Codex CLI and VS Code.")
print(" Run `hermes auth` to create a fully independent session.\n")
_save_codex_tokens(cli_tokens)
data = _read_codex_tokens()
else:
raise
data = _read_codex_tokens()
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20"))
@@ -2151,6 +2097,62 @@ def refresh_nous_oauth_from_state(
)
NOUS_DEVICE_CODE_SOURCE = "device_code"
def persist_nous_credentials(
creds: Dict[str, Any],
*,
label: Optional[str] = None,
):
"""Persist minted Nous OAuth credentials as the singleton provider state
and ensure the credential pool is in sync.
Nous credentials are read at runtime from two independent locations:
- ``providers.nous``: singleton state read by
``resolve_nous_runtime_credentials()`` during 401 recovery and by
``_seed_from_singletons()`` during pool load.
- ``credential_pool.nous``: used by the runtime ``pool.select()`` path.
Historically ``hermes auth add nous`` wrote a ``manual:device_code`` pool
entry only, skipping ``providers.nous``. When the 24h agent_key TTL
expired, the recovery path read the empty singleton state and raised
``AuthError`` silently (``logger.debug`` at INFO level).
This helper writes ``providers.nous`` then calls ``load_pool("nous")`` so
``_seed_from_singletons`` materialises the canonical ``device_code`` pool
entry from the singleton. Re-running login upserts the same entry in
place; the pool never accumulates duplicate device_code rows.
``label`` is an optional user-chosen display name (from
``hermes auth add nous --label <name>``). It gets embedded in the
singleton state so that ``_seed_from_singletons`` uses it as the pool
entry's label on every subsequent ``load_pool("nous")`` instead of the
auto-derived token fingerprint. When ``None``, the auto-derived label
via ``label_from_token`` is used (unchanged default behaviour).
Returns the upserted :class:`PooledCredential` entry (or ``None`` if
seeding somehow produced no match shouldn't happen).
"""
from agent.credential_pool import load_pool
state = dict(creds)
if label and str(label).strip():
state["label"] = str(label).strip()
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
pool = load_pool("nous")
return next(
(e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE),
None,
)
def resolve_nous_runtime_credentials(
*,
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
+8 -12
View File
@@ -217,19 +217,15 @@ def auth_add_command(args) -> None:
ca_bundle=getattr(args, "ca_bundle", None),
min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))),
)
label = (getattr(args, "label", None) or "").strip() or label_from_token(
creds.get("access_token", ""),
_oauth_default_label(provider, len(pool.entries()) + 1),
# Honor `--label <name>` so nous matches other providers' UX. The
# helper embeds this into providers.nous so that label_from_token
# doesn't overwrite it on every subsequent load_pool("nous").
custom_label = (getattr(args, "label", None) or "").strip() or None
entry = auth_mod.persist_nous_credentials(creds, label=custom_label)
shown_label = entry.label if entry is not None else label_from_token(
creds.get("access_token", ""), _oauth_default_label(provider, 1),
)
entry = PooledCredential.from_dict(provider, {
**creds,
"label": label,
"auth_type": AUTH_TYPE_OAUTH,
"source": f"{SOURCE_MANUAL}:device_code",
"base_url": creds.get("inference_base_url"),
})
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
return
if provider == "openai-codex":
+119 -70
View File
@@ -7,8 +7,8 @@ CLI tools that ship with the platform (or are commonly installed).
Platform support:
macOS osascript (always available), pngpaste (if installed)
Windows PowerShell via .NET System.Windows.Forms.Clipboard
WSL2 powershell.exe via .NET System.Windows.Forms.Clipboard
Windows PowerShell via WinForms, Get-Clipboard, file-drop fallback
WSL2 powershell.exe via WinForms, Get-Clipboard, file-drop fallback
Linux wl-paste (Wayland), xclip (X11)
"""
@@ -46,10 +46,11 @@ def has_clipboard_image() -> bool:
return _macos_has_image()
if sys.platform == "win32":
return _windows_has_image()
if _is_wsl():
return _wsl_has_image()
if os.environ.get("WAYLAND_DISPLAY"):
return _wayland_has_image()
# Match _linux_save fallthrough order: WSL → Wayland → X11
if _is_wsl() and _wsl_has_image():
return True
if os.environ.get("WAYLAND_DISPLAY") and _wayland_has_image():
return True
return _xclip_has_image()
@@ -135,6 +136,114 @@ _PS_EXTRACT_IMAGE = (
"[System.Convert]::ToBase64String($ms.ToArray())"
)
_PS_CHECK_IMAGE_GET_CLIPBOARD = (
"try { "
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
"if ($null -ne $img) { 'True' } else { 'False' }"
"} catch { 'False' }"
)
_PS_EXTRACT_IMAGE_GET_CLIPBOARD = (
"try { "
"Add-Type -AssemblyName System.Drawing;"
"Add-Type -AssemblyName PresentationCore;"
"Add-Type -AssemblyName WindowsBase;"
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"if ($img -is [System.Drawing.Image]) {"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)"
"} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {"
"$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;"
"$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));"
"$enc.Save($ms)"
"} else { exit 2 }"
"[System.Convert]::ToBase64String($ms.ToArray())"
"} catch { exit 1 }"
)
_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'"
_PS_CHECK_FILEDROP_IMAGE = (
"try { "
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
"if ($null -ne $hit) { 'True' } else { 'False' }"
"} catch { 'False' }"
)
_PS_EXTRACT_FILEDROP_IMAGE = (
"try { "
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
"if ($null -eq $hit) { exit 1 }"
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))"
"} catch { exit 1 }"
)
_POWERSHELL_HAS_IMAGE_SCRIPTS = (
_PS_CHECK_IMAGE,
_PS_CHECK_IMAGE_GET_CLIPBOARD,
_PS_CHECK_FILEDROP_IMAGE,
)
_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = (
_PS_EXTRACT_IMAGE,
_PS_EXTRACT_IMAGE_GET_CLIPBOARD,
_PS_EXTRACT_FILEDROP_IMAGE,
)
def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
return subprocess.run(
[exe, "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True, text=True, timeout=timeout,
)
def _write_base64_image(dest: Path, b64_data: str) -> bool:
image_bytes = base64.b64decode(b64_data, validate=True)
dest.write_bytes(image_bytes)
return dest.exists() and dest.stat().st_size > 0
def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool:
for script in _POWERSHELL_HAS_IMAGE_SCRIPTS:
try:
r = _run_powershell(exe, script, timeout=timeout)
if r.returncode == 0 and "True" in r.stdout:
return True
except FileNotFoundError:
logger.debug("%s not found — clipboard unavailable", exe)
return False
except Exception as e:
logger.debug("%s clipboard image check failed: %s", label, e)
return False
def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool:
for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS:
try:
r = _run_powershell(exe, script, timeout=timeout)
if r.returncode != 0:
continue
b64_data = r.stdout.strip()
if not b64_data:
continue
if _write_base64_image(dest, b64_data):
return True
except FileNotFoundError:
logger.debug("%s not found — clipboard unavailable", exe)
return False
except Exception as e:
logger.debug("%s clipboard image extraction failed: %s", label, e)
dest.unlink(missing_ok=True)
return False
# ── Native Windows ────────────────────────────────────────────────────────
@@ -175,15 +284,7 @@ def _windows_has_image() -> bool:
ps = _get_ps_exe()
if ps is None:
return False
try:
r = subprocess.run(
[ps, "-NoProfile", "-NonInteractive", "-Command", _PS_CHECK_IMAGE],
capture_output=True, text=True, timeout=5,
)
return r.returncode == 0 and "True" in r.stdout
except Exception as e:
logger.debug("Windows clipboard image check failed: %s", e)
return False
return _powershell_has_image(ps, timeout=5, label="Windows")
def _windows_save(dest: Path) -> bool:
@@ -192,26 +293,7 @@ def _windows_save(dest: Path) -> bool:
if ps is None:
logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
return False
try:
r = subprocess.run(
[ps, "-NoProfile", "-NonInteractive", "-Command", _PS_EXTRACT_IMAGE],
capture_output=True, text=True, timeout=15,
)
if r.returncode != 0:
return False
b64_data = r.stdout.strip()
if not b64_data:
return False
png_bytes = base64.b64decode(b64_data)
dest.write_bytes(png_bytes)
return dest.exists() and dest.stat().st_size > 0
except Exception as e:
logger.debug("Windows clipboard image extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False
return _powershell_save_image(ps, dest, timeout=15, label="Windows")
# ── Linux ────────────────────────────────────────────────────────────────
@@ -235,45 +317,12 @@ def _linux_save(dest: Path) -> bool:
def _wsl_has_image() -> bool:
"""Check if Windows clipboard has an image (via powershell.exe)."""
try:
r = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
_PS_CHECK_IMAGE],
capture_output=True, text=True, timeout=8,
)
return r.returncode == 0 and "True" in r.stdout
except FileNotFoundError:
logger.debug("powershell.exe not found — WSL clipboard unavailable")
except Exception as e:
logger.debug("WSL clipboard check failed: %s", e)
return False
return _powershell_has_image("powershell.exe", timeout=8, label="WSL")
def _wsl_save(dest: Path) -> bool:
"""Extract clipboard image via powershell.exe → base64 → decode to PNG."""
try:
r = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
_PS_EXTRACT_IMAGE],
capture_output=True, text=True, timeout=15,
)
if r.returncode != 0:
return False
b64_data = r.stdout.strip()
if not b64_data:
return False
png_bytes = base64.b64decode(b64_data)
dest.write_bytes(png_bytes)
return dest.exists() and dest.stat().st_size > 0
except FileNotFoundError:
logger.debug("powershell.exe not found — WSL clipboard unavailable")
except Exception as e:
logger.debug("WSL clipboard extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False
return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL")
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
+112 -7
View File
@@ -87,8 +87,12 @@ COMMAND_REGISTRY: list[CommandDef] = [
aliases=("bg",), args_hint="<prompt>"),
CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session",
args_hint="<question>"),
CommandDef("agents", "Show active agents and running tasks", "Session",
aliases=("tasks",)),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
aliases=("q",), args_hint="<prompt>"),
CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session",
args_hint="<prompt>"),
CommandDef("status", "Show session info", "Session"),
CommandDef("profile", "Show active profile name and home directory", "Info"),
CommandDef("sethome", "Set this chat as the home channel", "Session",
@@ -99,7 +103,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
# Configuration
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"),
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"),
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info"),
@@ -120,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[normal|fast|status]",
subcommands=("normal", "fast", "status", "on", "off")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
@@ -155,7 +159,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
cli_only=True, aliases=("gateway",)),
CommandDef("paste", "Check clipboard for an image and attach it", "Info",
CommandDef("copy", "Copy the last assistant response to clipboard", "Info",
cli_only=True, args_hint="[number]"),
CommandDef("paste", "Attach clipboard image from your clipboard", "Info",
cli_only=True),
CommandDef("image", "Attach a local image file for your next prompt", "Info",
cli_only=True, args_hint="<path>"),
@@ -254,6 +260,53 @@ GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
)
# Commands with explicit Level-2 running-agent handlers in gateway/run.py.
# Listed here for introspection / tests; semantically a subset of
# "all resolvable commands" — which is the real bypass set (see
# should_bypass_active_session below).
ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
{
"agents",
"approve",
"background",
"commands",
"deny",
"help",
"new",
"profile",
"queue",
"restart",
"status",
"steer",
"stop",
"update",
}
)
def should_bypass_active_session(command_name: str | None) -> bool:
"""Return True for any resolvable slash command.
Rationale: every gateway-registered slash command either has a
specific Level-2 handler in gateway/run.py (/stop, /new, /model,
/approve, etc.) or reaches the running-agent catch-all that returns
a "busy — wait or /stop first" response. In both paths the command
is dispatched, not queued.
Queueing is always wrong for a recognized slash command because the
safety net in gateway.run discards any command text that reaches
the pending queue which meant a mid-run /model (or /reasoning,
/voice, /insights, /title, /resume, /retry, /undo, /compress,
/usage, /provider, /reload-mcp, /sethome, /reset) would silently
interrupt the agent AND get discarded, producing a zero-char
response. See issue #5057 / PRs #6252, #10370, #4665.
ACTIVE_SESSION_BYPASS_COMMANDS remains the subset of commands with
explicit Level-2 handlers; the rest fall through to the catch-all.
"""
return resolve_command(command_name) is not None if command_name else False
def _resolve_config_gates() -> set[str]:
"""Return canonical names of commands whose ``gateway_config_gate`` is truthy.
@@ -1044,6 +1097,51 @@ class SlashCommandCompleter(Completer):
display_meta=f"{fp} {meta}" if meta else fp,
)
@staticmethod
def _skin_completions(sub_text: str, sub_lower: str):
"""Yield completions for /skin from available skins."""
try:
from hermes_cli.skin_engine import list_skins
for s in list_skins():
name = s["name"]
if name.startswith(sub_lower) and name != sub_lower:
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=s.get("description", "") or s.get("source", ""),
)
except Exception:
pass
@staticmethod
def _personality_completions(sub_text: str, sub_lower: str):
"""Yield completions for /personality from configured personalities."""
try:
from hermes_cli.config import load_config
personalities = load_config().get("agent", {}).get("personalities", {})
if "none".startswith(sub_lower) and "none" != sub_lower:
yield Completion(
"none",
start_position=-len(sub_text),
display="none",
display_meta="clear personality overlay",
)
for name, prompt in personalities.items():
if name.startswith(sub_lower) and name != sub_lower:
if isinstance(prompt, dict):
meta = prompt.get("description") or prompt.get("system_prompt", "")[:50]
else:
meta = str(prompt)[:50]
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=meta,
)
except Exception:
pass
def _model_completions(self, sub_text: str, sub_lower: str):
"""Yield completions for /model from config aliases + built-in aliases."""
seen = set()
@@ -1098,10 +1196,17 @@ class SlashCommandCompleter(Completer):
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
# Dynamic model alias completions for /model
if " " not in sub_text and base_cmd == "/model":
yield from self._model_completions(sub_text, sub_lower)
return
# Dynamic completions for commands with runtime lists
if " " not in sub_text:
if base_cmd == "/model":
yield from self._model_completions(sub_text, sub_lower)
return
if base_cmd == "/skin":
yield from self._skin_completions(sub_text, sub_lower)
return
if base_cmd == "/personality":
yield from self._personality_completions(sub_text, sub_lower)
return
# Static subcommand completions
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
+146 -10
View File
@@ -12,6 +12,7 @@ This module provides:
- hermes config wizard - Re-run setup wizard
"""
import copy
import os
import platform
import re
@@ -26,6 +27,7 @@ from typing import Dict, Any, Optional, List, Tuple
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
# (managed by setup/provider flows directly).
_EXTRA_ENV_KEYS = frozenset({
@@ -44,7 +46,8 @@ _EXTRA_ENV_KEYS = frozenset({
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME",
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
@@ -417,6 +420,7 @@ DEFAULT_CONFIG = {
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
"camofox": {
# When true, Hermes sends a stable profile-scoped userId to Camofox
# so the server maps it to a persistent Firefox profile automatically.
@@ -537,6 +541,13 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
},
"title_generation": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
},
},
"display": {
@@ -726,9 +737,14 @@ DEFAULT_CONFIG = {
# manual — always prompt the user (default)
# smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
# off — skip all approval prompts (equivalent to --yolo)
#
# cron_mode — what to do when a cron job hits a dangerous command:
# deny — block the command and let the agent find another way (default, safe)
# approve — auto-approve all dangerous commands in cron jobs
"approvals": {
"mode": "manual",
"timeout": 60,
"cron_mode": "deny",
},
# Permanently allowed dangerous command patterns (added via "always" approval)
@@ -760,6 +776,20 @@ DEFAULT_CONFIG = {
"wrap_response": True,
},
# execute_code settings — controls the tool used for programmatic tool calls.
"code_execution": {
# Execution mode:
# project (default) — scripts run in the session's working directory
# with the active virtualenv/conda env's python, so project deps
# (pandas, torch, project packages) and relative paths resolve.
# strict — scripts run in an isolated temp directory with
# hermes-agent's own python (sys.executable). Maximum isolation
# and reproducibility; project deps and relative paths won't work.
# Env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, ...) and the
# tool whitelist apply identically in both modes.
"mode": "project",
},
# Logging — controls file logging to ~/.hermes/logs/.
# agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
"logging": {
@@ -777,7 +807,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 18,
"_config_version": 19,
}
# =============================================================================
@@ -861,6 +891,22 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"NVIDIA_API_KEY": {
"description": "NVIDIA NIM API key (build.nvidia.com or local NIM endpoint)",
"prompt": "NVIDIA NIM API key",
"url": "https://build.nvidia.com/",
"password": True,
"category": "provider",
"advanced": True,
},
"NVIDIA_BASE_URL": {
"description": "NVIDIA NIM base URL override (e.g. http://localhost:8000/v1 for local NIM)",
"prompt": "NVIDIA NIM base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"GLM_API_KEY": {
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
"prompt": "Z.AI / GLM API key",
@@ -1518,12 +1564,12 @@ OPTIONAL_ENV_VARS = {
"prompt": "Allow All QQ Users",
"category": "messaging",
},
"QQ_HOME_CHANNEL": {
"QQBOT_HOME_CHANNEL": {
"description": "Default QQ channel/group for cron delivery and notifications",
"prompt": "QQ Home Channel",
"category": "messaging",
},
"QQ_HOME_CHANNEL_NAME": {
"QQBOT_HOME_CHANNEL_NAME": {
"description": "Display name for the QQ home channel",
"prompt": "QQ Home Channel Name",
"category": "messaging",
@@ -2610,6 +2656,85 @@ def _expand_env_vars(obj):
return obj
def _items_by_unique_name(items):
"""Return a name-indexed dict only when all items have unique string names."""
if not isinstance(items, list):
return None
indexed = {}
for item in items:
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
return None
name = item["name"]
if name in indexed:
return None
indexed[name] = item
return indexed
def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
"""Restore raw ``${VAR}`` templates when a value is otherwise unchanged.
``load_config()`` expands env refs for runtime use. When a caller later
persists that config after modifying some unrelated setting, keep the
original on-disk template instead of writing the expanded plaintext
secret back to ``config.yaml``.
Prefer preserving the raw template when ``current`` still matches either
the value previously returned by ``load_config()`` for this config path or
the current environment expansion of ``raw``. This handles env-var
rotation between load and save while still treating mixed literal/template
string edits as caller-owned once their rendered value diverges.
"""
if isinstance(current, str) and isinstance(raw, str) and re.search(r"\${[^}]+}", raw):
if current == raw:
return raw
if isinstance(loaded_expanded, str) and current == loaded_expanded:
return raw
if _expand_env_vars(raw) == current:
return raw
return current
if isinstance(current, dict) and isinstance(raw, dict):
return {
key: _preserve_env_ref_templates(
value,
raw.get(key),
loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None,
)
for key, value in current.items()
}
if isinstance(current, list) and isinstance(raw, list):
# Prefer matching named config objects (e.g. custom_providers) by name
# so harmless reordering doesn't drop the original template. If names
# are duplicated, fall back to positional matching instead of silently
# shadowing one entry.
current_by_name = _items_by_unique_name(current)
raw_by_name = _items_by_unique_name(raw)
loaded_by_name = _items_by_unique_name(loaded_expanded)
if current_by_name is not None and raw_by_name is not None:
return [
_preserve_env_ref_templates(
item,
raw_by_name.get(item.get("name")),
loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None,
)
for item in current
]
return [
_preserve_env_ref_templates(
item,
raw[index] if index < len(raw) else None,
loaded_expanded[index]
if isinstance(loaded_expanded, list) and index < len(loaded_expanded)
else None,
)
for index, item in enumerate(current)
]
return current
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
"""Move stale root-level provider/base_url into model section.
@@ -2677,7 +2802,6 @@ def read_raw_config() -> Dict[str, Any]:
def load_config() -> Dict[str, Any]:
"""Load configuration from ~/.hermes/config.yaml."""
import copy
ensure_hermes_home()
config_path = get_config_path()
@@ -2698,8 +2822,11 @@ def load_config() -> Dict[str, Any]:
config = _deep_merge(config, user_config)
except Exception as e:
print(f"Warning: Failed to load config: {e}")
return _expand_env_vars(_normalize_root_model_keys(_normalize_max_turns_config(config)))
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
expanded = _expand_env_vars(normalized)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(expanded)
return expanded
_SECURITY_COMMENT = """
@@ -2734,7 +2861,7 @@ _FALLBACK_COMMENT = """
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
# fallback_model:
# provider: openrouter
@@ -2778,7 +2905,7 @@ _COMMENTED_SECTIONS = """
# minimax (MINIMAX_API_KEY) — MiniMax
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
#
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
# For custom OpenAI-compatible endpoints, add base_url and key_env.
#
# fallback_model:
# provider: openrouter
@@ -2808,7 +2935,15 @@ def save_config(config: Dict[str, Any]):
ensure_hermes_home()
config_path = get_config_path()
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
normalized = current_normalized
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
if raw_existing:
normalized = _preserve_env_ref_templates(
normalized,
raw_existing,
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
)
# Build optional commented-out sections for features that are off by
# default or only relevant when explicitly configured.
@@ -2826,6 +2961,7 @@ def save_config(config: Dict[str, Any]):
extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
def load_env() -> Dict[str, str]:
+137 -29
View File
@@ -6,7 +6,10 @@ Currently supports:
"""
import io
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -31,6 +34,119 @@ _MAX_LOG_BYTES = 512_000
_AUTO_DELETE_SECONDS = 21600
# ---------------------------------------------------------------------------
# Pending-deletion tracking (replaces the old fork-and-sleep subprocess).
# ---------------------------------------------------------------------------
def _pending_file() -> Path:
"""Path to ``~/.hermes/pastes/pending.json``.
Each entry: ``{"url": "...", "expire_at": <unix_ts>}``. Scheduled
DELETEs used to be handled by spawning a detached Python process per
paste that slept for 6 hours; those accumulated forever if the user
ran ``hermes debug share`` repeatedly. We now persist the schedule
to disk and sweep expired entries on the next debug invocation.
"""
return get_hermes_home() / "pastes" / "pending.json"
def _load_pending() -> list[dict]:
path = _pending_file()
if not path.exists():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
# Filter to well-formed entries only
return [
e for e in data
if isinstance(e, dict) and "url" in e and "expire_at" in e
]
except (OSError, ValueError, json.JSONDecodeError):
pass
return []
def _save_pending(entries: list[dict]) -> None:
path = _pending_file()
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
os.replace(tmp, path)
except OSError:
# Non-fatal — worst case the user has to run ``hermes debug delete``
# manually.
pass
def _record_pending(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS) -> None:
"""Record *urls* for deletion at ``now + delay_seconds``.
Only paste.rs URLs are recorded (dpaste.com auto-expires). Entries
are merged into any existing pending.json.
"""
paste_rs_urls = [u for u in urls if _extract_paste_id(u)]
if not paste_rs_urls:
return
entries = _load_pending()
# Dedupe by URL: keep the later expire_at if same URL appears twice
by_url: dict[str, float] = {e["url"]: float(e["expire_at"]) for e in entries}
expire_at = time.time() + delay_seconds
for u in paste_rs_urls:
by_url[u] = max(expire_at, by_url.get(u, 0.0))
merged = [{"url": u, "expire_at": ts} for u, ts in by_url.items()]
_save_pending(merged)
def _sweep_expired_pastes(now: Optional[float] = None) -> tuple[int, int]:
"""Synchronously DELETE any pending pastes whose ``expire_at`` has passed.
Returns ``(deleted, remaining)``. Best-effort: failed deletes stay in
the pending file and will be retried on the next sweep. Silent
intended to be called from every ``hermes debug`` invocation with
minimal noise.
"""
entries = _load_pending()
if not entries:
return (0, 0)
current = time.time() if now is None else now
deleted = 0
remaining: list[dict] = []
for entry in entries:
try:
expire_at = float(entry.get("expire_at", 0))
except (TypeError, ValueError):
continue # drop malformed entries
if expire_at > current:
remaining.append(entry)
continue
url = entry.get("url", "")
try:
if delete_paste(url):
deleted += 1
continue
except Exception:
# Network hiccup, 404 (already gone), etc. — drop the entry
# after a grace period; don't retry forever.
pass
# Retain failed deletes for up to 24h past expiration, then give up.
if expire_at + 86400 > current:
remaining.append(entry)
else:
deleted += 1 # count as reaped (paste.rs will GC eventually)
if deleted:
_save_pending(remaining)
return (deleted, len(remaining))
# ---------------------------------------------------------------------------
# Privacy / delete helpers
# ---------------------------------------------------------------------------
@@ -90,37 +206,19 @@ def delete_paste(url: str) -> bool:
def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS):
"""Spawn a detached process to delete paste.rs pastes after *delay_seconds*.
"""Record *urls* for deletion ``delay_seconds`` from now.
The child process is fully detached (``start_new_session=True``) so it
survives the parent exiting (important for CLI mode). Only paste.rs
URLs are attempted dpaste.com pastes auto-expire on their own.
Previously this spawned a detached Python subprocess per call that slept
for 6 hours and then issued DELETE requests. Those subprocesses leaked
every ``hermes debug share`` invocation added ~20 MB of resident Python
interpreters that never exited until the sleep completed.
The replacement is stateless: we append to ``~/.hermes/pastes/pending.json``
and rely on opportunistic sweeps (``_sweep_expired_pastes``) called from
every ``hermes debug`` invocation. If the user never runs ``hermes debug``
again, paste.rs's own retention policy handles cleanup.
"""
import subprocess
paste_rs_urls = [u for u in urls if _extract_paste_id(u)]
if not paste_rs_urls:
return
# Build a tiny inline Python script. No imports beyond stdlib.
url_list = ", ".join(f'"{u}"' for u in paste_rs_urls)
script = (
"import time, urllib.request; "
f"time.sleep({delay_seconds}); "
f"[urllib.request.urlopen(urllib.request.Request(u, method='DELETE', "
f"headers={{'User-Agent': 'hermes-agent/auto-delete'}}), timeout=15) "
f"for u in [{url_list}]]"
)
try:
subprocess.Popen(
[sys.executable, "-c", script],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass # Best-effort; manual delete still available.
_record_pending(urls, delay_seconds=delay_seconds)
def _delete_hint(url: str) -> str:
@@ -455,6 +553,16 @@ def run_debug_delete(args):
def run_debug(args):
"""Route debug subcommands."""
# Opportunistic sweep of expired pastes on every ``hermes debug`` call.
# Replaces the old per-paste sleeping subprocess that used to leak as
# one orphaned Python interpreter per scheduled deletion. Silent and
# best-effort — any failure is swallowed so ``hermes debug`` stays
# reliable even when offline.
try:
_sweep_expired_pastes()
except Exception:
pass
subcmd = getattr(args, "debug_command", None)
if subcmd == "share":
run_debug_share(args)
+3 -2
View File
@@ -825,6 +825,7 @@ def run_doctor(args):
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
@@ -894,8 +895,8 @@ def run_doctor(args):
_model_count = len(_br_resp.get("modelSummaries", []))
print(f"\r {color('', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} ")
except ImportError:
print(f"\r {color('', Colors.YELLOW)} {_label} {color('(boto3 not installed — pip install hermes-agent[bedrock])', Colors.DIM)} ")
issues.append("Install boto3 for Bedrock: pip install hermes-agent[bedrock]")
print(f"\r {color('', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} ")
issues.append(f"Install boto3 for Bedrock: {sys.executable} -m pip install boto3")
except Exception as _e:
_err_name = type(_e).__name__
print(f"\r {color('', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} ")
+15 -35
View File
@@ -43,41 +43,20 @@ def _redact(value: str) -> str:
def _gateway_status() -> str:
"""Return a short gateway status string."""
if sys.platform.startswith("linux"):
from hermes_constants import is_container
if is_container():
try:
from hermes_cli.gateway import find_gateway_pids
pids = find_gateway_pids()
if pids:
return f"running (docker, pid {pids[0]})"
return "stopped (docker)"
except Exception:
return "stopped (docker)"
try:
from hermes_cli.gateway import get_service_name
svc = get_service_name()
except Exception:
svc = "hermes-gateway"
try:
r = subprocess.run(
["systemctl", "--user", "is-active", svc],
capture_output=True, text=True, timeout=5,
)
return "running (systemd)" if r.stdout.strip() == "active" else "stopped"
except Exception:
return "unknown"
elif sys.platform == "darwin":
try:
from hermes_cli.gateway import get_launchd_label
r = subprocess.run(
["launchctl", "list", get_launchd_label()],
capture_output=True, text=True, timeout=5,
)
return "loaded (launchd)" if r.returncode == 0 else "not loaded"
except Exception:
return "unknown"
return "N/A"
try:
from hermes_cli.gateway import get_gateway_runtime_snapshot
snapshot = get_gateway_runtime_snapshot()
if snapshot.running:
mode = snapshot.manager
if snapshot.has_process_service_mismatch:
mode = "manual"
return f"running ({mode}, pid {snapshot.gateway_pids[0]})"
if snapshot.service_installed and not snapshot.service_running:
return f"stopped ({snapshot.manager})"
return f"stopped ({snapshot.manager})"
except Exception:
return "unknown" if sys.platform.startswith(("linux", "darwin")) else "N/A"
def _count_skills(hermes_home: Path) -> int:
@@ -296,6 +275,7 @@ def run_dump(args):
("DEEPSEEK_API_KEY", "deepseek"),
("DASHSCOPE_API_KEY", "dashscope"),
("HF_TOKEN", "huggingface"),
("NVIDIA_API_KEY", "nvidia"),
("AI_GATEWAY_API_KEY", "ai_gateway"),
("OPENCODE_ZEN_API_KEY", "opencode_zen"),
("OPENCODE_GO_API_KEY", "opencode_go"),
+634 -32
View File
@@ -10,6 +10,7 @@ import shutil
import signal
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@@ -41,6 +42,23 @@ from hermes_cli.colors import Colors, color
# Process Management (for manual gateway runs)
# =============================================================================
@dataclass(frozen=True)
class GatewayRuntimeSnapshot:
manager: str
service_installed: bool = False
service_running: bool = False
gateway_pids: tuple[int, ...] = ()
service_scope: str | None = None
@property
def running(self) -> bool:
return self.service_running or bool(self.gateway_pids)
@property
def has_process_service_mismatch(self) -> bool:
return self.service_installed and self.running and not self.service_running
def _get_service_pids() -> set:
"""Return PIDs currently managed by systemd or launchd gateway services.
@@ -157,20 +175,22 @@ def _request_gateway_self_restart(pid: int) -> bool:
return True
def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = False) -> list:
"""Find PIDs of running gateway processes.
def _append_unique_pid(pids: list[int], pid: int | None, exclude_pids: set[int]) -> None:
if pid is None or pid <= 0:
return
if pid == os.getpid() or pid in exclude_pids or pid in pids:
return
pids.append(pid)
Args:
exclude_pids: PIDs to exclude from the result (e.g. service-managed
PIDs that should not be killed during a stale-process sweep).
all_profiles: When ``True``, return gateway PIDs across **all**
profiles (the pre-7923 global behaviour). ``hermes update``
needs this because a code update affects every profile.
When ``False`` (default), only PIDs belonging to the current
Hermes profile are returned.
def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> list[int]:
"""Best-effort process-table scan for gateway PIDs.
This supplements the profile-scoped PID file so status views can still spot
a live gateway when the PID file is stale/missing, and ``--all`` sweeps can
discover gateways outside the current profile.
"""
_exclude = exclude_pids or set()
pids = [pid for pid in _get_service_pids() if pid not in _exclude]
pids: list[int] = []
patterns = [
"hermes_cli.main gateway",
"hermes_cli.main --profile",
@@ -203,20 +223,24 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals
if is_windows():
result = subprocess.run(
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
capture_output=True, text=True, timeout=10
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return []
current_cmd = ""
for line in result.stdout.split('\n'):
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("CommandLine="):
current_cmd = line[len("CommandLine="):]
elif line.startswith("ProcessId="):
pid_str = line[len("ProcessId="):]
if any(p in current_cmd for p in patterns) and (all_profiles or _matches_current_profile(current_cmd)):
if any(p in current_cmd for p in patterns) and (
all_profiles or _matches_current_profile(current_cmd)
):
try:
pid = int(pid_str)
if pid != os.getpid() and pid not in pids and pid not in _exclude:
pids.append(pid)
_append_unique_pid(pids, int(pid_str), exclude_pids)
except ValueError:
pass
current_cmd = ""
@@ -227,9 +251,11 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals
text=True,
timeout=10,
)
for line in result.stdout.split('\n'):
if result.returncode != 0:
return []
for line in result.stdout.split("\n"):
stripped = line.strip()
if not stripped or 'grep' in stripped:
if not stripped or "grep" in stripped:
continue
pid = None
@@ -251,16 +277,137 @@ def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = Fals
if pid is None:
continue
if pid == os.getpid() or pid in pids or pid in _exclude:
continue
if any(pattern in command for pattern in patterns) and (all_profiles or _matches_current_profile(command)):
pids.append(pid)
if any(pattern in command for pattern in patterns) and (
all_profiles or _matches_current_profile(command)
):
_append_unique_pid(pids, pid, exclude_pids)
except (OSError, subprocess.TimeoutExpired):
pass
return []
return pids
def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = False) -> list:
"""Find PIDs of running gateway processes.
Args:
exclude_pids: PIDs to exclude from the result (e.g. service-managed
PIDs that should not be killed during a stale-process sweep).
all_profiles: When ``True``, return gateway PIDs across **all**
profiles (the pre-7923 global behaviour). ``hermes update``
needs this because a code update affects every profile.
When ``False`` (default), only PIDs belonging to the current
Hermes profile are returned.
"""
_exclude = set(exclude_pids or set())
pids: list[int] = []
if not all_profiles:
try:
from gateway.status import get_running_pid
_append_unique_pid(pids, get_running_pid(), _exclude)
except Exception:
pass
for pid in _get_service_pids():
_append_unique_pid(pids, pid, _exclude)
for pid in _scan_gateway_pids(_exclude, all_profiles=all_profiles):
_append_unique_pid(pids, pid, _exclude)
return pids
def _probe_systemd_service_running(system: bool = False) -> tuple[bool, bool]:
selected_system = _select_systemd_scope(system)
unit_exists = get_systemd_unit_path(system=selected_system).exists()
if not unit_exists:
return selected_system, False
try:
result = _run_systemctl(
["is-active", get_service_name()],
system=selected_system,
capture_output=True,
text=True,
timeout=10,
)
except (RuntimeError, subprocess.TimeoutExpired):
return selected_system, False
return selected_system, result.stdout.strip() == "active"
def _probe_launchd_service_running() -> bool:
if not get_launchd_plist_path().exists():
return False
try:
result = subprocess.run(
["launchctl", "list", get_launchd_label()],
capture_output=True,
text=True,
timeout=10,
)
except subprocess.TimeoutExpired:
return False
return result.returncode == 0
def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot:
"""Return a unified view of gateway liveness for the current profile."""
gateway_pids = tuple(find_gateway_pids())
if is_termux():
return GatewayRuntimeSnapshot(
manager="Termux / manual process",
gateway_pids=gateway_pids,
)
from hermes_constants import is_container
if is_linux() and is_container():
return GatewayRuntimeSnapshot(
manager="docker (foreground)",
gateway_pids=gateway_pids,
)
if supports_systemd_services():
selected_system, service_running = _probe_systemd_service_running(system=system)
scope_label = _service_scope_label(selected_system)
return GatewayRuntimeSnapshot(
manager=f"systemd ({scope_label})",
service_installed=get_systemd_unit_path(system=selected_system).exists(),
service_running=service_running,
gateway_pids=gateway_pids,
service_scope=scope_label,
)
if is_macos():
return GatewayRuntimeSnapshot(
manager="launchd",
service_installed=get_launchd_plist_path().exists(),
service_running=_probe_launchd_service_running(),
gateway_pids=gateway_pids,
service_scope="launchd",
)
return GatewayRuntimeSnapshot(
manager="manual process",
gateway_pids=gateway_pids,
)
def _format_gateway_pids(pids: tuple[int, ...] | list[int], *, limit: int | None = 3) -> str:
rendered = [str(pid) for pid in pids[:limit] if pid > 0] if limit is not None else [str(pid) for pid in pids if pid > 0]
if limit is not None and len(pids) > limit:
rendered.append("...")
return ", ".join(rendered)
def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:
if not snapshot.has_process_service_mismatch:
return
print()
print("⚠ Gateway process is running for this profile, but the service is not active")
print(f" PID(s): {_format_gateway_pids(snapshot.gateway_pids, limit=None)}")
print(" This is usually a manual foreground/tmux/nohup run, so `hermes gateway`")
print(" can refuse to start another copy until this process stops.")
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
all_profiles: bool = False) -> int:
"""Kill any running gateway processes. Returns count killed.
@@ -340,25 +487,44 @@ def _wsl_systemd_operational() -> bool:
WSL2 with ``systemd=true`` in wsl.conf has working systemd.
WSL2 without it (or WSL1) does not systemctl commands fail.
"""
return _systemd_operational(system=True)
def _systemd_operational(system: bool = False) -> bool:
"""Return True when the requested systemd scope is usable."""
try:
result = subprocess.run(
["systemctl", "is-system-running"],
capture_output=True, text=True, timeout=5,
result = _run_systemctl(
["is-system-running"],
system=system,
capture_output=True,
text=True,
timeout=5,
)
# "running", "degraded", "starting" all mean systemd is PID 1
status = result.stdout.strip().lower()
return status in ("running", "degraded", "starting", "initializing")
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
except (RuntimeError, subprocess.TimeoutExpired, OSError):
return False
def _container_systemd_operational() -> bool:
"""Return True when a container exposes working user or system systemd."""
if _systemd_operational(system=False):
return True
if _systemd_operational(system=True):
return True
return False
def supports_systemd_services() -> bool:
if not is_linux() or is_termux() or is_container():
if not is_linux() or is_termux():
return False
if shutil.which("systemctl") is None:
return False
if is_wsl():
return _wsl_systemd_operational()
if is_container():
return _container_systemd_operational()
return True
@@ -521,6 +687,195 @@ def has_conflicting_systemd_units() -> bool:
return len(get_installed_systemd_scopes()) > 1
# Legacy service names from older Hermes installs that predate the
# hermes-gateway rename. Kept as an explicit allowlist (NOT a glob) so
# profile units (hermes-gateway-*.service) and unrelated third-party
# "hermes" units are never matched.
_LEGACY_SERVICE_NAMES: tuple[str, ...] = ("hermes.service",)
# ExecStart content markers that identify a unit as running our gateway.
# A legacy unit is only flagged when its file contains one of these.
_LEGACY_UNIT_EXECSTART_MARKERS: tuple[str, ...] = (
"hermes_cli.main gateway",
"hermes_cli/main.py gateway",
"gateway/run.py",
" hermes gateway ",
"/hermes gateway ",
)
def _legacy_unit_search_paths() -> list[tuple[bool, Path]]:
"""Return ``[(is_system, base_dir), ...]`` — directories to scan for legacy units.
Factored out so tests can monkeypatch the search roots without touching
real filesystem paths.
"""
return [
(False, Path.home() / ".config" / "systemd" / "user"),
(True, Path("/etc/systemd/system")),
]
def _find_legacy_hermes_units() -> list[tuple[str, Path, bool]]:
"""Return ``[(unit_name, unit_path, is_system)]`` for legacy Hermes gateway units.
Detects unit files installed by older Hermes versions that used a
different service name (e.g. ``hermes.service`` before the rename to
``hermes-gateway.service``). When both a legacy unit and the current
``hermes-gateway.service`` are active, they fight over the same bot
token the PR #5646 signal-recovery change turns this into a 30-second
SIGTERM flap loop.
Safety guards:
* Explicit allowlist of legacy names (no globbing). Profile units such
as ``hermes-gateway-coder.service`` and unrelated third-party
``hermes-*`` services are never matched.
* ExecStart content check only flag units that invoke our gateway
entrypoint. A user-created ``hermes.service`` running an unrelated
binary is left untouched.
* Results are returned purely for caller inspection; this function
never mutates or removes anything.
"""
results: list[tuple[str, Path, bool]] = []
for is_system, base in _legacy_unit_search_paths():
for name in _LEGACY_SERVICE_NAMES:
unit_path = base / name
try:
if not unit_path.exists():
continue
text = unit_path.read_text(encoding="utf-8", errors="ignore")
except (OSError, PermissionError):
continue
if not any(marker in text for marker in _LEGACY_UNIT_EXECSTART_MARKERS):
# Not our gateway — leave alone
continue
results.append((name, unit_path, is_system))
return results
def has_legacy_hermes_units() -> bool:
"""Return True when any legacy Hermes gateway unit files exist."""
return bool(_find_legacy_hermes_units())
def print_legacy_unit_warning() -> None:
"""Warn about legacy Hermes gateway unit files if any are installed.
Idempotent: prints nothing when no legacy units are detected. Safe to
call from any status/install/setup path.
"""
legacy = _find_legacy_hermes_units()
if not legacy:
return
print_warning("Legacy Hermes gateway unit(s) detected from an older install:")
for name, path, is_system in legacy:
scope = "system" if is_system else "user"
print_info(f" {path} ({scope} scope)")
print_info(" These run alongside the current hermes-gateway service and")
print_info(" cause SIGTERM flap loops — both try to use the same bot token.")
print_info(" Remove them with:")
print_info(" hermes gateway migrate-legacy")
def remove_legacy_hermes_units(
interactive: bool = True,
dry_run: bool = False,
) -> tuple[int, list[Path]]:
"""Stop, disable, and remove legacy Hermes gateway unit files.
Iterates over whatever ``_find_legacy_hermes_units()`` returns which is
an explicit allowlist of legacy names (not a glob). Profile units and
unrelated third-party services are never touched.
Args:
interactive: When True, prompt before removing. When False, remove
without asking (used when another prompt has already confirmed,
e.g. from the install flow).
dry_run: When True, list what would be removed and return.
Returns:
``(removed_count, remaining_paths)`` remaining includes units we
couldn't remove (typically system-scope when not running as root).
"""
legacy = _find_legacy_hermes_units()
if not legacy:
print("No legacy Hermes gateway units found.")
return 0, []
user_units = [(n, p) for n, p, is_sys in legacy if not is_sys]
system_units = [(n, p) for n, p, is_sys in legacy if is_sys]
print()
print("Legacy Hermes gateway unit(s) found:")
for name, path, is_system in legacy:
scope = "system" if is_system else "user"
print(f" {path} ({scope} scope)")
print()
if dry_run:
print("(dry-run — nothing removed)")
return 0, [p for _, p, _ in legacy]
if interactive and not prompt_yes_no("Remove these legacy units?", True):
print("Skipped. Run again with: hermes gateway migrate-legacy")
return 0, [p for _, p, _ in legacy]
removed = 0
remaining: list[Path] = []
# User-scope removal
for name, path in user_units:
try:
_run_systemctl(["stop", name], system=False, check=False, timeout=90)
_run_systemctl(["disable", name], system=False, check=False, timeout=30)
path.unlink(missing_ok=True)
print(f" ✓ Removed {path}")
removed += 1
except (OSError, RuntimeError) as e:
print(f" ⚠ Could not remove {path}: {e}")
remaining.append(path)
if user_units:
try:
_run_systemctl(["daemon-reload"], system=False, check=False, timeout=30)
except RuntimeError:
pass
# System-scope removal (needs root)
if system_units:
if os.geteuid() != 0:
print()
print_warning("System-scope legacy units require root to remove.")
print_info(" Re-run with: sudo hermes gateway migrate-legacy")
for _, path in system_units:
remaining.append(path)
else:
for name, path in system_units:
try:
_run_systemctl(["stop", name], system=True, check=False, timeout=90)
_run_systemctl(["disable", name], system=True, check=False, timeout=30)
path.unlink(missing_ok=True)
print(f" ✓ Removed {path}")
removed += 1
except (OSError, RuntimeError) as e:
print(f" ⚠ Could not remove {path}: {e}")
remaining.append(path)
try:
_run_systemctl(["daemon-reload"], system=True, check=False, timeout=30)
except RuntimeError:
pass
print()
if remaining:
print_warning(f"{len(remaining)} legacy unit(s) still present — see messages above.")
else:
print_success(f"Removed {removed} legacy unit(s).")
return removed, remaining
def print_systemd_scope_conflict_warning() -> None:
scopes = get_installed_systemd_scopes()
if len(scopes) < 2:
@@ -1054,6 +1409,19 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
if system:
_require_root_for_system_service("install")
# Offer to remove legacy units (hermes.service from pre-rename installs)
# before installing the new hermes-gateway.service. If both remain, they
# flap-fight for the Telegram bot token on every gateway startup.
# Only removes units matching _LEGACY_SERVICE_NAMES + our ExecStart
# signature — profile units are never touched.
if has_legacy_hermes_units():
print()
print_legacy_unit_warning()
print()
if prompt_yes_no("Remove the legacy unit(s) before installing?", True):
remove_legacy_hermes_units(interactive=False)
print()
unit_path = get_systemd_unit_path(system=system)
scope_flag = " --system" if system else ""
@@ -1092,6 +1460,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
_ensure_linger_enabled()
print_systemd_scope_conflict_warning()
print_legacy_unit_warning()
def systemd_uninstall(system: bool = False):
@@ -1215,6 +1584,10 @@ def systemd_status(deep: bool = False, system: bool = False):
print_systemd_scope_conflict_warning()
print()
if has_legacy_hermes_units():
print_legacy_unit_warning()
print()
if not systemd_unit_is_current(system=system):
print("⚠ Installed gateway service definition is outdated")
print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit")
@@ -1998,7 +2371,7 @@ _PLATFORMS = [
{"name": "QQ_ALLOWED_USERS", "prompt": "Allowed user OpenIDs (comma-separated, leave empty for open access)", "password": False,
"is_allowlist": True,
"help": "Optional — restrict DM access to specific user OpenIDs."},
{"name": "QQ_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False,
{"name": "QQBOT_HOME_CHANNEL", "prompt": "Home channel (user/group OpenID for cron delivery, or empty)", "password": False,
"help": "OpenID to deliver cron results and notifications to."},
],
},
@@ -2625,6 +2998,215 @@ def _setup_feishu():
print_info(f" Bot: {bot_name}")
def _setup_qqbot():
"""Interactive setup for QQ Bot — scan-to-configure or manual credentials."""
print()
print(color(" ─── 🐧 QQ Bot Setup ───", Colors.CYAN))
existing_app_id = get_env_value("QQ_APP_ID")
existing_secret = get_env_value("QQ_CLIENT_SECRET")
if existing_app_id and existing_secret:
print()
print_success("QQ Bot is already configured.")
if not prompt_yes_no(" Reconfigure QQ Bot?", False):
return
# ── Choose setup method ──
print()
method_choices = [
"Scan QR code to add bot automatically (recommended)",
"Enter existing App ID and App Secret manually",
]
method_idx = prompt_choice(" How would you like to set up QQ Bot?", method_choices, 0)
credentials = None
used_qr = False
if method_idx == 0:
# ── QR scan-to-configure ──
try:
credentials = _qqbot_qr_flow()
except KeyboardInterrupt:
print()
print_warning(" QQ Bot setup cancelled.")
return
if credentials:
used_qr = True
if not credentials:
print_info(" QR setup did not complete. Continuing with manual input.")
# ── Manual credential input ──
if not credentials:
print()
print_info(" Go to https://q.qq.com to register a QQ Bot application.")
print_info(" Note your App ID and App Secret from the application page.")
print()
app_id = prompt(" App ID", password=False)
if not app_id:
print_warning(" Skipped — QQ Bot won't work without an App ID.")
return
app_secret = prompt(" App Secret", password=True)
if not app_secret:
print_warning(" Skipped — QQ Bot won't work without an App Secret.")
return
credentials = {"app_id": app_id.strip(), "client_secret": app_secret.strip(), "user_openid": ""}
# ── Save core credentials ──
save_env_value("QQ_APP_ID", credentials["app_id"])
save_env_value("QQ_CLIENT_SECRET", credentials["client_secret"])
user_openid = credentials.get("user_openid", "")
# ── DM security policy ──
print()
access_choices = [
"Use DM pairing approval (recommended)",
"Allow all direct messages",
"Only allow listed user OpenIDs",
]
access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0)
if access_idx == 0:
save_env_value("QQ_ALLOW_ALL_USERS", "false")
if user_openid:
print()
if prompt_yes_no(f" Add yourself ({user_openid}) to the allow list?", True):
save_env_value("QQ_ALLOWED_USERS", user_openid)
print_success(f" Allow list set to {user_openid}")
else:
save_env_value("QQ_ALLOWED_USERS", "")
else:
save_env_value("QQ_ALLOWED_USERS", "")
print_success(" DM pairing enabled.")
print_info(" Unknown users can request access; approve with `hermes pairing approve`.")
elif access_idx == 1:
save_env_value("QQ_ALLOW_ALL_USERS", "true")
save_env_value("QQ_ALLOWED_USERS", "")
print_warning(" Open DM access enabled for QQ Bot.")
else:
default_allow = user_openid or ""
allowlist = prompt(" Allowed user OpenIDs (comma-separated)", default_allow, password=False).replace(" ", "")
save_env_value("QQ_ALLOW_ALL_USERS", "false")
save_env_value("QQ_ALLOWED_USERS", allowlist)
print_success(" Allowlist saved.")
# ── Home channel ──
if user_openid:
print()
if prompt_yes_no(f" Use your QQ user ID ({user_openid}) as the home channel?", True):
save_env_value("QQBOT_HOME_CHANNEL", user_openid)
print_success(f" Home channel set to {user_openid}")
else:
print()
home_channel = prompt(" Home channel OpenID (for cron/notifications, or empty)", password=False)
if home_channel:
save_env_value("QQBOT_HOME_CHANNEL", home_channel.strip())
print_success(f" Home channel set to {home_channel.strip()}")
print()
print_success("🐧 QQ Bot configured!")
print_info(f" App ID: {credentials['app_id']}")
def _qqbot_render_qr(url: str) -> bool:
"""Try to render a QR code in the terminal. Returns True if successful."""
try:
import qrcode as _qr
qr = _qr.QRCode(border=1,error_correction=_qr.constants.ERROR_CORRECT_L)
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
return True
except Exception:
return False
def _qqbot_qr_flow():
"""Run the QR-code scan-to-configure flow.
Returns a dict with app_id, client_secret, user_openid on success,
or None on failure/cancel.
"""
try:
from gateway.platforms.qqbot import (
create_bind_task, poll_bind_result, build_connect_url,
decrypt_secret, BindStatus,
)
from gateway.platforms.qqbot.constants import ONBOARD_POLL_INTERVAL
except Exception as exc:
print_error(f" QQBot onboard import failed: {exc}")
return None
import asyncio
import time
MAX_REFRESHES = 3
refresh_count = 0
while refresh_count <= MAX_REFRESHES:
loop = asyncio.new_event_loop()
# ── Create bind task ──
try:
task_id, aes_key = loop.run_until_complete(create_bind_task())
except Exception as e:
print_warning(f" Failed to create bind task: {e}")
loop.close()
return None
url = build_connect_url(task_id)
# ── Display QR code + URL ──
print()
if _qqbot_render_qr(url):
print(f" Scan the QR code above, or open this URL directly:\n {url}")
else:
print(f" Open this URL in QQ on your phone:\n {url}")
print_info(" Tip: pip install qrcode to show a scannable QR code here")
# ── Poll loop (silent — keep QR visible at bottom) ──
try:
while True:
try:
status, app_id, encrypted_secret, user_openid = loop.run_until_complete(
poll_bind_result(task_id)
)
except Exception:
time.sleep(ONBOARD_POLL_INTERVAL)
continue
if status == BindStatus.COMPLETED:
client_secret = decrypt_secret(encrypted_secret, aes_key)
print()
print_success(f" QR scan complete! (App ID: {app_id})")
if user_openid:
print_info(f" Scanner's OpenID: {user_openid}")
return {
"app_id": app_id,
"client_secret": client_secret,
"user_openid": user_openid,
}
if status == BindStatus.EXPIRED:
refresh_count += 1
if refresh_count > MAX_REFRESHES:
print()
print_warning(f" QR code expired {MAX_REFRESHES} times — giving up.")
return None
print()
print_warning(f" QR code expired, refreshing... ({refresh_count}/{MAX_REFRESHES})")
loop.close()
break # outer while creates a new task
time.sleep(ONBOARD_POLL_INTERVAL)
except KeyboardInterrupt:
loop.close()
raise
finally:
loop.close()
return None
def _setup_signal():
"""Interactive setup for Signal messenger."""
import shutil
@@ -2762,6 +3344,10 @@ def gateway_setup():
print_systemd_scope_conflict_warning()
print()
if supports_systemd_services() and has_legacy_hermes_units():
print_legacy_unit_warning()
print()
if service_installed and service_running:
print_success("Gateway service is installed and running.")
elif service_installed:
@@ -2806,6 +3392,8 @@ def gateway_setup():
_setup_dingtalk()
elif platform["key"] == "feishu":
_setup_feishu()
elif platform["key"] == "qqbot":
_setup_qqbot()
else:
_setup_standard_platform(platform)
@@ -3165,15 +3753,18 @@ def gateway_command(args):
elif subcmd == "status":
deep = getattr(args, 'deep', False)
system = getattr(args, 'system', False)
snapshot = get_gateway_runtime_snapshot(system=system)
# Check for service first
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
systemd_status(deep, system=system)
_print_gateway_process_mismatch(snapshot)
elif is_macos() and get_launchd_plist_path().exists():
launchd_status(deep)
_print_gateway_process_mismatch(snapshot)
else:
# Check for manually running processes
pids = find_gateway_pids()
pids = list(snapshot.gateway_pids)
if pids:
print(f"✓ Gateway is running (PID: {', '.join(map(str, pids))})")
print(" (Running manually, not as a system service)")
@@ -3214,3 +3805,14 @@ def gateway_command(args):
else:
print(" hermes gateway install # Install as user service")
print(" sudo hermes gateway install --system # Install as boot-time system service")
elif subcmd == "migrate-legacy":
# Stop, disable, and remove legacy Hermes gateway unit files from
# pre-rename installs (e.g. hermes.service). Profile units and
# unrelated third-party services are never touched.
dry_run = getattr(args, 'dry_run', False)
yes = getattr(args, 'yes', False)
if not supports_systemd_services() and not is_macos():
print("Legacy unit migration only applies to systemd-based Linux hosts.")
return
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
+2586 -688
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -692,12 +692,12 @@ def switch_model(
api_key=api_key,
base_url=base_url,
)
except Exception:
except Exception as e:
validation = {
"accepted": True,
"persist": True,
"accepted": False,
"persist": False,
"recognized": False,
"message": None,
"message": f"Could not validate `{new_model}`: {e}",
}
if not validation.get("accepted"):
+45 -31
View File
@@ -26,7 +26,8 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
("anthropic/claude-opus-4.7", "recommended"),
("moonshotai/kimi-k2.5", "recommended"),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("qwen/qwen3.6-plus", ""),
@@ -49,7 +50,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("z-ai/glm-5.1", ""),
("z-ai/glm-5v-turbo", ""),
("z-ai/glm-5-turbo", ""),
("moonshotai/kimi-k2.5", ""),
("x-ai/grok-4.20", ""),
("nvidia/nemotron-3-super-120b-a12b", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
@@ -75,6 +75,7 @@ def _codex_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
"moonshotai/kimi-k2.5",
"xiaomi/mimo-v2-pro",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
@@ -96,7 +97,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"z-ai/glm-5.1",
"z-ai/glm-5v-turbo",
"z-ai/glm-5-turbo",
"moonshotai/kimi-k2.5",
"x-ai/grok-4.20-beta",
"nvidia/nemotron-3-super-120b-a12b",
"nvidia/nemotron-3-super-120b-a12b:free",
@@ -133,9 +133,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
# Gemma open models (also served via AI Studio)
"gemma-4-31b-it",
"gemma-4-26b-it",
],
"google-gemini-cli": [
"gemini-2.5-pro",
@@ -155,9 +152,23 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"grok-4.20-reasoning",
"grok-4-1-fast-reasoning",
],
"nvidia": [
# NVIDIA flagship reasoning models
"nvidia/nemotron-3-super-120b-a12b",
"nvidia/nemotron-3-nano-30b-a3b",
"nvidia/llama-3.3-nemotron-super-49b-v1.5",
# Third-party agentic models hosted on build.nvidia.com
# (map to OpenRouter defaults — users get familiar picks on NIM)
"qwen/qwen3.5-397b-a17b",
"deepseek-ai/deepseek-v3.2",
"moonshotai/kimi-k2.5",
"minimaxai/minimax-m2.5",
"z-ai/glm5",
"openai/gpt-oss-120b",
],
"kimi-coding": [
"kimi-for-coding",
"kimi-k2.5",
"kimi-for-coding",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
"kimi-k2-turbo-preview",
@@ -212,6 +223,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"trinity-mini",
],
"opencode-zen": [
"kimi-k2.5",
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.3-codex",
@@ -243,16 +255,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"glm-5",
"glm-4.7",
"glm-4.6",
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2",
"qwen3-coder",
"big-pickle",
],
"opencode-go": [
"kimi-k2.5",
"glm-5.1",
"glm-5",
"kimi-k2.5",
"mimo-v2-pro",
"mimo-v2-omni",
"minimax-m2.7",
@@ -285,21 +296,21 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat)
# or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).
"alibaba": [
"kimi-k2.5",
"qwen3.5-plus",
"qwen3-coder-plus",
"qwen3-coder-next",
# Third-party models available on coding-intl
"glm-5",
"glm-4.7",
"kimi-k2.5",
"MiniMax-M2.5",
],
# Curated HF model list — only agentic models that map to OpenRouter defaults.
"huggingface": [
"moonshotai/Kimi-K2.5",
"Qwen/Qwen3.5-397B-A17B",
"Qwen/Qwen3.5-35B-A3B",
"deepseek-ai/DeepSeek-V3.2",
"moonshotai/Kimi-K2.5",
"MiniMaxAI/MiniMax-M2.5",
"zai-org/GLM-5",
"XiaomiMiMo/MiMo-V2-Flash",
@@ -536,6 +547,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"),
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
@@ -618,6 +630,10 @@ _PROVIDER_ALIASES = {
"grok": "xai",
"x-ai": "xai",
"x.ai": "xai",
"nim": "nvidia",
"nvidia-nim": "nvidia",
"build-nvidia": "nvidia",
"nemotron": "nvidia",
"ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud
"ollama_cloud": "ollama-cloud",
}
@@ -2032,8 +2048,8 @@ def validate_requested_model(
)
return {
"accepted": True,
"persist": True,
"accepted": False,
"persist": False,
"recognized": False,
"message": message,
}
@@ -2046,8 +2062,8 @@ def validate_requested_model(
message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`"
return {
"accepted": True,
"persist": True,
"accepted": False,
"persist": False,
"recognized": False,
"message": message,
}
@@ -2081,12 +2097,11 @@ def validate_requested_model(
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
return {
"accepted": True,
"persist": True,
"accepted": False,
"persist": False,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
f"It may still work if your account has access to it."
f"Model `{requested}` was not found in the OpenAI Codex model listing."
f"{suggestion_text}"
),
}
@@ -2125,16 +2140,15 @@ def validate_requested_model(
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in this provider's model listing. "
f"It may still work if your plan supports it."
f"{suggestion_text}"
),
}
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": (
f"Model `{requested}` was not found in this provider's model listing."
f"{suggestion_text}"
),
}
# api_models is None — couldn't reach API. Accept and persist,
# but warn so typos don't silently break things.
@@ -2176,8 +2190,8 @@ def validate_requested_model(
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
return {
"accepted": True,
"persist": True,
"accepted": False,
"persist": False,
"recognized": False,
"message": (
f"Could not reach the {provider_label} API to validate `{requested}`. "
+3 -12
View File
@@ -300,19 +300,10 @@ def _read_config_model(profile_dir: Path) -> tuple:
def _check_gateway_running(profile_dir: Path) -> bool:
"""Check if a gateway is running for a given profile directory."""
pid_file = profile_dir / "gateway.pid"
if not pid_file.exists():
return False
try:
raw = pid_file.read_text().strip()
if not raw:
return False
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
pid = int(data["pid"])
os.kill(pid, 0) # existence check
return True
except (json.JSONDecodeError, KeyError, ValueError, TypeError,
ProcessLookupError, PermissionError, OSError):
from gateway.status import get_running_pid
return get_running_pid(profile_dir / "gateway.pid", cleanup_stale=False) is not None
except Exception:
return False
+11
View File
@@ -137,6 +137,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
base_url_override="https://api.x.ai/v1",
base_url_env_var="XAI_BASE_URL",
),
"nvidia": HermesOverlay(
transport="openai_chat",
base_url_override="https://integrate.api.nvidia.com/v1",
base_url_env_var="NVIDIA_BASE_URL",
),
"xiaomi": HermesOverlay(
transport="openai_chat",
base_url_env_var="XIAOMI_BASE_URL",
@@ -191,6 +196,12 @@ ALIASES: Dict[str, str] = {
"x.ai": "xai",
"grok": "xai",
# nvidia
"nim": "nvidia",
"nvidia-nim": "nvidia",
"build-nvidia": "nvidia",
"nemotron": "nvidia",
# kimi-for-coding (models.dev ID)
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
+15 -55
View File
@@ -91,7 +91,6 @@ _DEFAULT_PROVIDER_MODELS = {
"gemini": [
"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
"gemma-4-31b-it", "gemma-4-26b-it",
],
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
@@ -1461,7 +1460,9 @@ def setup_agent_settings(config: dict):
)
print_info("Maximum tool-calling iterations per conversation.")
print_info("Higher = more complex tasks, but costs more tokens.")
print_info("Default is 90, which works for most tasks. Use 150+ for open exploration.")
print_info(
f"Press Enter to keep {current_max}. Use 90 for most tasks or 150+ for open exploration."
)
max_iter_str = prompt("Max iterations", current_max)
try:
@@ -2005,52 +2006,6 @@ def _setup_wecom_callback():
_gw_setup()
def _setup_qqbot():
"""Configure QQ Bot gateway."""
print_header("QQ Bot")
existing = get_env_value("QQ_APP_ID")
if existing:
print_info("QQ Bot: already configured")
if not prompt_yes_no("Reconfigure QQ Bot?", False):
return
print_info("Connects Hermes to QQ via the Official QQ Bot API (v2).")
print_info(" Requires a QQ Bot application at q.qq.com")
print_info(" Reference: https://bot.q.qq.com/wiki/develop/api-v2/")
print()
app_id = prompt("QQ Bot App ID")
if not app_id:
print_warning("App ID is required — skipping QQ Bot setup")
return
save_env_value("QQ_APP_ID", app_id.strip())
client_secret = prompt("QQ Bot App Secret", password=True)
if not client_secret:
print_warning("App Secret is required — skipping QQ Bot setup")
return
save_env_value("QQ_CLIENT_SECRET", client_secret)
print_success("QQ Bot credentials saved")
print()
print_info("🔒 Security: Restrict who can DM your bot")
print_info(" Use QQ user OpenIDs (found in event payloads)")
print()
allowed_users = prompt("Allowed user OpenIDs (comma-separated, leave empty for open access)")
if allowed_users:
save_env_value("QQ_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("QQ Bot allowlist configured")
else:
print_info("⚠️ No allowlist set — anyone can DM the bot!")
print()
print_info("📬 Home Channel: OpenID for cron job delivery and notifications.")
home_channel = prompt("Home channel OpenID (leave empty to set later)")
if home_channel:
save_env_value("QQ_HOME_CHANNEL", home_channel)
print()
print_success("QQ Bot configured!")
def _setup_bluebubbles():
@@ -2119,12 +2074,9 @@ def _setup_bluebubbles():
def _setup_qqbot():
"""Configure QQ Bot (Official API v2) via standard platform setup."""
from hermes_cli.gateway import _PLATFORMS
qq_platform = next((p for p in _PLATFORMS if p["key"] == "qqbot"), None)
if qq_platform:
from hermes_cli.gateway import _setup_standard_platform
_setup_standard_platform(qq_platform)
"""Configure QQ Bot (Official API v2) via gateway setup."""
from hermes_cli.gateway import _setup_qqbot as _gateway_setup_qqbot
_gateway_setup_qqbot()
def _setup_webhooks():
@@ -2264,7 +2216,9 @@ def setup_gateway(config: dict):
missing_home.append("Slack")
if get_env_value("BLUEBUBBLES_SERVER_URL") and not get_env_value("BLUEBUBBLES_HOME_CHANNEL"):
missing_home.append("BlueBubbles")
if get_env_value("QQ_APP_ID") and not get_env_value("QQ_HOME_CHANNEL"):
if get_env_value("QQ_APP_ID") and not (
get_env_value("QQBOT_HOME_CHANNEL") or get_env_value("QQ_HOME_CHANNEL")
):
missing_home.append("QQBot")
if missing_home:
@@ -2289,8 +2243,10 @@ def setup_gateway(config: dict):
_is_service_running,
supports_systemd_services,
has_conflicting_systemd_units,
has_legacy_hermes_units,
install_linux_gateway_from_setup,
print_systemd_scope_conflict_warning,
print_legacy_unit_warning,
systemd_start,
systemd_restart,
launchd_install,
@@ -2308,6 +2264,10 @@ def setup_gateway(config: dict):
print_systemd_scope_conflict_warning()
print()
if supports_systemd and has_legacy_hermes_units():
print_legacy_unit_warning()
print()
if service_running:
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
try:
+84
View File
@@ -515,6 +515,90 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
c.print()
def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict:
"""Paginated hub browse for programmatic callers (e.g. TUI gateway).
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
"""
from tools.skills_hub import GitHubAuth, create_source_router
page_size = max(1, min(page_size, 100))
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
"claude-marketplace": 50, "lobehub": 50}
auth = GitHubAuth()
sources = create_source_router(auth)
all_results: list = []
for src in sources:
sid = src.source_id()
if source != "all" and sid != source and sid != "official":
continue
try:
limit = _PER_SOURCE_LIMIT.get(sid, 50)
all_results.extend(src.search("", limit=limit))
except Exception:
continue
if not all_results:
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
seen: dict = {}
for r in all_results:
rank = _TRUST_RANK.get(r.trust_level, 0)
if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0):
seen[r.name] = r
deduped = list(seen.values())
deduped.sort(key=lambda r: (-_TRUST_RANK.get(r.trust_level, 0), r.source != "official", r.name.lower()))
total = len(deduped)
total_pages = max(1, (total + page_size - 1) // page_size)
page = max(1, min(page, total_pages))
start = (page - 1) * page_size
page_items = deduped[start : min(start + page_size, total)]
return {
"items": [{"name": r.name, "description": r.description, "source": r.source,
"trust": r.trust_level} for r in page_items],
"page": page,
"total_pages": total_pages,
"total": total,
}
def inspect_skill(identifier: str) -> Optional[dict]:
"""Skill metadata (+ SKILL.md preview) for programmatic callers."""
from tools.skills_hub import GitHubAuth, create_source_router
class _Q:
def print(self, *a, **k):
pass
c = _Q()
auth = GitHubAuth()
sources = create_source_router(auth)
ident = identifier
if "/" not in ident:
ident = _resolve_short_name(ident, sources, c)
if not ident:
return None
meta, bundle, _ = _resolve_source_meta_and_bundle(ident, sources)
if not meta:
return None
out: dict = {
"name": meta.name,
"description": meta.description,
"source": meta.source,
"identifier": meta.identifier,
"tags": list(meta.tags) if meta.tags else [],
}
if bundle and "SKILL.md" in bundle.files:
content = bundle.files["SKILL.md"]
if isinstance(content, bytes):
content = content.decode("utf-8", errors="replace")
lines = content.split("\n")
preview = "\n".join(lines[:50])
if len(lines) > 50:
preview += f"\n\n... ({len(lines) - 50} more lines)"
out["skill_md_preview"] = preview
return out
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
"""List installed skills, distinguishing hub, builtin, and local skills."""
from tools.skills_hub import HubLockFile, ensure_hub_dirs
+2 -2
View File
@@ -23,7 +23,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
banner_dim: "#B8860B" # Dim/muted text (separators, labels)
banner_text: "#FFF8DC" # Body text (tool names, skill names)
ui_accent: "#FFBF00" # General UI accent
ui_label: "#4dd0e1" # UI labels
ui_label: "#DAA520" # UI labels (warm gold; teal clashed w/ default banner gold)
ui_ok: "#4caf50" # Success indicators
ui_error: "#ef5350" # Error indicators
ui_warn: "#ffa726" # Warning indicators
@@ -163,7 +163,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = {
"banner_dim": "#B8860B",
"banner_text": "#FFF8DC",
"ui_accent": "#FFBF00",
"ui_label": "#4dd0e1",
"ui_label": "#DAA520",
"ui_ok": "#4caf50",
"ui_error": "#ef5350",
"ui_warn": "#ffa726",
+30 -64
View File
@@ -317,7 +317,7 @@ def show_status(args):
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
"QQBot": ("QQ_APP_ID", "QQBOT_HOME_CHANNEL"),
}
for name, (token_var, home_var) in platforms.items():
@@ -327,6 +327,9 @@ def show_status(args):
home_channel = ""
if home_var:
home_channel = os.getenv(home_var, "")
# Back-compat: QQBot home channel was renamed from QQ_HOME_CHANNEL to QQBOT_HOME_CHANNEL
if not home_channel and home_var == "QQBOT_HOME_CHANNEL":
home_channel = os.getenv("QQ_HOME_CHANNEL", "")
status = "configured" if has_token else "not configured"
if home_channel:
@@ -339,73 +342,36 @@ def show_status(args):
# =========================================================================
print()
print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
if _is_termux():
try:
from hermes_cli.gateway import find_gateway_pids
gateway_pids = find_gateway_pids()
except Exception:
gateway_pids = []
is_running = bool(gateway_pids)
try:
from hermes_cli.gateway import get_gateway_runtime_snapshot, _format_gateway_pids
snapshot = get_gateway_runtime_snapshot()
is_running = snapshot.running
print(f" Status: {check_mark(is_running)} {'running' if is_running else 'stopped'}")
print(" Manager: Termux / manual process")
if gateway_pids:
rendered = ", ".join(str(pid) for pid in gateway_pids[:3])
if len(gateway_pids) > 3:
rendered += ", ..."
print(f" PID(s): {rendered}")
else:
print(f" Manager: {snapshot.manager}")
if snapshot.gateway_pids:
print(f" PID(s): {_format_gateway_pids(snapshot.gateway_pids)}")
if snapshot.has_process_service_mismatch:
print(" Service: installed but not managing the current running gateway")
elif _is_termux() and not snapshot.gateway_pids:
print(" Start with: hermes gateway")
print(" Note: Android may stop background jobs when Termux is suspended")
elif sys.platform.startswith('linux'):
from hermes_constants import is_container
if is_container():
# Docker/Podman: no systemd — check for running gateway processes
try:
from hermes_cli.gateway import find_gateway_pids
gateway_pids = find_gateway_pids()
is_active = len(gateway_pids) > 0
except Exception:
is_active = False
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
print(" Manager: docker (foreground)")
elif snapshot.service_installed and not snapshot.service_running:
print(" Service: installed but stopped")
except Exception:
if _is_termux():
print(f" Status: {color('unknown', Colors.DIM)}")
print(" Manager: Termux / manual process")
elif sys.platform.startswith('linux'):
print(f" Status: {color('unknown', Colors.DIM)}")
print(" Manager: systemd/manual")
elif sys.platform == 'darwin':
print(f" Status: {color('unknown', Colors.DIM)}")
print(" Manager: launchd")
else:
try:
from hermes_cli.gateway import get_service_name
_gw_svc = get_service_name()
except Exception:
_gw_svc = "hermes-gateway"
try:
result = subprocess.run(
["systemctl", "--user", "is-active", _gw_svc],
capture_output=True,
text=True,
timeout=5
)
is_active = result.stdout.strip() == "active"
except (FileNotFoundError, subprocess.TimeoutExpired):
is_active = False
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
print(" Manager: systemd (user)")
elif sys.platform == 'darwin':
from hermes_cli.gateway import get_launchd_label
try:
result = subprocess.run(
["launchctl", "list", get_launchd_label()],
capture_output=True,
text=True,
timeout=5
)
is_loaded = result.returncode == 0
except subprocess.TimeoutExpired:
is_loaded = False
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
print(" Manager: launchd")
else:
print(f" Status: {color('N/A', Colors.DIM)}")
print(" Manager: (not supported on this platform)")
print(f" Status: {color('N/A', Colors.DIM)}")
print(" Manager: (not supported on this platform)")
# =========================================================================
# Cron Jobs
+210 -55
View File
@@ -118,59 +118,166 @@ def remove_wrapper_script():
def uninstall_gateway_service():
"""Stop and uninstall the gateway service if running."""
"""Stop and uninstall the gateway service (systemd, launchd) and kill any
standalone gateway processes.
Delegates to the gateway module which handles:
- Linux: user + system systemd services (with proper DBUS env setup)
- macOS: launchd plists
- All platforms: standalone ``hermes gateway run`` processes
- Termux/Android: skips systemd (no systemd on Android), still kills standalone processes
"""
import platform
if platform.system() != "Linux":
return False
stopped_something = False
prefix = os.getenv("PREFIX", "")
if os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix:
return False
# 1. Kill any standalone gateway processes (all platforms, including Termux)
try:
from hermes_cli.gateway import get_service_name
svc_name = get_service_name()
except Exception:
svc_name = "hermes-gateway"
service_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service"
if not service_file.exists():
return False
try:
# Stop the service
subprocess.run(
["systemctl", "--user", "stop", svc_name],
capture_output=True,
check=False
)
# Disable the service
subprocess.run(
["systemctl", "--user", "disable", svc_name],
capture_output=True,
check=False
)
# Remove service file
service_file.unlink()
# Reload systemd
subprocess.run(
["systemctl", "--user", "daemon-reload"],
capture_output=True,
check=False
)
return True
from hermes_cli.gateway import kill_gateway_processes, find_gateway_pids
pids = find_gateway_pids()
if pids:
killed = kill_gateway_processes()
if killed:
log_success(f"Killed {killed} running gateway process(es)")
stopped_something = True
except Exception as e:
log_warn(f"Could not fully remove gateway service: {e}")
log_warn(f"Could not check for gateway processes: {e}")
system = platform.system()
# Termux/Android has no systemd and no launchd — nothing left to do.
prefix = os.getenv("PREFIX", "")
is_termux = bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
if is_termux:
return stopped_something
# 2. Linux: uninstall systemd services (both user and system scopes)
if system == "Linux":
try:
from hermes_cli.gateway import (
get_systemd_unit_path,
get_service_name,
_systemctl_cmd,
)
svc_name = get_service_name()
for is_system in (False, True):
unit_path = get_systemd_unit_path(system=is_system)
if not unit_path.exists():
continue
scope = "system" if is_system else "user"
try:
if is_system and os.geteuid() != 0:
log_warn(f"System gateway service exists at {unit_path} "
f"but needs sudo to remove")
continue
cmd = _systemctl_cmd(is_system)
subprocess.run(cmd + ["stop", svc_name],
capture_output=True, check=False)
subprocess.run(cmd + ["disable", svc_name],
capture_output=True, check=False)
unit_path.unlink()
subprocess.run(cmd + ["daemon-reload"],
capture_output=True, check=False)
log_success(f"Removed {scope} gateway service ({unit_path})")
stopped_something = True
except Exception as e:
log_warn(f"Could not remove {scope} gateway service: {e}")
except Exception as e:
log_warn(f"Could not check systemd gateway services: {e}")
# 3. macOS: uninstall launchd plist
elif system == "Darwin":
try:
from hermes_cli.gateway import get_launchd_plist_path
plist_path = get_launchd_plist_path()
if plist_path.exists():
subprocess.run(["launchctl", "unload", str(plist_path)],
capture_output=True, check=False)
plist_path.unlink()
log_success(f"Removed macOS gateway service ({plist_path})")
stopped_something = True
except Exception as e:
log_warn(f"Could not remove launchd gateway service: {e}")
return stopped_something
def _is_default_hermes_home(hermes_home: Path) -> bool:
"""Return True when ``hermes_home`` points at the default (non-profile) root."""
try:
from hermes_constants import get_default_hermes_root
return hermes_home.resolve() == get_default_hermes_root().resolve()
except Exception:
return False
def _discover_named_profiles():
"""Return a list of ``ProfileInfo`` for every non-default profile, or ``[]``
if profile support is unavailable or nothing is installed beyond the
default root."""
try:
from hermes_cli.profiles import list_profiles
except Exception:
return []
try:
return [p for p in list_profiles() if not getattr(p, "is_default", False)]
except Exception as e:
log_warn(f"Could not enumerate profiles: {e}")
return []
def _uninstall_profile(profile) -> None:
"""Fully uninstall a single named profile: stop its gateway service,
remove its alias wrapper, and wipe its HERMES_HOME directory.
We shell out to ``hermes -p <name> gateway stop|uninstall`` because
service names, unit paths, and plist paths are all derived from the
current HERMES_HOME and can't be easily switched in-process.
"""
import sys as _sys
name = profile.name
profile_home = profile.path
log_info(f"Uninstalling profile '{name}'...")
# 1. Stop and remove this profile's gateway service.
# Use `python -m hermes_cli.main` so we don't depend on a `hermes`
# wrapper that may be half-removed mid-uninstall.
hermes_invocation = [_sys.executable, "-m", "hermes_cli.main", "--profile", name]
for subcmd in ("stop", "uninstall"):
try:
subprocess.run(
hermes_invocation + ["gateway", subcmd],
capture_output=True,
text=True,
timeout=60,
check=False,
)
except subprocess.TimeoutExpired:
log_warn(f" Gateway {subcmd} timed out for '{name}'")
except Exception as e:
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 3. Wipe the profile's HERMES_HOME directory.
try:
if profile_home.exists():
shutil.rmtree(profile_home)
log_success(f" Removed {profile_home}")
except Exception as e:
log_warn(f" Could not remove {profile_home}: {e}")
def run_uninstall(args):
"""
Run the uninstall process.
@@ -181,7 +288,13 @@ def run_uninstall(args):
"""
project_root = get_project_root()
hermes_home = get_hermes_home()
# Detect named profiles when uninstalling from the default root —
# offer to clean them up too instead of leaving zombie HERMES_HOMEs
# and systemd units behind.
is_default_profile = _is_default_hermes_home(hermes_home)
named_profiles = _discover_named_profiles() if is_default_profile else []
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD))
@@ -195,6 +308,13 @@ def run_uninstall(args):
print(f" Secrets: {hermes_home / '.env'}")
print(f" Data: {hermes_home / 'cron/'}, {hermes_home / 'sessions/'}, {hermes_home / 'logs/'}")
print()
if named_profiles:
print(color("Other profiles detected:", Colors.CYAN, Colors.BOLD))
for p in named_profiles:
running = " (gateway running)" if getattr(p, "gateway_running", False) else ""
print(f"{p.name}{running}: {p.path}")
print()
# Ask for confirmation
print(color("Uninstall Options:", Colors.YELLOW, Colors.BOLD))
@@ -221,12 +341,40 @@ def run_uninstall(args):
return
full_uninstall = (choice == "2")
# When doing a full uninstall from the default profile, also offer to
# remove any named profiles — stopping their gateway services, unlinking
# their alias wrappers, and wiping their HERMES_HOME dirs. Otherwise
# those leave zombie services and data behind.
remove_profiles = False
if full_uninstall and named_profiles:
print()
print(color("Other profiles will NOT be removed by default.", Colors.YELLOW))
print(f"Found {len(named_profiles)} named profile(s): " +
", ".join(p.name for p in named_profiles))
print()
try:
resp = input(color(
f"Also stop and remove these {len(named_profiles)} profile(s)? [y/N]: ",
Colors.BOLD
)).strip().lower()
except (KeyboardInterrupt, EOFError):
print()
print("Cancelled.")
return
remove_profiles = resp in ("y", "yes")
# Final confirmation
print()
if full_uninstall:
print(color("⚠️ WARNING: This will permanently delete ALL Hermes data!", Colors.RED, Colors.BOLD))
print(color(" Including: configs, API keys, sessions, scheduled jobs, logs", Colors.RED))
if remove_profiles:
print(color(
f" Plus {len(named_profiles)} profile(s): " +
", ".join(p.name for p in named_profiles),
Colors.RED
))
else:
print("This will remove the Hermes code but keep your configuration and data.")
@@ -247,12 +395,10 @@ def run_uninstall(args):
print(color("Uninstalling...", Colors.CYAN, Colors.BOLD))
print()
# 1. Stop and uninstall gateway service
log_info("Checking for gateway service...")
if uninstall_gateway_service():
log_success("Gateway service stopped and removed")
else:
log_info("No gateway service found")
# 1. Stop and uninstall gateway service + kill standalone processes
log_info("Checking for running gateway...")
if not uninstall_gateway_service():
log_info("No gateway service or processes found")
# 2. Remove PATH entries from shell configs
log_info("Removing PATH entries from shell configs...")
@@ -291,8 +437,17 @@ def run_uninstall(args):
log_warn(f"Could not fully remove {project_root}: {e}")
log_info("You may need to manually remove it")
# 5. Optionally remove ~/.hermes/ data directory
# 5. Optionally remove ~/.hermes/ data directory (and named profiles)
if full_uninstall:
# 5a. Stop and remove each named profile's gateway service and
# alias wrapper. The profile HERMES_HOME dirs live under
# ``<default>/profiles/<name>/`` and will be swept away by the
# rmtree below, but services + alias scripts live OUTSIDE the
# default root and have to be cleaned up explicitly.
if remove_profiles and named_profiles:
for prof in named_profiles:
_uninstall_profile(prof)
log_info("Removing configuration and data...")
try:
if hermes_home.exists():
+4 -34
View File
@@ -56,10 +56,10 @@ try:
except ImportError:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
"Run 'hermes web' to auto-install, or: pip install hermes-agent[web]"
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
)
WEB_DIST = Path(__file__).parent / "web_dist"
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
_log = logging.getLogger(__name__)
app = FastAPI(title="Hermes Agent", version=__version__)
@@ -1444,38 +1444,8 @@ def _nous_poller(session_id: str) -> None:
auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0,
force_refresh=False, force_mint=True,
)
# Save into credential pool same as auth_commands.py does
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
)
pool = load_pool("nous")
entry = PooledCredential.from_dict("nous", {
**full_state,
"label": "dashboard device_code",
"auth_type": AUTH_TYPE_OAUTH,
"source": f"{SOURCE_MANUAL}:dashboard_device_code",
"base_url": full_state.get("inference_base_url"),
})
pool.add_entry(entry)
# Also persist to auth store so get_nous_auth_status() sees it
# (matches what _login_nous in auth.py does for the CLI flow).
try:
from hermes_cli.auth import (
_load_auth_store, _save_provider_state, _save_auth_store,
_auth_store_lock,
)
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, "nous", full_state)
_save_auth_store(auth_store)
except Exception as store_exc:
_log.warning(
"oauth/device: credential pool saved but auth store write failed "
"(session=%s): %s", session_id, store_exc,
)
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(full_state)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: nous login completed (session=%s)", session_id)
+2 -1
View File
@@ -14,7 +14,8 @@ def get_hermes_home() -> Path:
Reads HERMES_HOME env var, falls back to ~/.hermes.
This is the single source of truth all other copies should import this.
"""
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else Path.home() / ".hermes"
def get_default_hermes_root() -> Path:
+57 -2
View File
@@ -987,6 +987,22 @@ class SessionDB:
return sanitized.strip()
@staticmethod
def _contains_cjk(text: str) -> bool:
"""Check if text contains CJK (Chinese, Japanese, Korean) characters."""
for ch in text:
cp = ord(ch)
if (0x4E00 <= cp <= 0x9FFF or # CJK Unified Ideographs
0x3400 <= cp <= 0x4DBF or # CJK Extension A
0x20000 <= cp <= 0x2A6DF or # CJK Extension B
0x3000 <= cp <= 0x303F or # CJK Symbols
0x3040 <= cp <= 0x309F or # Hiragana
0x30A0 <= cp <= 0x30FF or # Katakana
0xAC00 <= cp <= 0xD7AF): # Hangul Syllables
return True
return False
def search_messages(
self,
query: str,
@@ -1062,8 +1078,47 @@ class SessionDB:
cursor = self._conn.execute(sql, params)
except sqlite3.OperationalError:
# FTS5 query syntax error despite sanitization — return empty
return []
matches = [dict(row) for row in cursor.fetchall()]
# unless query contains CJK (fall back to LIKE below)
if not self._contains_cjk(query):
return []
matches = []
else:
matches = [dict(row) for row in cursor.fetchall()]
# LIKE fallback for CJK queries: FTS5 default tokenizer splits CJK
# characters individually, causing multi-character queries to fail.
if not matches and self._contains_cjk(query):
raw_query = query.strip('"').strip()
like_where = ["m.content LIKE ?"]
like_params: list = [f"%{raw_query}%"]
if source_filter is not None:
like_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
like_params.extend(source_filter)
if exclude_sources is not None:
like_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
like_params.extend(exclude_sources)
if role_filter:
like_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
like_params.extend(role_filter)
like_sql = f"""
SELECT m.id, m.session_id, m.role,
substr(m.content,
max(1, instr(m.content, ?) - 40),
120) AS snippet,
m.content, m.timestamp, m.tool_name,
s.source, s.model, s.started_at AS session_started
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(like_where)}
ORDER BY m.timestamp DESC
LIMIT ? OFFSET ?
"""
like_params.extend([limit, offset])
# instr() parameter goes first in the bound list
like_params = [raw_query] + like_params
with self._lock:
like_cursor = self._conn.execute(like_sql, like_params)
matches = [dict(row) for row in like_cursor.fetchall()]
# Add surrounding context (1 message before + after each match).
# Done outside the lock so we don't hold it across N sequential queries.
+2 -2
View File
@@ -433,7 +433,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
if not _MCP_SERVER_AVAILABLE:
raise ImportError(
"MCP server requires the 'mcp' package. "
"Install with: pip install 'hermes-agent[mcp]'"
f"Install with: {sys.executable} -m pip install 'mcp'"
)
mcp = FastMCP(
@@ -838,7 +838,7 @@ def run_mcp_server(verbose: bool = False) -> None:
if not _MCP_SERVER_AVAILABLE:
print(
"Error: MCP server requires the 'mcp' package.\n"
"Install with: pip install 'hermes-agent[mcp]'",
f"Install with: {sys.executable} -m pip install 'mcp'",
file=sys.stderr,
)
sys.exit(1)
+20 -6
View File
@@ -43,6 +43,15 @@ from dotenv import load_dotenv
load_dotenv()
def _effective_temperature_for_model(model: str) -> Optional[float]:
"""Return a fixed temperature for models with strict sampling contracts."""
try:
from agent.auxiliary_client import _fixed_temperature_for_model
except Exception:
return None
return _fixed_temperature_for_model(model)
# ============================================================================
@@ -442,12 +451,17 @@ Complete the user's task step by step."""
# Make API call
try:
response = self.client.chat.completions.create(
model=self.model,
messages=api_messages,
tools=self.tools,
timeout=300.0
)
api_kwargs = {
"model": self.model,
"messages": api_messages,
"tools": self.tools,
"timeout": 300.0,
}
fixed_temperature = _effective_temperature_for_model(self.model)
if fixed_temperature is not None:
api_kwargs["temperature"] = fixed_temperature
response = self.client.chat.completions.create(**api_kwargs)
except Exception as e:
self.logger.error(f"API call failed: {e}")
break
+2 -2
View File
@@ -274,9 +274,9 @@ def get_tool_definitions(
# execute_code" even when the API key isn't configured or the toolset is
# disabled (#560-discord).
if "execute_code" in available_tool_names:
from tools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema
from tools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema, _get_execution_mode
sandbox_enabled = SANDBOX_ALLOWED_TOOLS & available_tool_names
dynamic_schema = build_execute_code_schema(sandbox_enabled)
dynamic_schema = build_execute_code_schema(sandbox_enabled, mode=_get_execution_mode())
for i, td in enumerate(filtered_tools):
if td.get("function", {}).get("name") == "execute_code":
filtered_tools[i] = {"type": "function", "function": dynamic_schema}
+69 -1
View File
@@ -37,7 +37,30 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
in {
packages.configKeys = configKeys;
checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
checks = {
# Cross-platform evaluation — catches "not supported for interpreter"
# errors (e.g. sphinx dropping python311) without needing a darwin builder.
# Evaluation is pure and instant; it doesn't build anything.
cross-eval = let
targetSystems = builtins.filter
(s: inputs.self.packages ? ${s})
[ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
tryEvalPkg = sys:
let pkg = inputs.self.packages.${sys}.default;
in builtins.tryEval (builtins.seq pkg.drvPath true);
results = map (sys: { inherit sys; result = tryEvalPkg sys; }) targetSystems;
failures = builtins.filter (r: !r.result.success) results;
failMsg = lib.concatMapStringsSep "\n" (r: " - ${r.sys}") failures;
in pkgs.runCommand "hermes-cross-eval" { } (
if failures != [] then
builtins.throw "Package fails to evaluate on:\n${failMsg}"
else ''
echo "PASS: package evaluates on all ${toString (builtins.length targetSystems)} platforms"
mkdir -p $out
echo "ok" > $out/result
''
);
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# Verify binaries exist and are executable
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
set -e
@@ -103,6 +126,51 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
'';
# Verify bundled TUI is present and compiled
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
set -e
echo "=== Checking bundled TUI ==="
test -d ${hermes-agent}/ui-tui || (echo "FAIL: ui-tui directory missing"; exit 1)
echo "PASS: ui-tui directory exists"
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
echo "PASS: compiled entry.js present"
test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1)
echo "PASS: node_modules present"
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)
echo "PASS: HERMES_TUI_DIR set in wrapper"
echo "=== All bundled TUI checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify HERMES_NODE is set in wrapper and points to Node 20+
# (string-width uses the /v regex flag which requires Node 20+)
hermes-node = pkgs.runCommand "hermes-node-version" { } ''
set -e
echo "=== Checking HERMES_NODE in wrapper ==="
grep -q "HERMES_NODE" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_NODE not set in wrapper"; exit 1)
echo "PASS: HERMES_NODE present in wrapper"
HERMES_NODE=$(sed -n "s/^export HERMES_NODE='\(.*\)'/\1/p" ${hermes-agent}/bin/hermes)
test -x "$HERMES_NODE" || (echo "FAIL: HERMES_NODE=$HERMES_NODE not executable"; exit 1)
echo "PASS: HERMES_NODE executable at $HERMES_NODE"
NODE_MAJOR=$("$HERMES_NODE" --version | sed 's/^v//' | cut -d. -f1)
test "$NODE_MAJOR" -ge 20 || \
(echo "FAIL: Node v$NODE_MAJOR < 20, TUI needs /v regex flag support"; exit 1)
echo "PASS: Node v$NODE_MAJOR >= 20"
echo "=== All HERMES_NODE checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify HERMES_MANAGED guard works on all mutation commands
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
set -e
+15 -38
View File
@@ -1,49 +1,26 @@
# nix/devShell.nix — Fast dev shell with stamp-file optimization
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each package in inputsFrom exposes passthru.devShellHook — a bash snippet
# with stamp-checked setup logic. This file collects and runs them all.
{ inputs, ... }: {
perSystem = { pkgs, ... }:
perSystem = { pkgs, system, ... }:
let
python = pkgs.python311;
hermes-agent = inputs.self.packages.${system}.default;
hermes-tui = inputs.self.packages.${system}.tui;
packages = [ hermes-agent hermes-tui ];
in {
devShells.default = pkgs.mkShell {
inputsFrom = packages;
packages = with pkgs; [
python uv nodejs_20 ripgrep git openssh ffmpeg
python312 uv nodejs_22 ripgrep git openssh ffmpeg
];
shellHook = ''
shellHook = let
hooks = map (p: p.passthru.devShellHook or "") packages;
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
in ''
echo "Hermes Agent dev shell"
# Composite stamp: changes when nix python or uv change
STAMP_VALUE="${python}:${pkgs.uv}"
STAMP_FILE=".venv/.nix-stamp"
# Create venv if missing
if [ ! -d .venv ]; then
echo "Creating Python 3.11 venv..."
uv venv .venv --python ${python}/bin/python3
fi
source .venv/bin/activate
# Only install if stamp is stale or missing
if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then
echo "Installing Python dependencies..."
uv pip install -e ".[all]"
if [ -d mini-swe-agent ]; then
uv pip install -e ./mini-swe-agent 2>/dev/null || true
fi
if [ -d tinker-atropos ]; then
uv pip install -e ./tinker-atropos 2>/dev/null || true
fi
# Install npm deps
if [ -f package.json ] && [ ! -d node_modules ]; then
echo "Installing npm dependencies..."
npm install
fi
echo "$STAMP_VALUE" > "$STAMP_FILE"
fi
${combined}
echo "Ready. Run 'hermes' to start."
'';
};
+14 -7
View File
@@ -121,11 +121,19 @@
# ── Provision apt packages (first boot only, cached in writable layer) ──
# sudo: agent self-modification
# nodejs/npm: writable node so npm i -g works (nix store copies are read-only)
# curl: needed for uv installer
# Node 22 via NodeSource — Ubuntu 24.04 ships Node 18 which is EOL.
# curl: needed for uv installer + NodeSource setup
if [ ! -f /var/lib/hermes-tools-provisioned ] && command -v apt-get >/dev/null 2>&1; then
echo "First boot: provisioning agent tools..."
apt-get update -qq
apt-get install -y -qq sudo nodejs npm curl
apt-get install -y -qq sudo curl ca-certificates gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
apt-get update -qq
apt-get install -y -qq nodejs
touch /var/lib/hermes-tools-provisioned
fi
@@ -140,15 +148,14 @@
su -s /bin/sh "$TARGET_USER" -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true
fi
# Python 3.11 venv — gives the agent a writable Python with pip.
# Uses uv to install Python 3.11 (Ubuntu 24.04 ships 3.12).
# Python 3.12 venv — gives the agent a writable Python with pip.
# --seed includes pip/setuptools so bare `pip install` works.
_UV_BIN="$TARGET_HOME/.local/bin/uv"
if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then
su -s /bin/sh "$TARGET_USER" -c "
export PATH=\"\$HOME/.local/bin:\$PATH\"
uv python install 3.11
uv venv --python 3.11 --seed \"\$HOME/.venv\"
uv python install 3.12
uv venv --python 3.12 --seed \"\$HOME/.venv\"
" || true
fi
@@ -171,7 +178,7 @@
# Package and entrypoint use stable symlinks (current-package, current-entrypoint)
# so they can update without recreation. Env vars go through $HERMES_HOME/.env.
containerIdentity = builtins.hashString "sha256" (builtins.toJSON {
schema = 3; # bump when identity inputs change
schema = 4; # bump when identity inputs change (4: Node 18→22 via NodeSource)
image = cfg.container.image;
extraVolumes = cfg.container.extraVolumes;
extraOptions = cfg.container.extraOptions;
+91 -29
View File
@@ -1,54 +1,116 @@
# nix/packages.nix — Hermes Agent package built with uv2nix
{ inputs, ... }: {
perSystem = { pkgs, system, ... }:
{ inputs, ... }:
{
perSystem =
{ pkgs, inputs', ... }:
let
hermesVenv = pkgs.callPackage ./python.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
};
hermesTui = pkgs.callPackage ./tui.nix {
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
};
# Import bundled skills, excluding runtime caches
bundledSkills = pkgs.lib.cleanSourceWith {
src = ../skills;
filter = path: _type:
!(pkgs.lib.hasInfix "/index-cache/" path);
filter = path: _type: !(pkgs.lib.hasInfix "/index-cache/" path);
};
hermesWeb = pkgs.callPackage ./web.nix {
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
};
runtimeDeps = with pkgs; [
nodejs_20 ripgrep git openssh ffmpeg tirith
nodejs_22
ripgrep
git
openssh
ffmpeg
tirith
];
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
in {
packages.default = pkgs.stdenv.mkDerivation {
pname = "hermes-agent";
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ pkgs.makeWrapper ];
# Lockfile hashes for dev shell stamps
pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml);
uvLockHash =
if builtins.pathExists ../uv.lock then
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
else
"none";
in
{
packages = {
default = pkgs.stdenv.mkDerivation {
pname = "hermes-agent";
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
installPhase = ''
runHook preInstall
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ pkgs.makeWrapper ];
mkdir -p $out/share/hermes-agent $out/bin
cp -r ${bundledSkills} $out/share/hermes-agent/skills
installPhase = ''
runHook preInstall
${pkgs.lib.concatMapStringsSep "\n" (name: ''
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--suffix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills
'') [ "hermes" "hermes-agent" "hermes-acp" ]}
mkdir -p $out/share/hermes-agent $out/bin
cp -r ${bundledSkills} $out/share/hermes-agent/skills
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
runHook postInstall
'';
# copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/)
mkdir -p $out/ui-tui
cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/
meta = with pkgs.lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
mainProgram = "hermes";
license = licenses.mit;
platforms = platforms.unix;
${pkgs.lib.concatMapStringsSep "\n"
(name: ''
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--suffix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${pkgs.nodejs_22}/bin/node
'')
[
"hermes"
"hermes-agent"
"hermes-acp"
]
}
runHook postInstall
'';
passthru.devShellHook = ''
STAMP=".nix-stamps/hermes-agent"
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "hermes-agent: installing Python dependencies..."
uv venv .venv --python ${pkgs.python312}/bin/python3 2>/dev/null || true
source .venv/bin/activate
uv pip install -e ".[all]"
[ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true
[ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true
mkdir -p .nix-stamps
echo "$STAMP_VALUE" > "$STAMP"
else
source .venv/bin/activate
export HERMES_PYTHON=${hermesVenv}/bin/python3
fi
'';
meta = with pkgs.lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
mainProgram = "hermes";
license = licenses.mit;
platforms = platforms.unix;
};
};
tui = hermesTui;
web = hermesWeb;
};
};
}
+26 -9
View File
@@ -1,6 +1,6 @@
# nix/python.nix — uv2nix virtual environment builder
{
python311,
python312,
lib,
callPackage,
uv2nix,
@@ -35,30 +35,46 @@ let
};
};
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
# and no pyproject.toml, so setuptools isn't declared as a build dep.
buildSystemOverrides = final: prev: builtins.mapAttrs
(name: _: prev.${name}.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
}))
(lib.genAttrs [
"alibabacloud-credentials-api"
"alibabacloud-endpoint-util"
"alibabacloud-gateway-dingtalk"
"alibabacloud-gateway-spi"
"alibabacloud-tea"
] (_: null));
pythonPackageOverrides = final: _prev:
if isAarch64Darwin then {
numpy = mkPrebuiltOverride final python311.pkgs.numpy { };
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
av = mkPrebuiltOverride final python311.pkgs.av { };
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
humanfriendly = mkPrebuiltOverride final python311.pkgs.humanfriendly { };
av = mkPrebuiltOverride final python312.pkgs.av { };
coloredlogs = mkPrebuiltOverride final python311.pkgs.coloredlogs {
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
humanfriendly = [ ];
};
onnxruntime = mkPrebuiltOverride final python311.pkgs.onnxruntime {
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
coloredlogs = [ ];
numpy = [ ];
packaging = [ ];
};
ctranslate2 = mkPrebuiltOverride final python311.pkgs.ctranslate2 {
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
numpy = [ ];
pyyaml = [ ];
};
faster-whisper = mkPrebuiltOverride final python311.pkgs.faster-whisper {
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
av = [ ];
ctranslate2 = [ ];
huggingface-hub = [ ];
@@ -70,11 +86,12 @@ let
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python311;
python = python312;
}).overrideScope
(lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
buildSystemOverrides
pythonPackageOverrides
]);
in
+77
View File
@@ -0,0 +1,77 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, npm-lockfile-fix, ... }:
let
src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-mG3vpgGi4ljt4X3XIf3I/5mIcm+rVTUAmx2DQ6YVA90=";
};
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
version = packageJson.version;
npmLockHash = builtins.hashString "sha256" (builtins.readFile ../ui-tui/package-lock.json);
in
pkgs.buildNpmPackage {
pname = "hermes-tui";
inherit src npmDeps version;
doCheck = false;
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
cp -r dist $out/lib/hermes-tui/dist
# runtime node_modules
cp -r node_modules $out/lib/hermes-tui/node_modules
# @hermes/ink is a file: dependency, we need to copy it in fr
rm -f $out/lib/hermes-tui/node_modules/@hermes/ink
cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink
# package.json needed for "type": "module" resolution
cp package.json $out/lib/hermes-tui/
runHook postInstall
'';
nativeBuildInputs = [
(pkgs.writeShellScriptBin "update_tui_lockfile" ''
set -euox pipefail
# get root of repo
REPO_ROOT=$(git rev-parse --show-toplevel)
# cd into ui-tui and reinstall
cd "$REPO_ROOT/ui-tui"
rm -rf node_modules/
npm cache clean --force
CI=true npm install # ci env var to suppress annoying unicode install banner lag
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
NIX_FILE="$REPO_ROOT/nix/tui.nix"
# compute the new hash
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
NIX_OUTPUT=$(nix build .#tui 2>&1 || true)
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
echo got new hash $NEW_HASH
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
nix build .#tui
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
'')
];
passthru.devShellHook = ''
STAMP=".nix-stamps/hermes-tui"
STAMP_VALUE="${npmLockHash}"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "hermes-tui: installing npm dependencies..."
cd ui-tui && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd ..
mkdir -p .nix-stamps
echo "$STAMP_VALUE" > "$STAMP"
fi
'';
}
+63
View File
@@ -0,0 +1,63 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ pkgs, npm-lockfile-fix, ... }:
let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-Y0pOzdFG8BLjfvCLmsvqYpjxFjAQabXp1i7X9W/cCU4=";
};
npmLockHash = builtins.hashString "sha256" (builtins.readFile ../web/package-lock.json);
in
pkgs.buildNpmPackage {
pname = "hermes-web";
version = "0.0.0";
inherit src npmDeps;
doCheck = false;
buildPhase = ''
npx tsc -b
npx vite build --outDir dist
'';
installPhase = ''
runHook preInstall
cp -r dist $out
runHook postInstall
'';
nativeBuildInputs = [
(pkgs.writeShellScriptBin "update_web_lockfile" ''
set -euox pipefail
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT/web"
rm -rf node_modules/
npm cache clean --force
CI=true npm install
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
NIX_FILE="$REPO_ROOT/nix/web.nix"
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
NIX_OUTPUT=$(nix build .#web 2>&1 || true)
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
echo got new hash $NEW_HASH
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
nix build .#web
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
'')
];
passthru.devShellHook = ''
STAMP=".nix-stamps/hermes-web"
STAMP_VALUE="${npmLockHash}"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "hermes-web: installing npm dependencies..."
cd web && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd ..
mkdir -p .nix-stamps
echo "$STAMP_VALUE" > "$STAMP"
fi
'';
}
@@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen.
| Key | Default | Description |
|-----|---------|-------------|
| `contextCadence` | `1` | Min turns between context API calls |
| `dialecticCadence` | `3` | Min turns between dialectic API calls |
| `dialecticCadence` | `2` | Min turns between dialectic API calls. Recommended 15 |
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection |
Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn.
Higher cadence values fire the dialectic LLM less often. `dialecticCadence: 2` means the engine fires every other turn. Setting it to `1` fires every turn.
### Depth (how many)
@@ -180,6 +180,8 @@ If `dialecticDepthLevels` is omitted, rounds use **proportional levels** derived
This keeps earlier passes cheap while using full depth on the final synthesis.
**Depth at session start.** The session-start prewarm runs the full configured `dialecticDepth` in the background before turn 1. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. Turn 1 consumes the prewarm result directly; if prewarm hasn't landed in time, turn 1 falls back to a synchronous call with a bounded timeout.
### Level (how hard)
Controls the **intensity** of each dialectic reasoning round.
@@ -368,7 +370,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso
| `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. |
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` |
| `contextCadence` | `1` | Min turns between context API calls |
| `dialecticCadence` | `3` | Min turns between dialectic LLM calls |
| `dialecticCadence` | `2` | Min turns between dialectic LLM calls (recommended 15) |
The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions.
@@ -0,0 +1,339 @@
---
name: touchdesigner-mcp
description: "Control a running TouchDesigner instance via twozero MCP — create operators, set parameters, wire connections, execute Python, build real-time visuals. 36 native tools."
version: 1.0.0
author: kshitijk4poor
license: MIT
metadata:
hermes:
tags: [TouchDesigner, MCP, twozero, creative-coding, real-time-visuals, generative-art, audio-reactive, VJ, installation, GLSL]
related_skills: [native-mcp, ascii-video, manim-video, hermes-video]
---
# TouchDesigner Integration (twozero MCP)
## CRITICAL RULES
1. **NEVER guess parameter names.** Call `td_get_par_info` for the op type FIRST. Your training data is wrong for TD 2025.32.
2. **If `tdAttributeError` fires, STOP.** Call `td_get_operator_info` on the failing node before continuing.
3. **NEVER hardcode absolute paths** in script callbacks. Use `me.parent()` / `scriptOp.parent()`.
4. **Prefer native MCP tools over td_execute_python.** Use `td_create_operator`, `td_set_operator_pars`, `td_get_errors` etc. Only fall back to `td_execute_python` for complex multi-step logic.
5. **Call `td_get_hints` before building.** It returns patterns specific to the op type you're working with.
## Architecture
```
Hermes Agent -> MCP (Streamable HTTP) -> twozero.tox (port 40404) -> TD Python
```
36 native tools. Free plugin (no payment/license — confirmed April 2026).
Context-aware (knows selected OP, current network).
Hub health check: `GET http://localhost:40404/mcp` returns JSON with instance PID, project name, TD version.
## Setup (Automated)
Run the setup script to handle everything:
```bash
bash "${HERMES_HOME:-$HOME/.hermes}/skills/creative/touchdesigner-mcp/scripts/setup.sh"
```
The script will:
1. Check if TD is running
2. Download twozero.tox if not already cached
3. Add `twozero_td` MCP server to Hermes config (if missing)
4. Test the MCP connection on port 40404
5. Report what manual steps remain (drag .tox into TD, enable MCP toggle)
### Manual steps (one-time, cannot be automated)
1. **Drag `~/Downloads/twozero.tox` into the TD network editor** → click Install
2. **Enable MCP:** click twozero icon → Settings → mcp → "auto start MCP" → Yes
3. **Restart Hermes session** to pick up the new MCP server
After setup, verify:
```bash
nc -z 127.0.0.1 40404 && echo "twozero MCP: READY"
```
## Environment Notes
- **Non-Commercial TD** caps resolution at 1280×1280. Use `outputresolution = 'custom'` and set width/height explicitly.
- **Codecs:** `prores` (preferred on macOS) or `mjpa` as fallback. H.264/H.265/AV1 require a Commercial license.
- Always call `td_get_par_info` before setting params — names vary by TD version (see CRITICAL RULES #1).
## Workflow
### Step 0: Discover (before building anything)
```
Call td_get_par_info with op_type for each type you plan to use.
Call td_get_hints with the topic you're building (e.g. "glsl", "audio reactive", "feedback").
Call td_get_focus to see where the user is and what's selected.
Call td_get_network to see what already exists.
```
No temp nodes, no cleanup. This replaces the old discovery dance entirely.
### Step 1: Clean + Build
**IMPORTANT: Split cleanup and creation into SEPARATE MCP calls.** Destroying and recreating same-named nodes in one `td_execute_python` script causes "Invalid OP object" errors. See pitfalls #11b.
Use `td_create_operator` for each node (handles viewport positioning automatically):
```
td_create_operator(type="noiseTOP", parent="/project1", name="bg", parameters={"resolutionw": 1280, "resolutionh": 720})
td_create_operator(type="levelTOP", parent="/project1", name="brightness")
td_create_operator(type="nullTOP", parent="/project1", name="out")
```
For bulk creation or wiring, use `td_execute_python`:
```python
# td_execute_python script:
root = op('/project1')
nodes = []
for name, optype in [('bg', noiseTOP), ('fx', levelTOP), ('out', nullTOP)]:
n = root.create(optype, name)
nodes.append(n.path)
# Wire chain
for i in range(len(nodes)-1):
op(nodes[i]).outputConnectors[0].connect(op(nodes[i+1]).inputConnectors[0])
result = {'created': nodes}
```
### Step 2: Set Parameters
Prefer the native tool (validates params, won't crash):
```
td_set_operator_pars(path="/project1/bg", parameters={"roughness": 0.6, "monochrome": true})
```
For expressions or modes, use `td_execute_python`:
```python
op('/project1/time_driver').par.colorr.expr = "absTime.seconds % 1000.0"
```
### Step 3: Wire
Use `td_execute_python` — no native wire tool exists:
```python
op('/project1/bg').outputConnectors[0].connect(op('/project1/fx').inputConnectors[0])
```
### Step 4: Verify
```
td_get_errors(path="/project1", recursive=true)
td_get_perf()
td_get_operator_info(path="/project1/out", detail="full")
```
### Step 5: Display / Capture
```
td_get_screenshot(path="/project1/out")
```
Or open a window via script:
```python
win = op('/project1').create(windowCOMP, 'display')
win.par.winop = op('/project1/out').path
win.par.winw = 1280; win.par.winh = 720
win.par.winopen.pulse()
```
## MCP Tool Quick Reference
**Core (use these most):**
| Tool | What |
|------|------|
| `td_execute_python` | Run arbitrary Python in TD. Full API access. |
| `td_create_operator` | Create node with params + auto-positioning |
| `td_set_operator_pars` | Set params safely (validates, won't crash) |
| `td_get_operator_info` | Inspect one node: connections, params, errors |
| `td_get_operators_info` | Inspect multiple nodes in one call |
| `td_get_network` | See network structure at a path |
| `td_get_errors` | Find errors/warnings recursively |
| `td_get_par_info` | Get param names for an OP type (replaces discovery) |
| `td_get_hints` | Get patterns/tips before building |
| `td_get_focus` | What network is open, what's selected |
**Read/Write:**
| Tool | What |
|------|------|
| `td_read_dat` | Read DAT text content |
| `td_write_dat` | Write/patch DAT content |
| `td_read_chop` | Read CHOP channel values |
| `td_read_textport` | Read TD console output |
**Visual:**
| Tool | What |
|------|------|
| `td_get_screenshot` | Capture one OP viewer to file |
| `td_get_screenshots` | Capture multiple OPs at once |
| `td_get_screen_screenshot` | Capture actual screen via TD |
| `td_navigate_to` | Jump network editor to an OP |
**Search:**
| Tool | What |
|------|------|
| `td_find_op` | Find ops by name/type across project |
| `td_search` | Search code, expressions, string params |
**System:**
| Tool | What |
|------|------|
| `td_get_perf` | Performance profiling (FPS, slow ops) |
| `td_list_instances` | List all running TD instances |
| `td_get_docs` | In-depth docs on a TD topic |
| `td_agents_md` | Read/write per-COMP markdown docs |
| `td_reinit_extension` | Reload extension after code edit |
| `td_clear_textport` | Clear console before debug session |
**Input Automation:**
| Tool | What |
|------|------|
| `td_input_execute` | Send mouse/keyboard to TD |
| `td_input_status` | Poll input queue status |
| `td_input_clear` | Stop input automation |
| `td_op_screen_rect` | Get screen coords of a node |
| `td_click_screen_point` | Click a point in a screenshot |
See `references/mcp-tools.md` for full parameter schemas.
## Key Implementation Rules
**GLSL time:** No `uTDCurrentTime` in GLSL TOP. Use the Values page:
```python
# Call td_get_par_info(op_type="glslTOP") first to confirm param names
td_set_operator_pars(path="/project1/shader", parameters={"value0name": "uTime"})
# Then set expression via script:
# op('/project1/shader').par.value0.expr = "absTime.seconds"
# In GLSL: uniform float uTime;
```
Fallback: Constant TOP in `rgba32float` format (8-bit clamps to 0-1, freezing the shader).
**Feedback TOP:** Use `top` parameter reference, not direct input wire. "Not enough sources" resolves after first cook. "Cook dependency loop" warning is expected.
**Resolution:** Non-Commercial caps at 1280×1280. Use `outputresolution = 'custom'`.
**Large shaders:** Write GLSL to `/tmp/file.glsl`, then use `td_write_dat` or `td_execute_python` to load.
**Vertex/Point access (TD 2025.32):** `point.P[0]`, `point.P[1]`, `point.P[2]` — NOT `.x`, `.y`, `.z`.
**Extensions:** `ext0object` format is `"op('./datName').module.ClassName(me)"` in CONSTANT mode. After editing extension code with `td_write_dat`, call `td_reinit_extension`.
**Script callbacks:** ALWAYS use relative paths via `me.parent()` / `scriptOp.parent()`.
**Cleaning nodes:** Always `list(root.children)` before iterating + `child.valid` check.
## Recording / Exporting Video
```python
# via td_execute_python:
root = op('/project1')
rec = root.create(moviefileoutTOP, 'recorder')
op('/project1/out').outputConnectors[0].connect(rec.inputConnectors[0])
rec.par.type = 'movie'
rec.par.file = '/tmp/output.mov'
rec.par.videocodec = 'prores' # Apple ProRes — NOT license-restricted on macOS
rec.par.record = True # start
# rec.par.record = False # stop (call separately later)
```
H.264/H.265/AV1 need Commercial license. Use `prores` on macOS or `mjpa` as fallback.
Extract frames: `ffmpeg -i /tmp/output.mov -vframes 120 /tmp/frames/frame_%06d.png`
**TOP.save() is useless for animation** — captures same GPU texture every time. Always use MovieFileOut.
### Before Recording: Checklist
1. **Verify FPS > 0** via `td_get_perf`. If FPS=0 the recording will be empty. See pitfalls #38-39.
2. **Verify shader output is not black** via `td_get_screenshot`. Black output = shader error or missing input. See pitfalls #8, #40.
3. **If recording with audio:** cue audio to start first, then delay recording by 3 frames. See pitfalls #19.
4. **Set output path before starting record** — setting both in the same script can race.
## Audio-Reactive GLSL (Proven Recipe)
### Correct signal chain (tested April 2026)
```
AudioFileIn CHOP (playmode=sequential)
→ AudioSpectrum CHOP (FFT=512, outputmenu=setmanually, outlength=256, timeslice=ON)
→ Math CHOP (gain=10)
→ CHOP to TOP (dataformat=r, layout=rowscropped)
→ GLSL TOP input 1 (spectrum texture, 256x2)
Constant TOP (rgba32float, time) → GLSL TOP input 0
GLSL TOP → Null TOP → MovieFileOut
```
### Critical audio-reactive rules (empirically verified)
1. **TimeSlice must stay ON** for AudioSpectrum. OFF = processes entire audio file → 24000+ samples → CHOP to TOP overflow.
2. **Set Output Length manually** to 256 via `outputmenu='setmanually'` and `outlength=256`. Default outputs 22050 samples.
3. **DO NOT use Lag CHOP for spectrum smoothing.** Lag CHOP operates in timeslice mode and expands 256 samples to 2400+, averaging all values to near-zero (~1e-06). The shader receives no usable data. This was the #1 audio sync failure in testing.
4. **DO NOT use Filter CHOP either** — same timeslice expansion problem with spectrum data.
5. **Smoothing belongs in the GLSL shader** if needed, via temporal lerp with a feedback texture: `mix(prevValue, newValue, 0.3)`. This gives frame-perfect sync with zero pipeline latency.
6. **CHOP to TOP dataformat = 'r'**, layout = 'rowscropped'. Spectrum output is 256x2 (stereo). Sample at y=0.25 for first channel.
7. **Math gain = 10** (not 5). Raw spectrum values are ~0.19 in bass range. Gain of 10 gives usable ~5.0 for the shader.
8. **No Resample CHOP needed.** Control output size via AudioSpectrum's `outlength` param directly.
### GLSL spectrum sampling
```glsl
// Input 0 = time (1x1 rgba32float), Input 1 = spectrum (256x2)
float iTime = texture(sTD2DInputs[0], vec2(0.5)).r;
// Sample multiple points per band and average for stability:
// NOTE: y=0.25 for first channel (stereo texture is 256x2, first row center is 0.25)
float bass = (texture(sTD2DInputs[1], vec2(0.02, 0.25)).r +
texture(sTD2DInputs[1], vec2(0.05, 0.25)).r) / 2.0;
float mid = (texture(sTD2DInputs[1], vec2(0.2, 0.25)).r +
texture(sTD2DInputs[1], vec2(0.35, 0.25)).r) / 2.0;
float hi = (texture(sTD2DInputs[1], vec2(0.6, 0.25)).r +
texture(sTD2DInputs[1], vec2(0.8, 0.25)).r) / 2.0;
```
See `references/network-patterns.md` for complete build scripts + shader code.
## Operator Quick Reference
| Family | Color | Python class / MCP type | Suffix |
|--------|-------|-------------|--------|
| TOP | Purple | noiseTOP, glslTOP, compositeTOP, levelTop, blurTOP, textTOP, nullTOP | TOP |
| CHOP | Green | audiofileinCHOP, audiospectrumCHOP, mathCHOP, lfoCHOP, constantCHOP | CHOP |
| SOP | Blue | gridSOP, sphereSOP, transformSOP, noiseSOP | SOP |
| DAT | White | textDAT, tableDAT, scriptDAT, webserverDAT | DAT |
| MAT | Yellow | phongMAT, pbrMAT, glslMAT, constMAT | MAT |
| COMP | Gray | geometryCOMP, containerCOMP, cameraCOMP, lightCOMP, windowCOMP | COMP |
## Security Notes
- MCP runs on localhost only (port 40404). No authentication — any local process can send commands.
- `td_execute_python` has unrestricted access to the TD Python environment and filesystem as the TD process user.
- `setup.sh` downloads twozero.tox from the official 404zero.com URL. Verify the download if concerned.
- The skill never sends data outside localhost. All MCP communication is local.
## References
| File | What |
|------|------|
| `references/pitfalls.md` | Hard-won lessons from real sessions |
| `references/operators.md` | All operator families with params and use cases |
| `references/network-patterns.md` | Recipes: audio-reactive, generative, GLSL, instancing |
| `references/mcp-tools.md` | Full twozero MCP tool parameter schemas |
| `references/python-api.md` | TD Python: op(), scripting, extensions |
| `references/troubleshooting.md` | Connection diagnostics, debugging |
| `scripts/setup.sh` | Automated setup script |
---
> You're not writing code. You're conducting light.
@@ -0,0 +1,382 @@
# twozero MCP Tools Reference
36 tools from twozero MCP v2.774+ (April 2026).
All tools accept an optional `target_instance` param for multi-TD-instance scenarios.
## Execution & Scripting
### td_execute_python
Execute Python code inside TouchDesigner and return the result. Has full access to TD Python API (op, project, app, etc). Print statements and the last expression value are captured. Best for: wiring connections (inputConnectors), setting expressions (par.X.expr/mode), querying parameter names, and batch creation scripts (5+ operators). For creating 1-4 operators, prefer td_create_operator instead.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `code` | string | yes | Python code to execute in TouchDesigner |
## Network & Structure
### td_get_network
Get the operator network structure in TouchDesigner (TD) at a given path. Returns compact list: name OPType flags. First line is full path of queried op. Flags: ch:N=children count, !cook=allowCooking off, bypass, private=isPrivate, blocked:reason, "comment text". depth=0 (default) = current level only. depth=1 = one level of children (indented). To explore deeper, call again on a specific COMP path. System operators (/ui, /sys) are hidden by default.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | no | Network path to inspect, e.g. '/' or '/project1' |
| `depth` | integer | no | How many levels deep to recurse. 0=current level only (recommended), 1=include direct children of COMPs |
| `includeSystem` | boolean | no | Include system operators (/ui, /sys). Default false. |
| `nodeXY` | boolean | no | Include nodeX,nodeY coordinates. Default false. |
### td_create_operator
Create a new operator (node) in TouchDesigner (TD). Preferred way to create operators — handles viewport positioning, viewer flag, and docked ops automatically. For batch creation (5+ ops), you may use td_execute_python with a script instead, but then call td_get_hints('construction') first for correct parameter names and layout rules. Supports all TD operator types: TOP, CHOP, SOP, DAT, COMP, MAT. If parent is omitted, creates in the currently open network at the user's viewport position. When building a container: first create baseCOMP (no parent), then create children with parent=compPath.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | yes | Operator type, e.g. 'textDAT', 'constantCHOP', 'noiseTOP', 'transformTOP', 'baseCOMP' |
| `parent` | string | no | Path to the parent operator. If omitted, uses the currently open network in TD. |
| `name` | string | no | Name for the new operator (optional, TD auto-names if omitted) |
| `parameters` | object | no | Key-value pairs of parameters to set on the created operator |
### td_find_op
Find operators by name and/or type across the project. Returns TSV: path, OPType, flags. Flags: bypass, !cook, private, blocked:reason. Use td_search to search inside code/expressions; use td_find_op to find operators themselves.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | no | Substring to match in operator name (case-insensitive). E.g. 'noise' finds noise1, noise2, myNoise. |
| `type` | string | no | Substring to match in OPType (case-insensitive). E.g. 'noiseTOP', 'baseCOMP', 'CHOP'. Use exact type for precision or partial for broader matches. |
| `root` | string | no | Root operator path to search from. Default '/project1'. |
| `max_results` | number | no | Maximum results to return. Default 50. |
| `max_depth` | number | no | Max recursion depth from root. Default unlimited. |
| `detail` | `basic` / `summary` | no | Result detail level. 'basic' = name/path/type (fast). 'summary' = + connections, non-default pars, expressions. Default 'basic'. |
### td_search
Search for text across all code (DAT scripts), parameter expressions, and string parameter values in the TD project. Returns TSV: path, kind (code/expression/parameter/ref), line, text. JSON when context>0. Words are OR-matched. Use quotes for exact phrases: 'GetLogin "op('login')"'. Use count_only=true to quickly check if something is referenced without fetching full results.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `query` | string | yes | Search query. Multiple words = OR (any match). Wrap in quotes for exact phrase. Example: 'GetLogin getLogin' finds either. |
| `root` | string | no | Root operator path to search from. Default '/project1'. |
| `scope` | `all` / `code` / `editable` / `expressions` / `parameters` | no | What to search. 'code' = DAT scripts only (fast, ~0.05s). 'editable' = only editable code (skips inherited/ref DATs). 'expressions' = parameter expressions only. 'parameters' = string parameter values only. 'all' = everything (slow, ~1.5s due to parameter scan). Default 'all'. |
| `case_sensitive` | boolean | no | Case-sensitive matching. Default false. |
| `max_results` | number | no | Maximum results to return. Default 50. |
| `context` | number | no | Lines to show before/after each code match. Saves td_read_dat calls. Default 0. |
| `count_only` | boolean | no | Return only match count, not results. Fast existence check. |
| `max_depth` | number | no | Max recursion depth from root. Default unlimited. |
### td_navigate_to
Navigate the TouchDesigner Network Editor viewport to show a specific operator. Opens the operator's parent network and centers the view on it. Use this to show the user where a problem is, or to navigate to an operator before modifying it.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the operator to navigate to, e.g. '/project1/noise1' |
## Operator Inspection
### td_get_operator_info
Get information about a specific operator (node) in TouchDesigner (TD). detail='summary': connections, non-default pars, expressions, CHOP channels (compact). detail='full': all of the above PLUS every parameter with value/default/label.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Full path to the operator, e.g. '/project1/noise1' |
| `detail` | `summary` / `full` | no | Level of detail. 'summary' = connections, expressions, non-default pars, custom pars (pulse marked), CHOP channels. 'full' = summary + all parameters. Default 'full'. |
### td_get_operators_info
Get information about multiple operators in one call. Returns an array of operator info objects. Use instead of calling td_get_operator_info multiple times.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `paths` | array | yes | Array of full operator paths, e.g. ['/project1/null1', '/project1/null2'] |
| `detail` | `summary` / `full` | no | Level of detail. Default 'summary'. |
### td_get_par_info
Get parameter names and details for a TouchDesigner operator type. Without specific pars: returns compact list of all parameters with their names, types, and menu options. With pars: returns full details (help text, menu values, style) for specific parameters. Use this when you need to know exact parameter names before setting them.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `op_type` | string | yes | TD operator type name, e.g. 'noiseTOP', 'blurTOP', 'lfoCHOP', 'compositeTOP' |
| `pars` | array | no | Optional list of specific parameter names to get full details for |
## Parameter Setting
### td_set_operator_pars
Set parameters and flags on an operator in TouchDesigner (TD). Safer than td_execute_python for simple parameter changes. Can set values, toggle bypass/viewer, without writing Python code.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the operator |
| `parameters` | object | no | Key-value pairs of parameters to set |
| `bypass` | boolean | no | Set bypass state of the operator (not available on COMPs) |
| `viewer` | boolean | no | Set viewer state of the operator |
| `allowCooking` | boolean | no | Set cooking flag on a COMP. When False, internal network stops cooking (0 CPU). COMP-only. |
## Data Read/Write
### td_read_dat
Read the text content of a DAT operator in TouchDesigner (TD). Returns content with line numbers. Use to read scripts, extensions, GLSL shaders, table data.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the DAT operator |
| `start_line` | integer | no | Start line (1-based). Omit to read from beginning. |
| `end_line` | integer | no | End line (inclusive). Omit to read to end. |
### td_write_dat
Write or patch text content of a DAT operator in TouchDesigner (TD). Can do full replacement or StrReplace-style patching (old_text -> new_text). Use for editing scripts, extensions, shaders. Does NOT reinit extensions automatically.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the DAT operator |
| `text` | string | no | Full replacement text. Use this OR old_text+new_text, not both. |
| `old_text` | string | no | Text to find and replace (must be unique in the DAT) |
| `new_text` | string | no | Replacement text |
| `replace_all` | boolean | no | If true, replaces ALL occurrences of old_text (default: false, requires unique match) |
### td_read_chop
Read CHOP channel sample data. Returns channel values as arrays. Use when you need the actual sample values (animation curves, lookup tables, waveforms), not just the summary from td_get_operator_info.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the CHOP operator |
| `channels` | array | no | Channel names to read. Omit to read all channels. |
| `start` | integer | no | Start sample index (0-based). Omit to read from beginning. |
| `end` | integer | no | End sample index (inclusive). Omit to read to end. |
### td_read_textport
Read the last N lines from the TouchDesigner (TD) log/textport (console output). Use this to see errors, warnings and print output from TD.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `lines` | integer | no | Number of recent lines to return |
### td_clear_textport
Clear the MCP textport log buffer. Use this before starting a debug session or an edit-run-check loop to keep td_read_textport output focused and minimal.
No parameters (other than optional `target_instance`).
## Visual Capture
### td_get_screenshot
Get a screenshot of an operator's viewer in TouchDesigner (TD). Saves the image to a file and returns the file path. Use your file-reading tool to view the image. Shows what the operator looks like in its viewer (TOP output, CHOP waveform graph, SOP geometry, DAT table, parameter UI, etc). Use this to visually inspect any operator, or to generate images via TD for use in your project. TWO-STEP ASYNC USAGE: Step 1 — call with 'path' to start: returns {'status': 'pending', 'requestId': '...'}. Step 2 — call with 'request_id' to retrieve: returns {'file': '/tmp/.../opname_id.jpg'}. Then read the file to see the image. If step 2 still returns pending, make one other tool call then retry.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | no | Full operator path to screenshot, e.g. '/project1/noise1'. Required for step 1. |
| `request_id` | string | no | Request ID from step 1 to retrieve the completed screenshot. |
| `max_size` | integer | no | Max pixel size for the longer side (default 512). Use 0 for original operator resolution (useful for pixel-accurate UI work). Higher values (e.g. 1024) for more detail. |
| `output_path` | string | no | Optional absolute path where the image should be saved (e.g. '/Users/me/project/render.png'). If omitted, saved to /tmp/pisang_mcp/screenshots/. Use absolute paths — TD's working directory may differ from the agent's. |
| `as_top` | boolean | no | If true, captures the operator directly as a TOP (bypasses the viewer renderer), preserving alpha/transparency. Only works for TOP operators — if the target is not a TOP, falls back to the viewer automatically. Use this when you need a clean PNG with alpha, e.g. to save a generated image for use in another project. |
| `format` | `auto` / `jpg` / `png` | no | Image format. 'auto' (default): JPEG for viewer mode, PNG for as_top=true. 'jpg': always JPEG (smaller). 'png': always PNG (lossless). |
### td_get_screenshots
Get screenshots of multiple operators in one batch. Saves images to files and returns file paths. Use your file-reading tool to view images. TWO-STEP ASYNC USAGE: Step 1 — call with 'paths' array to start: returns {'status': 'pending', 'batchId': '...', 'total': N}. Step 2 — call with 'batch_id' to retrieve: returns {'files': [{op, file}, ...]}. Then read the files to see the images. If still processing returns {'status': 'pending', 'ready': K, 'total': N}.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `paths` | array | no | List of full operator paths to screenshot. Required for step 1. |
| `batch_id` | string | no | Batch ID from step 1 to retrieve completed screenshots. |
| `max_size` | integer | no | Max pixel size for longer side (default 512). Use 0 for original resolution. |
| `as_top` | boolean | no | If true, captures TOP operators directly (preserves alpha). Non-TOP operators fall back to viewer. |
| `output_dir` | string | no | Optional absolute path to a directory. Each screenshot saved as <opname>.jpg or .png inside it and kept on disk. |
| `format` | `auto` / `jpg` / `png` | no | Image format. 'auto' (default): JPEG for viewer mode, PNG for as_top=true. 'jpg': always JPEG (smaller). 'png': always PNG (lossless). |
### td_get_screen_screenshot
Capture a screenshot of the actual screen via TD's screenGrabTOP. Saves the image to a file and returns the file path. Use your file-reading tool to view the image. Unlike td_get_screenshot (operator viewer), this shows what the user literally sees on their monitor — TD windows, UI panels, everything. Use when simulating mouse/keyboard input to verify what happened on screen. Workflow: td_get_screen_screenshot → read file → td_input_execute → wait idle → td_get_screen_screenshot again. TWO-STEP ASYNC: Step 1 — call without request_id: returns {'status':'pending','requestId':'...'}. Step 2 — call with request_id: returns {'file': '/tmp/.../screen_id.jpg', 'info': '...metadata...'}. Then read the file to see the image. The requestId also stays usable with td_screen_point_to_global for later coordinate lookup. crop_x/y/w/h are in ACTUAL SCREEN PIXELS (not image pixels). Crops exceeding screen bounds are auto-clamped. SMART DEFAULTS: max_size is auto when omitted — 1920 for full screen (good overview), max(crop_w,crop_h) for cropped (guarantees 1:1 scale). At 1:1 scale: screen_coord = crop_origin + image_pixel. Otherwise use the formula from metadata.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `request_id` | string | no | Request ID from step 1 to retrieve the completed screenshot. |
| `max_size` | integer | no | Max pixel size for the longer side. Auto when omitted: 1920 for full screen, max(crop_w,crop_h) for cropped (1:1). Set explicitly to override. |
| `crop_x` | integer | no | Left edge in screen pixels. |
| `crop_y` | integer | no | Top edge in screen pixels (y=0 at top of screen). |
| `crop_w` | integer | no | Width in pixels. |
| `crop_h` | integer | no | Height in pixels. |
| `display` | integer | no | Screen index (default 0 = primary display). |
## Context & Focus
### td_get_focus
Get the current user focus in TouchDesigner (TD): which network is open, selected operators, current operator, and rollover (what is under the mouse cursor). IMPORTANT: when the user says 'this operator' or 'вот этот', they mean the SELECTED/CURRENT operator, NOT the rollover. Rollover is just incidental mouse position and should be ignored for intent. Pass screenshots=true to immediately start a screenshot batch for all selected operators — response includes a 'screenshots' field with batchId; retrieve with td_get_screenshots(batch_id=...).
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `screenshots` | boolean | no | If true, start a screenshot batch for all selected operators. Retrieve with td_get_screenshots(batch_id=...). |
| `max_size` | integer | no | Max screenshot size when screenshots=true (default 512). |
| `as_top` | boolean | no | Passed to the screenshot batch when screenshots=true. |
### td_get_errors
Find errors and warnings in TouchDesigner (TD) operators. Checks operator errors, warnings, AND broken parameter expressions (missing channels, bad references, etc). Also includes recent script errors from the log (tracebacks), grouped and deduplicated — e.g. 1000 identical mouse-move errors shown as ×1000 with one entry. If path is given, checks that operator and its children. If no path, checks the currently open network. Use '/' for entire project. Use when user says something is broken, has errors, red nodes, горит ошибка, etc. TIP: call td_clear_textport before reproducing an error to keep log focused. TIP: combine with td_get_perf when user says 'тупит/лагает' to check both errors and performance.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | no | Path to check. If omitted, checks the current network. Use '/' to scan entire project. |
| `recursive` | boolean | no | Check children recursively (default true) |
| `include_log` | boolean | no | Include recent script errors from log, grouped by unique signature (default true). Use td_clear_textport before reproducing an error to keep results focused. |
### td_get_perf
Get performance data from TouchDesigner (TD). Returns TSV: header with fps/budget/memory summary, then slowest operators sorted by cook time. Columns: path, OPType, cpu/cook(ms), gpu/cook(ms), cpu/s, gpu/s, rate, flags. Use when user reports lag, low FPS, slow performance, тупит, тормозит.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | no | Path to profile. If omitted, profiles the current network. Use '/' for entire project. |
| `top` | integer | no | Number of slowest operators to return |
## Documentation
### td_get_docs
Get comprehensive documentation on a TouchDesigner topic. Unlike td_get_hints (compact tips), this returns in-depth reference material. Call without arguments to see available topics with descriptions. Call with a topic name to get the full documentation.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `topic` | string | no | Topic to get docs for. Omit to list available topics. |
### td_get_hints
Get TouchDesigner tips and common patterns for a topic. Call this BEFORE creating operators or writing TD Python code to learn correct parameter names, expressions, and idiomatic approaches. Available topics: animation, noise, connections, parameters, scripting, construction, ui_analysis, panel_layout, screenshots, input_simulation, undo. IMPORTANT: always call with topic='construction' before building multi-operator setups to get correct TOP/CHOP parameter names, compositeTOP input ordering, and layout guidelines. IMPORTANT: always call with topic='input_simulation' before using td_input_execute to learn focus recovery, coordinate systems, and testing workflow.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `topic` | string | yes | Topic to get hints for. Available: 'animation', 'noise', 'connections', 'parameters', 'scripting', 'construction', 'ui_analysis', 'panel_layout', 'screenshots', 'input_simulation', 'undo', 'networking', 'all' |
### td_agents_md
Read, write, or update the agents_md documentation inside a COMP container. agents_md is a Markdown textDAT describing the container's purpose, structure, and conventions. action='read': returns content + staleness check (compares documented children vs live state). action='update': refreshes auto-generated sections (children list, connections) from live state, preserves human-written sections. action='write': sets full content, creates the DAT if missing.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the COMP container |
| `action` | `read` / `update` / `write` | yes | read=get content+staleness, update=refresh auto sections, write=set content |
| `content` | string | no | Markdown content (only for action='write') |
## Input Automation
### td_input_execute
Send a sequence of mouse/keyboard commands to TouchDesigner. Commands execute sequentially with smooth bezier movement. Returns immediately — poll td_input_status() until status='idle' before proceeding. Command types: 'focus' — bring TD to foreground. 'move' — smooth mouse move: {type,x,y,duration,easing}. 'click' — click: {type,x,y,button,hold,duration,easing}. hold=seconds to hold down. duration=smooth move before click. 'dblclick' — double click: {type,x,y,duration}. 'mousedown'/'mouseup' — {type,x,y,button}. 'key' — keystroke: {type,keys} e.g. 'ctrl+z','tab','escape','shift+f5'. Requires Accessibility permission on Mac. 'type' — human-like typing: {type,text,wpm,variance} — layout-independent Unicode, variable timing. 'wait' — pause: {type,duration}. 'scroll' — {type,x,y,dx,dy,steps} — human-like scroll: moves mouse to (x,y) first, then sends dy (vertical, +up) and dx (horizontal, +right) as multiple ticks with natural timing. steps=4 by default. Mouse commands may include coord_space='logical' (default) or coord_space='physical'. On macOS, 'physical' means actual screen pixels from td_get_screen_screenshot and is converted to CGEvent logical coords automatically. Top-level coord_space applies to commands that do not override it. on_error: 'stop' (default) clears queue on error; 'continue' skips failed command. IMPORTANT: call td_get_hints('input_simulation') before first use to learn focus recovery, coordinate systems, and testing workflow.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `commands` | array | yes | List of command dicts to execute in sequence. |
| `coord_space` | `logical` / `physical` | no | Default coordinate space for mouse commands that do not specify their own coord_space. 'logical' uses CGEvent coords directly. 'physical' uses actual screen pixels from td_get_screen_screenshot and is auto-converted on macOS. |
| `on_error` | `stop` / `continue` | no | What to do on error. Default 'stop'. |
### td_input_status
Get current status of the td_input command queue. Poll this after td_input_execute until status='idle'. Returns: status ('idle'/'running'), current command, queue_remaining, last error.
No parameters (other than optional `target_instance`).
### td_input_clear
Clear the td_input command queue and stop current execution immediately.
No parameters (other than optional `target_instance`).
### td_op_screen_rect
Get the screen coordinates of an operator node in the network editor. Returns {x,y,w,h,cx,cy} where cx,cy is the center for clicking. Use this to find where to click on a specific operator. Only works if the operator's parent network is currently open in a network editor pane.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Full path to the operator, e.g. '/project1/myComp/noise1' |
### td_click_screen_point
Resolve a point inside a previous td_get_screen_screenshot result and click it. Pass the screenshot request_id plus either normalized u/v or image_x/image_y. Queues a td_input click using physical screen coordinates, so it works directly with screenshot-derived points. Use duration/easing to control the cursor travel before the click.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `request_id` | string | yes | Request ID originally returned by td_get_screen_screenshot. |
| `u` | number | no | Normalized horizontal position inside the screenshot region (0=left, 1=right). Use with v. |
| `v` | number | no | Normalized vertical position inside the screenshot region (0=top, 1=bottom). Use with u. |
| `image_x` | number | no | Horizontal pixel coordinate inside the returned screenshot image. Use with image_y. |
| `image_y` | number | no | Vertical pixel coordinate inside the returned screenshot image. Use with image_x. |
| `button` | `left` / `right` / `middle` | no | Mouse button to click. Default left. |
| `hold` | number | no | Seconds to hold the mouse button down before releasing. |
| `duration` | number | no | Seconds for the cursor to travel to the target before clicking. |
| `easing` | `linear` / `ease-in` / `ease-out` / `ease-in-out` | no | Cursor movement easing for the pre-click travel. |
| `focus` | boolean | no | If true, bring TD to the front before clicking and wait briefly for focus to settle. |
### td_screen_point_to_global
Convert a point inside a previous td_get_screen_screenshot result into absolute screen coordinates. Pass the screenshot request_id plus either normalized u/v (0..1 inside that screenshot region) or image_x/image_y in returned image pixels. Returns absolute physical screen coordinates, logical coordinates, and a ready-to-use td_input_execute payload. Metadata is kept for the most recent screen screenshots so multiple agents can resolve points later by request_id.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `request_id` | string | yes | Request ID originally returned by td_get_screen_screenshot. |
| `u` | number | no | Normalized horizontal position inside the screenshot region (0=left, 1=right). Use with v. |
| `v` | number | no | Normalized vertical position inside the screenshot region (0=top, 1=bottom). Use with u. |
| `image_x` | number | no | Horizontal pixel coordinate inside the returned screenshot image. Use with image_y. |
| `image_y` | number | no | Vertical pixel coordinate inside the returned screenshot image. Use with image_x. |
## System
### td_list_instances
List all running TouchDesigner (TD) instances with active MCP servers. Returns port, project name, PID, and instanceId for each instance. Call this at the start of every conversation to discover available instances and choose which one to work with. instanceId is stable for the lifetime of a TD process and is used as target_instance in all other tool calls.
No parameters (other than optional `target_instance`).
### td_project_quit
Save and/or close the current TouchDesigner (TD) project. Can save before closing. Reports if project has unsaved changes. To close a different instance, pass target_instance=instanceId. WARNING: this will shut down the MCP server on that instance.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `save` | boolean | no | Save the project before closing. Default true. |
| `force` | boolean | no | Force close without save dialog. Default false. |
### td_reinit_extension
Reinitialize an extension on a COMP in TouchDesigner (TD). Call this AFTER finishing all code edits via td_write_dat to apply changes. Do NOT call after every small edit - batch your changes first.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | yes | Path to the COMP with the extension |
### td_dev_log
Read the last N entries from the MCP dev log. Only available when Devmode is enabled. Shows request/response history.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `count` | integer | no | Number of recent log entries to return |
### td_clear_dev_log
Clear the current MCP dev log by closing the old file and starting a fresh one. Only available when Devmode is enabled.
No parameters (other than optional `target_instance`).
### td_test_session
Manage test sessions, bug reports, and conversation export. IMPORTANT: Do NOT proactively suggest exporting chat or submitting reports. These are tools for specific situations: - export_chat / submit_report: ONLY when the user encounters a BUG with the plugin or TouchDesigner and wants to report it, or when the user explicitly asks to export the conversation. Never suggest this at session end or as routine action. USER PHRASES → ACTIONS: 'разбор тестовых сессий' / 'analyze test sessions' → list, then pull, read meta.json → index.jsonl → calls/. 'разбор репортов' / 'analyze user reports' → list with session='user', then pull by name. 'экспортируй чат' / 'export chat' → (1) export_chat_id → marker, (2) export_chat with session=marker. 'сообщи о проблеме' / 'report bug' → export chat, review for privacy, then submit_report with summary + tags + result_op=file_path. ACTIONS: export_chat_id | export_chat | submit_report | start | note | import_chat | end | list | pull. list: default=auto-detect repo. session='user' for user_reports (dev only). pull: auto-searches both repos. Auto-detects dev vs user Hub access.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `action` | `export_chat_id` / `export_chat` / `submit_report` / `start` / `note` / `import_chat` / `end` / `list` / `pull` | yes | Action: export_chat_id / export_chat / submit_report / start / note / import_chat / end / list / pull |
| `prompt` | string | no | (start) The test prompt/task description |
| `tags` | array | no | (start) Tags for categorization, e.g. ['ui', 'layout'] |
| `text` | string | no | (note) Observation text. (import_chat) Full conversation text. |
| `outcome` | `success` / `partial` / `failure` | no | (end) Result: success / partial / failure |
| `summary` | string | no | (end) Brief summary of what happened |
| `result_op` | string | no | (end) Path to operator to save as result.tox |
| `session` | string | no | (pull) Session name or substring to download |
@@ -0,0 +1,966 @@
# TouchDesigner Network Patterns
Complete network recipes for common creative coding tasks. Each pattern shows the operator chain, MCP tool calls to build it, and key parameter settings.
## Audio-Reactive Visuals
### Pattern 1: Audio Spectrum -> Noise Displacement
Audio drives noise parameters for organic, music-responsive textures.
```
Audio File In CHOP -> Audio Spectrum CHOP -> Math CHOP (scale)
|
v (export to noise params)
Noise TOP -> Level TOP -> Feedback TOP -> Composite TOP -> Null TOP (out)
^ |
|________________|
```
**MCP Build Sequence:**
```
1. td_create_operator(parent="/project1", type="audiofileinChop", name="audio_in")
2. td_create_operator(parent="/project1", type="audiospectrumChop", name="spectrum")
3. td_create_operator(parent="/project1", type="mathChop", name="spectrum_scale")
4. td_create_operator(parent="/project1", type="noiseTop", name="noise1")
5. td_create_operator(parent="/project1", type="levelTop", name="level1")
6. td_create_operator(parent="/project1", type="feedbackTop", name="feedback1")
7. td_create_operator(parent="/project1", type="compositeTop", name="comp1")
8. td_create_operator(parent="/project1", type="nullTop", name="out")
9. td_set_operator_pars(path="/project1/audio_in",
properties={"file": "/path/to/music.wav", "play": true})
10. td_set_operator_pars(path="/project1/spectrum",
properties={"size": 512})
11. td_set_operator_pars(path="/project1/spectrum_scale",
properties={"gain": 2.0, "postoff": 0.0})
12. td_set_operator_pars(path="/project1/noise1",
properties={"type": 1, "monochrome": false, "resolutionw": 1280, "resolutionh": 720,
"period": 4.0, "harmonics": 3, "amp": 1.0})
13. td_set_operator_pars(path="/project1/level1",
properties={"opacity": 0.95, "gamma1": 0.75})
14. td_set_operator_pars(path="/project1/feedback1",
properties={"top": "/project1/comp1"})
15. td_set_operator_pars(path="/project1/comp1",
properties={"operand": 0})
16. td_execute_python: """
op('/project1/audio_in').outputConnectors[0].connect(op('/project1/spectrum'))
op('/project1/spectrum').outputConnectors[0].connect(op('/project1/spectrum_scale'))
op('/project1/noise1').outputConnectors[0].connect(op('/project1/level1'))
op('/project1/level1').outputConnectors[0].connect(op('/project1/comp1').inputConnectors[0])
op('/project1/feedback1').outputConnectors[0].connect(op('/project1/comp1').inputConnectors[1])
op('/project1/comp1').outputConnectors[0].connect(op('/project1/out'))
"""
17. td_execute_python: """
# Export spectrum values to drive noise parameters
# This makes the noise react to audio frequencies
op('/project1/noise1').par.seed.expr = "op('/project1/spectrum_scale')['chan1']"
op('/project1/noise1').par.period.expr = "tdu.remap(op('/project1/spectrum_scale')['chan1'].eval(), 0, 1, 1, 8)"
"""
```
### Pattern 2: Beat Detection -> Visual Pulses
Detect beats from audio and trigger visual events.
```
Audio Device In CHOP -> Audio Spectrum CHOP -> Math CHOP (isolate bass)
|
Trigger CHOP (envelope)
|
[export to visual params]
```
**Key parameter settings:**
```
# Isolate bass frequencies (20-200 Hz)
Math CHOP: chanop=1 (Add channels), range1low=0, range1high=10
(first 10 FFT bins = bass frequencies with 512 FFT at 44100Hz)
# ADSR envelope on each beat
Trigger CHOP: attack=0.02, peak=1.0, decay=0.3, sustain=0.0, release=0.1
# Export to visual: Scale, brightness, or color intensity
td_execute_python: "op('/project1/level1').par.brightness1.expr = \"1.0 + op('/project1/trigger1')['chan1'] * 0.5\""
```
### Pattern 3: Multi-Band Audio -> Multi-Layer Visuals
Split audio into frequency bands, drive different visual layers per band.
```
Audio In -> Spectrum -> Audio Band EQ (3 bands: bass, mid, treble)
|
+---------+---------+
| | |
Bass Mids Treble
| | |
Noise TOP Circle TOP Text TOP
(slow,dark) (mid,warm) (fast,bright)
| | |
+-----+----+----+----+
| |
Composite Composite
|
Out
```
### Pattern 3b: Audio-Reactive GLSL Fractal (Proven Recipe)
Complete working recipe. Plays an MP3, runs FFT, feeds spectrum as a texture into a GLSL shader where inner fractal reacts to bass, outer to treble.
**Network:**
```
AudioFileIn CHOP → AudioSpectrum CHOP (FFT=512, outlength=256)
→ Math CHOP (gain=10) → CHOP To TOP (256x2 spectrum texture, dataformat=r)
Constant TOP (time, rgba32float) → GLSL TOP (input 0=time, input 1=spectrum) → Null → MovieFileOut
AudioFileIn CHOP → Audio Device Out CHOP Record to .mov
```
**Build via td_execute_python (one call per step for reliability):**
```python
# Step 1: Audio chain
# td_execute_python script:
td_execute_python(code="""
root = op('/project1')
audio = root.create(audiofileinCHOP, 'audio_in')
audio.par.file = '/path/to/music.mp3'
audio.par.playmode = 0 # Locked to timeline
audio.par.volume = 0.5
spec = root.create(audiospectrumCHOP, 'spectrum')
audio.outputConnectors[0].connect(spec.inputConnectors[0])
math_n = root.create(mathCHOP, 'math_norm')
spec.outputConnectors[0].connect(math_n.inputConnectors[0])
math_n.par.gain = 5 # boost signal
resamp = root.create(resampleCHOP, 'resample_spec')
math_n.outputConnectors[0].connect(resamp.inputConnectors[0])
resamp.par.timeslice = True
resamp.par.rate = 256
chop2top = root.create(choptoTOP, 'spectrum_tex')
chop2top.par.chop = resamp # CHOP To TOP has NO input connectors — use par.chop reference
# Audio output (hear the music)
aout = root.create(audiodeviceoutCHOP, 'audio_out')
audio.outputConnectors[0].connect(aout.inputConnectors[0])
result = 'audio chain ok'
""")
# Step 2: Time driver (MUST be rgba32float — see pitfalls #6)
# td_execute_python script:
td_execute_python(code="""
root = op('/project1')
td = root.create(constantTOP, 'time_driver')
td.par.format = 'rgba32float'
td.par.outputresolution = 'custom'
td.par.resolutionw = 1
td.par.resolutionh = 1
td.par.colorr.expr = "absTime.seconds % 1000.0"
td.par.colorg.expr = "int(absTime.seconds / 1000.0)"
result = 'time ok'
""")
# Step 3: GLSL shader (write to /tmp, load from file)
# td_execute_python script:
td_execute_python(code="""
root = op('/project1')
glsl = root.create(glslTOP, 'audio_shader')
glsl.par.outputresolution = 'custom'
glsl.par.resolutionw = 1280
glsl.par.resolutionh = 720
sd = root.create(textDAT, 'shader_code')
sd.text = open('/tmp/my_shader.glsl').read()
glsl.par.pixeldat = sd
# Wire: input 0 = time, input 1 = spectrum texture
op('/project1/time_driver').outputConnectors[0].connect(glsl.inputConnectors[0])
op('/project1/spectrum_tex').outputConnectors[0].connect(glsl.inputConnectors[1])
result = 'glsl ok'
""")
# Step 4: Output + recorder
# td_execute_python script:
td_execute_python(code="""
root = op('/project1')
out = root.create(nullTOP, 'output')
op('/project1/audio_shader').outputConnectors[0].connect(out.inputConnectors[0])
rec = root.create(moviefileoutTOP, 'recorder')
out.outputConnectors[0].connect(rec.inputConnectors[0])
rec.par.type = 'movie'
rec.par.file = '/tmp/output.mov'
rec.par.videocodec = 'mjpa'
result = 'output ok'
""")
```
**GLSL shader pattern (audio-reactive fractal):**
```glsl
out vec4 fragColor;
vec3 palette(float t) {
vec3 a = vec3(0.5); vec3 b = vec3(0.5);
vec3 c = vec3(1.0); vec3 d = vec3(0.263, 0.416, 0.557);
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
// Input 0 = time (1x1 rgba32float constant)
// Input 1 = audio spectrum (256x2 CHOP To TOP, stereo — sample at y=0.25 for first channel)
vec4 td = texture(sTD2DInputs[0], vec2(0.5));
float t = td.r + td.g * 1000.0;
vec2 res = uTDOutputInfo.res.zw;
vec2 uv = (gl_FragCoord.xy * 2.0 - res) / min(res.x, res.y);
vec2 uv0 = uv;
vec3 finalColor = vec3(0.0);
float bass = texture(sTD2DInputs[1], vec2(0.05, 0.25)).r;
float mids = texture(sTD2DInputs[1], vec2(0.25, 0.25)).r;
for (float i = 0.0; i < 4.0; i++) {
uv = fract(uv * (1.4 + bass * 0.3)) - 0.5;
float d = length(uv) * exp(-length(uv0));
// Sample spectrum at distance: inner=bass, outer=treble
float freq = texture(sTD2DInputs[1], vec2(clamp(d * 0.5, 0.0, 1.0), 0.25)).r;
vec3 col = palette(length(uv0) + i * 0.4 + t * 0.35);
d = sin(d * (7.0 + bass * 4.0) + t * 1.5) / 8.0;
d = abs(d);
d = pow(0.012 / d, 1.2 + freq * 0.8 + bass * 0.5);
finalColor += col * d;
}
// Tone mapping
finalColor = finalColor / (finalColor + vec3(1.0));
fragColor = TDOutputSwizzle(vec4(finalColor, 1.0));
}
```
**Key insights from testing:**
- `spectrum_tex` (CHOP To TOP) produces a 256x2 texture — x position = frequency, y=0.25 for first channel
- Sampling at `vec2(0.05, 0.0)` gets bass, `vec2(0.65, 0.0)` gets treble
- Sampling based on pixel distance (`d * 0.5`) makes inner fractal react to bass, outer to treble
- `bass * 0.3` in the `fract()` zoom makes the fractal breathe with kicks
- Math CHOP gain of 5 is needed because raw spectrum values are very small
## Generative Art
### Pattern 4: Feedback Loop with Transform
Classic generative technique — texture evolves through recursive transformation.
```
Noise TOP -> Composite TOP -> Level TOP -> Null TOP (out)
^ |
| v
Transform TOP <- Feedback TOP
```
**MCP Build Sequence:**
```
1. td_create_operator(parent="/project1", type="noiseTop", name="seed_noise")
2. td_create_operator(parent="/project1", type="compositeTop", name="mix")
3. td_create_operator(parent="/project1", type="transformTop", name="evolve")
4. td_create_operator(parent="/project1", type="feedbackTop", name="fb")
5. td_create_operator(parent="/project1", type="levelTop", name="color_correct")
6. td_create_operator(parent="/project1", type="nullTop", name="out")
7. td_set_operator_pars(path="/project1/seed_noise",
properties={"type": 1, "monochrome": false, "period": 2.0, "amp": 0.3,
"resolutionw": 1280, "resolutionh": 720})
8. td_set_operator_pars(path="/project1/mix",
properties={"operand": 27}) # 27 = Screen blend
9. td_set_operator_pars(path="/project1/evolve",
properties={"sx": 1.003, "sy": 1.003, "rz": 0.5, "extend": 2}) # slight zoom + rotate, repeat edges
10. td_set_operator_pars(path="/project1/fb",
properties={"top": "/project1/mix"})
11. td_set_operator_pars(path="/project1/color_correct",
properties={"opacity": 0.98, "gamma1": 0.85})
12. td_execute_python: """
op('/project1/seed_noise').outputConnectors[0].connect(op('/project1/mix').inputConnectors[0])
op('/project1/fb').outputConnectors[0].connect(op('/project1/evolve'))
op('/project1/evolve').outputConnectors[0].connect(op('/project1/mix').inputConnectors[1])
op('/project1/mix').outputConnectors[0].connect(op('/project1/color_correct'))
op('/project1/color_correct').outputConnectors[0].connect(op('/project1/out'))
"""
```
**Variations:**
- Change Transform: `rz` (rotation), `sx/sy` (zoom), `tx/ty` (drift)
- Change Composite operand: Screen (glow), Add (bright), Multiply (dark)
- Add HSV Adjust in the feedback loop for color evolution
- Add Blur for dreamlike softness
- Replace Noise with a GLSL TOP for custom seed patterns
### Pattern 5: Instancing (Particle-Like Systems)
Render thousands of copies of geometry, each with unique position/rotation/scale driven by CHOP data or DATs.
```
Table DAT (instance data) -> DAT to CHOP -> Geometry COMP (instancing on) -> Render TOP
+ Sphere SOP (template geometry)
+ Constant MAT (material)
+ Camera COMP
+ Light COMP
```
**MCP Build Sequence:**
```
1. td_create_operator(parent="/project1", type="tableDat", name="instance_data")
2. td_create_operator(parent="/project1", type="geometryComp", name="geo1")
3. td_create_operator(parent="/project1/geo1", type="sphereSop", name="sphere")
4. td_create_operator(parent="/project1", type="constMat", name="mat1")
5. td_create_operator(parent="/project1", type="cameraComp", name="cam1")
6. td_create_operator(parent="/project1", type="lightComp", name="light1")
7. td_create_operator(parent="/project1", type="renderTop", name="render1")
8. td_execute_python: """
import random, math
dat = op('/project1/instance_data')
dat.clear()
dat.appendRow(['tx', 'ty', 'tz', 'sx', 'sy', 'sz', 'cr', 'cg', 'cb'])
for i in range(500):
angle = i * 0.1
r = 2 + i * 0.01
dat.appendRow([
str(math.cos(angle) * r),
str(math.sin(angle) * r),
str((i - 250) * 0.02),
'0.05', '0.05', '0.05',
str(random.random()),
str(random.random()),
str(random.random())
])
"""
9. td_set_operator_pars(path="/project1/geo1",
properties={"instancing": true, "instancechop": "",
"instancedat": "/project1/instance_data",
"material": "/project1/mat1"})
10. td_set_operator_pars(path="/project1/render1",
properties={"camera": "/project1/cam1", "geometry": "/project1/geo1",
"light": "/project1/light1",
"resolutionw": 1280, "resolutionh": 720})
11. td_set_operator_pars(path="/project1/cam1",
properties={"tz": 10})
```
### Pattern 6: Reaction-Diffusion (GLSL)
Classic Gray-Scott reaction-diffusion system running on the GPU.
```
Text DAT (GLSL code) -> GLSL TOP (resolution, dat reference) -> Feedback TOP
^ |
|_______________________________________|
Level TOP (out)
```
**Key GLSL code (write to Text DAT via td_execute_python):**
```glsl
// Gray-Scott reaction-diffusion
uniform float feed; // 0.037
uniform float kill; // 0.06
uniform float dA; // 1.0
uniform float dB; // 0.5
layout(location = 0) out vec4 fragColor;
void main() {
vec2 uv = vUV.st;
vec2 texel = 1.0 / uTDOutputInfo.res.zw;
vec4 c = texture(sTD2DInputs[0], uv);
float a = c.r;
float b = c.g;
// Laplacian (9-point stencil)
float lA = 0.0, lB = 0.0;
for(int dx = -1; dx <= 1; dx++) {
for(int dy = -1; dy <= 1; dy++) {
float w = (dx == 0 && dy == 0) ? -1.0 : (abs(dx) + abs(dy) == 1 ? 0.2 : 0.05);
vec4 s = texture(sTD2DInputs[0], uv + vec2(dx, dy) * texel);
lA += s.r * w;
lB += s.g * w;
}
}
float reaction = a * b * b;
float newA = a + (dA * lA - reaction + feed * (1.0 - a));
float newB = b + (dB * lB + reaction - (kill + feed) * b);
fragColor = vec4(clamp(newA, 0.0, 1.0), clamp(newB, 0.0, 1.0), 0.0, 1.0);
}
```
## Video Processing
### Pattern 7: Video Effects Chain
Apply a chain of effects to a video file.
```
Movie File In TOP -> HSV Adjust TOP -> Level TOP -> Blur TOP -> Composite TOP -> Null TOP (out)
^
Text TOP ---+
```
**MCP Build Sequence:**
```
1. td_create_operator(parent="/project1", type="moviefileinTop", name="video_in")
2. td_create_operator(parent="/project1", type="hsvadjustTop", name="color")
3. td_create_operator(parent="/project1", type="levelTop", name="levels")
4. td_create_operator(parent="/project1", type="blurTop", name="blur")
5. td_create_operator(parent="/project1", type="compositeTop", name="overlay")
6. td_create_operator(parent="/project1", type="textTop", name="title")
7. td_create_operator(parent="/project1", type="nullTop", name="out")
8. td_set_operator_pars(path="/project1/video_in",
properties={"file": "/path/to/video.mp4", "play": true})
9. td_set_operator_pars(path="/project1/color",
properties={"hueoffset": 0.1, "saturationmult": 1.3})
10. td_set_operator_pars(path="/project1/levels",
properties={"brightness1": 1.1, "contrast": 1.2, "gamma1": 0.9})
11. td_set_operator_pars(path="/project1/blur",
properties={"sizex": 2, "sizey": 2})
12. td_set_operator_pars(path="/project1/title",
properties={"text": "My Video", "fontsizex": 48, "alignx": 1, "aligny": 1})
13. td_execute_python: """
chain = ['video_in', 'color', 'levels', 'blur']
for i in range(len(chain) - 1):
op(f'/project1/{chain[i]}').outputConnectors[0].connect(op(f'/project1/{chain[i+1]}'))
op('/project1/blur').outputConnectors[0].connect(op('/project1/overlay').inputConnectors[0])
op('/project1/title').outputConnectors[0].connect(op('/project1/overlay').inputConnectors[1])
op('/project1/overlay').outputConnectors[0].connect(op('/project1/out'))
"""
```
### Pattern 8: Video Recording
Record the output to a file. **H.264/H.265 require a Commercial license** — use Motion JPEG (`mjpa`) on Non-Commercial.
```
[any TOP chain] -> Null TOP -> Movie File Out TOP
```
```python
# Build via td_execute_python:
root = op('/project1')
# Always put a Null TOP before the recorder
null_out = root.op('out') # or create one
rec = root.create(moviefileoutTOP, 'recorder')
null_out.outputConnectors[0].connect(rec.inputConnectors[0])
rec.par.type = 'movie'
rec.par.file = '/tmp/output.mov'
rec.par.videocodec = 'mjpa' # Motion JPEG — works on Non-Commercial
# Start recording (par.record is a toggle — .record() method may not exist)
rec.par.record = True
# ... let TD run for desired duration ...
rec.par.record = False
# For image sequences:
# rec.par.type = 'imagesequence'
# rec.par.imagefiletype = 'png'
# rec.par.file.expr = "'/tmp/frames/out' + me.fileSuffix" # fileSuffix REQUIRED
```
**Pitfalls:**
- Setting `par.file` + `par.record = True` in the same script may race — use `run("...", delayFrames=2)`
- `TOP.save()` called rapidly always captures the same frame — use MovieFileOut for animation
- See `pitfalls.md` #25-27 for full details
### Pattern 8b: TD → External Pipeline (FFmpeg / Python / Post-Processing)
Export TD visuals for use in another tool (ffmpeg, Python, ASCII art, etc.). This is the standard workflow when you need to composite TD output with external processing (ASCII conversion, Python shader chains, ML inference, etc.).
**Step 1: Record to video in TD**
```python
# Preferred: ProRes on macOS (lossless, Non-Commercial OK, ~55MB/s at 1280x720)
rec.par.videocodec = 'prores'
# Fallback for non-macOS: mjpa (Motion JPEG)
# rec.par.videocodec = 'mjpa'
rec.par.record = True
# ... wait N seconds ...
rec.par.record = False
```
**Step 2: Extract frames with ffmpeg**
```bash
# Extract all frames at 30fps
ffmpeg -y -i /tmp/output.mov -vf 'fps=30' /tmp/frames/frame_%06d.png
# Or extract a specific duration
ffmpeg -y -i /tmp/output.mov -t 25 -vf 'fps=30' /tmp/frames/frame_%06d.png
# Or extract specific frame range
ffmpeg -y -i /tmp/output.mov -vf 'select=between(n\,0\,749)' -vsync vfr /tmp/frames/frame_%06d.png
```
**Step 3: Process frames in Python**
```python
from PIL import Image
import os
frames_dir = '/tmp/frames'
output_dir = '/tmp/processed'
os.makedirs(output_dir, exist_ok=True)
for fname in sorted(os.listdir(frames_dir)):
if not fname.endswith('.png'):
continue
img = Image.open(os.path.join(frames_dir, fname))
# ... apply your processing ...
img.save(os.path.join(output_dir, fname))
```
**Step 4: Mux processed frames back with audio**
```bash
# Create video from processed frames + audio with fade-out
ffmpeg -y \
-framerate 30 -i /tmp/processed/frame_%06d.png \
-i /tmp/audio.mp3 \
-c:v libx264 -pix_fmt yuv420p -crf 18 \
-c:a aac -b:a 192k \
-shortest \
-af 'afade=t=out:st=23:d=2' \
/tmp/final_output.mp4
```
**Key considerations:**
- Use ProRes for the TD recording step to avoid generation loss during compositing
- Extract at the target output framerate (not TD's render framerate)
- For audio-synced content, analyze the audio file separately in Python (scipy FFT) to get per-frame features (rms, spectral bands, beats) and drive compositing parameters
- Always verify TD FPS > 0 before recording (see pitfalls #37, #38)
## Data Visualization
### Pattern 9: Table Data -> Bar Chart via Instancing
Visualize tabular data as a 3D bar chart.
```
Table DAT (data) -> Script DAT (transform to instance format) -> DAT to CHOP
|
Box SOP -> Geometry COMP (instancing from CHOP) -> Render TOP -> Null TOP (out)
+ PBR MAT
+ Camera COMP
+ Light COMP
```
```python
# Script DAT code to transform data to instance positions
td_execute_python: """
source = op('/project1/data_table')
instance = op('/project1/instance_transform')
instance.clear()
instance.appendRow(['tx', 'ty', 'tz', 'sx', 'sy', 'sz', 'cr', 'cg', 'cb'])
for i in range(1, source.numRows):
value = float(source[i, 'value'])
name = source[i, 'name']
instance.appendRow([
str(i * 1.5), # x position (spread bars)
str(value / 2), # y position (center bar vertically)
'0', # z position
'1', str(value), '1', # scale (height = data value)
'0.2', '0.6', '1.0' # color (blue)
])
"""
```
### Pattern 9b: Audio-Reactive GLSL Fractal (Proven Recipe)
Audio spectrum drives a GLSL fractal shader directly via a spectrum texture input. Bass thickens inner fractal lines, mids twist rotation, highs light outer edges. **Always run discovery (SKILL.md Step 0) before using any param names from these recipes — they may differ in your TD version.**
```
Audio File In CHOP → Audio Spectrum CHOP (FFT=512, outlength=256)
→ Math CHOP (gain=10)
→ CHOP To TOP (spectrum texture, 256x2, dataformat=r)
↓ (input 1)
Constant TOP (rgba32float, time) → GLSL TOP (audio-reactive shader) → Null TOP
(input 0) ↑
Text DAT (shader code)
```
**Build via td_execute_python (complete working script):**
```python
# td_execute_python script:
td_execute_python(code="""
import os
root = op('/project1')
# Audio input
audio = root.create(audiofileinCHOP, 'audio_in')
audio.par.file = '/path/to/music.mp3'
audio.par.playmode = 0 # Locked to timeline
# FFT analysis (output length manually set to 256 bins)
spectrum = root.create(audiospectrumCHOP, 'spectrum')
audio.outputConnectors[0].connect(spectrum.inputConnectors[0])
spectrum.par.fftsize = '512'
spectrum.par.outputmenu = 'setmanually'
spectrum.par.outlength = 256
# THEN boost gain on the raw spectrum (NO Lag CHOP — see pitfall #34)
math = root.create(mathCHOP, 'math_norm')
spectrum.outputConnectors[0].connect(math.inputConnectors[0])
math.par.gain = 10
# Spectrum → texture (256x2 image — stereo, sample at y=0.25 for first channel)
# NOTE: choptoTOP has NO input connectors — use par.chop reference!
spec_tex = root.create(choptoTOP, 'spectrum_tex')
spec_tex.par.chop = math
spec_tex.par.dataformat = 'r'
spec_tex.par.layout = 'rowscropped'
# Time driver (rgba32float to avoid 0-1 clamping!)
time_drv = root.create(constantTOP, 'time_driver')
time_drv.par.format = 'rgba32float'
time_drv.par.outputresolution = 'custom'
time_drv.par.resolutionw = 1
time_drv.par.resolutionh = 1
time_drv.par.colorr.expr = "absTime.seconds % 1000.0"
time_drv.par.colorg.expr = "int(absTime.seconds / 1000.0)"
# GLSL shader
glsl = root.create(glslTOP, 'audio_shader')
glsl.par.outputresolution = 'custom'
glsl.par.resolutionw = 1280; glsl.par.resolutionh = 720
shader_dat = root.create(textDAT, 'shader_code')
shader_dat.text = open('/tmp/shader.glsl').read()
glsl.par.pixeldat = shader_dat
# Wire: input 0=time, input 1=spectrum
time_drv.outputConnectors[0].connect(glsl.inputConnectors[0])
spec_tex.outputConnectors[0].connect(glsl.inputConnectors[1])
# Output + audio playback
out = root.create(nullTOP, 'output')
glsl.outputConnectors[0].connect(out.inputConnectors[0])
audio_out = root.create(audiodeviceoutCHOP, 'audio_out')
audio.outputConnectors[0].connect(audio_out.inputConnectors[0])
result = 'network built'
""")
```
**GLSL shader (reads spectrum from input 1 texture):**
```glsl
out vec4 fragColor;
vec3 palette(float t) {
vec3 a = vec3(0.5); vec3 b = vec3(0.5);
vec3 c = vec3(1.0); vec3 d = vec3(0.263, 0.416, 0.557);
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec4 td = texture(sTD2DInputs[0], vec2(0.5));
float t = td.r + td.g * 1000.0;
vec2 res = uTDOutputInfo.res.zw;
vec2 uv = (gl_FragCoord.xy * 2.0 - res) / min(res.x, res.y);
vec2 uv0 = uv;
vec3 finalColor = vec3(0.0);
float bass = texture(sTD2DInputs[1], vec2(0.05, 0.25)).r;
float mids = texture(sTD2DInputs[1], vec2(0.25, 0.25)).r;
float highs = texture(sTD2DInputs[1], vec2(0.65, 0.25)).r;
float ca = cos(t * (0.15 + mids * 0.3));
float sa = sin(t * (0.15 + mids * 0.3));
uv = mat2(ca, -sa, sa, ca) * uv;
for (float i = 0.0; i < 4.0; i++) {
uv = fract(uv * (1.4 + bass * 0.3)) - 0.5;
float d = length(uv) * exp(-length(uv0));
float freq = texture(sTD2DInputs[1], vec2(clamp(d*0.5, 0.0, 1.0), 0.25)).r;
vec3 col = palette(length(uv0) + i * 0.4 + t * 0.35);
d = sin(d * (7.0 + bass * 4.0) + t * 1.5) / 8.0;
d = abs(d);
d = pow(0.012 / d, 1.2 + freq * 0.8 + bass * 0.5);
finalColor += col * d;
}
float glow = (0.03 + bass * 0.05) / (length(uv0) + 0.03);
finalColor += vec3(0.4, 0.1, 0.7) * glow * (0.6 + 0.4 * sin(t * 2.5));
float ring = abs(length(uv0) - 0.4 - mids * 0.3);
finalColor += vec3(0.1, 0.6, 0.8) * (0.005 / ring) * (0.2 + highs * 0.5);
finalColor *= smoothstep(0.0, 1.0, 1.0 - dot(uv0*0.55, uv0*0.55));
finalColor = finalColor / (finalColor + vec3(1.0));
fragColor = TDOutputSwizzle(vec4(finalColor, 1.0));
}
```
**How spectrum sampling drives the visual:**
- `texture(sTD2DInputs[1], vec2(x, 0.0)).r` — x position = frequency (0=bass, 1=treble)
- Inner fractal iterations sample lower x → react to bass
- Outer iterations sample higher x → react to treble
- `bass * 0.3` on `fract()` scale → fractal zoom pulses with bass
- `bass * 4.0` on sin frequency → line density pulses with bass
- `mids * 0.3` on rotation speed → spiral twists faster during vocal/mid sections
- `highs * 0.5` on ring opacity → high-frequency sparkle on outer ring
**Recording the output:** Use MovieFileOut TOP with `mjpa` codec (H.264 requires Commercial license). See pitfalls #25-27.
## GLSL Shaders
### Pattern 10: Custom Fragment Shader
Write a custom visual effect as a GLSL fragment shader.
```
Text DAT (shader code) -> GLSL TOP -> Level TOP -> Null TOP (out)
+ optional input TOPs for texture sampling
```
**Common GLSL uniforms available in TouchDesigner:**
```glsl
// Automatically provided by TD
uniform vec4 uTDOutputInfo; // .res.zw = resolution
// NOTE: uTDCurrentTime does NOT exist in TD 099!
// Feed time via a 1x1 Constant TOP (format=rgba32float):
// t.par.colorr.expr = "absTime.seconds % 1000.0"
// t.par.colorg.expr = "int(absTime.seconds / 1000.0)"
// Then read in GLSL:
// vec4 td = texture(sTD2DInputs[0], vec2(0.5));
// float t = td.r + td.g * 1000.0;
// Input textures (from connected TOP inputs)
uniform sampler2D sTD2DInputs[1]; // array of input samplers
// From vertex shader
in vec3 vUV; // UV coordinates (0-1 range)
```
**Example: Plasma shader (using time from input texture)**
```glsl
layout(location = 0) out vec4 fragColor;
void main() {
vec2 uv = vUV.st;
// Read time from Constant TOP input 0 (rgba32float format)
vec4 td = texture(sTD2DInputs[0], vec2(0.5));
float t = td.r + td.g * 1000.0;
float v1 = sin(uv.x * 10.0 + t);
float v2 = sin(uv.y * 10.0 + t * 0.7);
float v3 = sin((uv.x + uv.y) * 10.0 + t * 1.3);
float v4 = sin(length(uv - 0.5) * 20.0 - t * 2.0);
float v = (v1 + v2 + v3 + v4) * 0.25;
vec3 color = vec3(
sin(v * 3.14159 + 0.0) * 0.5 + 0.5,
sin(v * 3.14159 + 2.094) * 0.5 + 0.5,
sin(v * 3.14159 + 4.189) * 0.5 + 0.5
);
fragColor = vec4(color, 1.0);
}
```
### Pattern 11: Multi-Pass GLSL (Ping-Pong)
For effects needing state across frames (particles, fluid, cellular automata), use GLSL Multi TOP with multiple passes or a Feedback TOP loop.
```
GLSL Multi TOP (pass 0: simulation, pass 1: rendering)
+ Text DAT (simulation shader)
+ Text DAT (render shader)
-> Level TOP -> Null TOP (out)
^
|__ Feedback TOP (feeds simulation state back)
```
## Interactive Installations
### Pattern 12: Mouse/Touch -> Visual Response
```
Mouse In CHOP -> Math CHOP (normalize to 0-1) -> [export to visual params]
# Or for touch/multi-touch:
Multi Touch In DAT -> Script CHOP (parse touches) -> [export to visual params]
```
```python
# Normalize mouse position to 0-1 range
td_execute_python: """
op('/project1/noise1').par.offsetx.expr = "op('/project1/mouse_norm')['tx']"
op('/project1/noise1').par.offsety.expr = "op('/project1/mouse_norm')['ty']"
"""
```
### Pattern 13: OSC Control (from external software)
```
OSC In CHOP (port 7000) -> Select CHOP (pick channels) -> [export to visual params]
```
```
1. td_create_operator(parent="/project1", type="oscinChop", name="osc_in")
2. td_set_operator_pars(path="/project1/osc_in", properties={"port": 7000})
# OSC messages like /frequency 440 will appear as channel "frequency" with value 440
# Export to any parameter:
3. td_execute_python: "op('/project1/noise1').par.period.expr = \"op('/project1/osc_in')['frequency']\""
```
### Pattern 14: MIDI Control (DJ/VJ)
```
MIDI In CHOP (device) -> Select CHOP -> [export channels to visual params]
```
Common MIDI mappings:
- CC channels (knobs/faders): continuous 0-127, map to float params
- Note On/Off: binary triggers, map to Trigger CHOP for envelopes
- Velocity: intensity/brightness
## Live Performance
### Pattern 15: Multi-Source VJ Setup
```
Source A (generative) ----+
Source B (video) ---------+-- Switch/Cross TOP -- Level TOP -- Window COMP (output)
Source C (camera) --------+
^
MIDI/OSC control selects active source and crossfade
```
```python
# MIDI CC1 controls which source is active (0-127 -> 0-2)
td_execute_python: """
op('/project1/switch1').par.index.expr = "int(op('/project1/midi_in')['cc1'] / 42)"
"""
# MIDI CC2 controls crossfade between current and next
td_execute_python: """
op('/project1/cross1').par.cross.expr = "op('/project1/midi_in')['cc2'] / 127.0"
"""
```
### Pattern 16: Projection Mapping
```
Content TOPs ----+
|
Stoner TOP (UV mapping) -> Composite TOP -> Window COMP (projector output)
or
Kantan Mapper COMP (external .tox)
```
For projection mapping, the key is:
1. Create your visual content as standard TOPs
2. Use Stoner TOP or a third-party mapping tool to UV-map content to physical surfaces
3. Output via Window COMP to the projector
### Pattern 17: Cue System
```
Table DAT (cue list: cue_number, scene_name, duration, transition_type)
|
Script CHOP (cue state: current_cue, progress, next_cue_trigger)
|
[export to Switch/Cross TOPs to transition between scenes]
```
```python
td_execute_python: """
# Simple cue system
cue_table = op('/project1/cue_list')
cue_state = op('/project1/cue_state')
def advance_cue():
current = int(cue_state.par.value0.val)
next_cue = min(current + 1, cue_table.numRows - 1)
cue_state.par.value0.val = next_cue
scene = cue_table[next_cue, 'scene']
duration = float(cue_table[next_cue, 'duration'])
# Set crossfade target and duration
op('/project1/cross1').par.cross.val = 0
# Animate cross to 1.0 over duration seconds
# (use a Timer CHOP or LFO CHOP for smooth animation)
"""
```
## Networking
### Pattern 18: OSC Server/Client
```
# Sending OSC
OSC Out CHOP -> (network) -> external application
# Receiving OSC
(network) -> OSC In CHOP -> Select CHOP -> [use values]
```
### Pattern 19: NDI Video Streaming
```
# Send video over network
[any TOP chain] -> NDI Out TOP (source name)
# Receive video from network
NDI In TOP (select source) -> [process as normal TOP]
```
### Pattern 20: WebSocket Communication
```
WebSocket DAT -> Script DAT (parse JSON messages) -> [update visuals]
```
```python
td_execute_python: """
ws = op('/project1/websocket1')
ws.par.address = 'ws://localhost:8080'
ws.par.active = True
# In a DAT Execute callback (Script DAT watching WebSocket DAT):
# def onTableChange(dat):
# import json
# msg = json.loads(dat.text)
# op('/project1/noise1').par.seed.val = msg.get('seed', 0)
"""
```
@@ -0,0 +1,239 @@
# TouchDesigner Operator Reference
## Operator Families Overview
TouchDesigner has 6 operator families. Each family processes a specific data type and is color-coded in the UI. Operators can only connect to others of the SAME family (with cross-family converters as the bridge).
## TOPs — Texture Operators (Purple)
2D image/texture processing on the GPU. The workhorse of visual output.
### Generators (create images from nothing)
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Noise TOP | `noiseTop` | `type` (0-6), `monochrome`, `seed`, `period`, `harmonics`, `exponent`, `amp`, `offset`, `resolutionw/h` | Procedural noise textures — Perlin, Simplex, Sparse, etc. Foundation of generative art. |
| Constant TOP | `constantTop` | `colorr/g/b/a`, `resolutionw/h` | Solid color. Use as background or blend input. |
| Text TOP | `textTop` | `text`, `fontsizex`, `fontfile`, `alignx/y`, `colorr/g/b` | Render text to texture. Supports multi-line, word wrap. |
| Ramp TOP | `rampTop` | `type` (0=horizontal, 1=vertical, 2=radial, 3=circular), `phase`, `period` | Gradient textures for masking, color mapping. |
| Circle TOP | `circleTop` | `radiusx/y`, `centerx/y`, `width` | Circles, rings, ellipses. |
| Rectangle TOP | `rectangleTop` | `sizex/y`, `centerx/y`, `softness` | Rectangles with optional softness. |
| GLSL TOP | `glslTop` | `dat` (points to shader DAT), `resolutionw/h`, `outputformat`, custom uniforms | Custom fragment shaders. Most powerful TOP for custom visuals. |
| GLSL Multi TOP | `glslmultiTop` | `dat`, `numinputs`, `numoutputs`, `numcomputepasses` | Multi-pass GLSL with compute shaders. Advanced. |
| Render TOP | `renderTop` | `camera`, `geometry`, `lights`, `resolutionw/h` | Renders 3D scenes (SOPs + MATs + Camera/Light COMPs). |
### Filters (modify a single input)
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Level TOP | `levelTop` | `opacity`, `brightness1/2`, `gamma1/2`, `contrast`, `invert`, `blacklevel/whitelevel` | Brightness, contrast, gamma, levels. Essential color correction. |
| Blur TOP | `blurTop` | `sizex/y`, `type` (0=Gaussian, 1=Box, 2=Bartlett) | Gaussian/box blur. |
| Transform TOP | `transformTop` | `tx/ty`, `sx/sy`, `rz`, `pivotx/y`, `extend` (0=Hold, 1=Zero, 2=Repeat, 3=Mirror) | Translate, scale, rotate textures. |
| HSV Adjust TOP | `hsvadjustTop` | `hueoffset`, `saturationmult`, `valuemult` | HSV color adjustments. |
| Lookup TOP | `lookupTop` | (input: texture + lookup table) | Color remapping via lookup table texture. |
| Edge TOP | `edgeTop` | `type` (0=Sobel, 1=Frei-Chen) | Edge detection. |
| Displace TOP | `displaceTop` | `scalex/y` | Pixel displacement using a second input as displacement map. |
| Flip TOP | `flipTop` | `flipx`, `flipy`, `flop` (diagonal) | Mirror/flip textures. |
| Crop TOP | `cropTop` | `cropleft/right/top/bottom` | Crop region of texture. |
| Resolution TOP | `resolutionTop` | `resolutionw/h`, `outputresolution` | Resize textures. |
| Null TOP | `nullTop` | (none significant) | Pass-through. Use for organization, referencing, feedback delay. |
| Cache TOP | `cacheTop` | `length`, `step` | Store N frames of history. Useful for trails, time effects. |
### Compositors (combine multiple inputs)
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Composite TOP | `compositeTop` | `operand` (0-31: Over, Add, Multiply, Screen, etc.) | Blend two textures with standard compositing modes. |
| Over TOP | `overTop` | (simple alpha compositing) | Layer with alpha. Simpler than Composite. |
| Add TOP | `addTop` | (additive blend) | Additive blending. Great for glow, light effects. |
| Multiply TOP | `multiplyTop` | (multiplicative blend) | Multiply blend. Good for masking, darkening. |
| Switch TOP | `switchTop` | `index` (0-based) | Switch between multiple inputs by index. |
| Cross TOP | `crossTop` | `cross` (0.0-1.0) | Crossfade between two inputs. |
### I/O (input/output)
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Movie File In TOP | `moviefileinTop` | `file`, `speed`, `trim`, `index` | Load video files, image sequences. |
| Movie File Out TOP | `moviefileoutTop` | `file`, `type` (codec), `record` (toggle) | Record/export video files. |
| NDI In TOP | `ndiinTop` | `sourcename` | Receive NDI video streams. |
| NDI Out TOP | `ndioutTop` | `sourcename` | Send NDI video streams. |
| Syphon Spout In/Out TOP | `syphonspoutinTop` / `syphonspoutoutTop` | `servername` | Inter-app texture sharing. |
| Video Device In TOP | `videodeviceinTop` | `device` | Webcam/capture card input. |
| Feedback TOP | `feedbackTop` | `top` (path to the TOP to feed back) | One-frame delay feedback. Essential for recursive effects. |
### Converters
| Operator | Type Name | Direction | Use |
|----------|-----------|-----------|-----|
| CHOP to TOP | `choptopTop` | CHOP -> TOP | Visualize channel data as texture (waveform, spectrum display). |
| TOP to CHOP | `topchopChop` | TOP -> CHOP | Sample texture pixels as channel data. |
## CHOPs — Channel Operators (Green)
Time-varying numeric data: audio, animation curves, sensor data, control signals.
### Generators
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Constant CHOP | `constantChop` | `name0/value0`, `name1/value1`... | Static named channels. Control panel for parameters. |
| LFO CHOP | `lfoChop` | `frequency`, `type` (0=Sin, 1=Tri, 2=Square, 3=Ramp, 4=Pulse), `amp`, `offset`, `phase` | Low frequency oscillator. Animation driver. |
| Noise CHOP | `noiseChop` | `type`, `roughness`, `period`, `amp`, `seed`, `channels` | Smooth random motion. Organic animation. |
| Pattern CHOP | `patternChop` | `type` (0=Sine, 1=Triangle, ...), `length`, `cycles` | Generate waveform patterns. |
| Timer CHOP | `timerChop` | `length`, `play`, `cue`, `cycles` | Countdown/count-up timer with cue points. |
| Count CHOP | `countChop` | `threshold`, `limittype`, `limitmin/max` | Event counter with wrapping/clamping. |
### Audio
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Audio File In CHOP | `audiofileinChop` | `file`, `volume`, `play`, `speed`, `trim` | Play audio files. |
| Audio Device In CHOP | `audiodeviceinChop` | `device`, `channels` | Live microphone/line input. |
| Audio Spectrum CHOP | `audiospectrumChop` | `size` (FFT size), `outputformat` (0=Power, 1=Magnitude) | FFT frequency analysis. |
| Audio Band EQ CHOP | `audiobandeqChop` | `bands`, `gaindb` per band | Frequency band isolation. |
| Audio Device Out CHOP | `audiodeviceoutChop` | `device` | Audio playback output. |
### Math/Logic
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Math CHOP | `mathChop` | `preoff`, `gain`, `postoff`, `chanop` (0=Off, 1=Add, 2=Subtract, 3=Multiply...) | Math operations on channels. The Swiss army knife. |
| Logic CHOP | `logicChop` | `preop` (0=Off, 1=AND, 2=OR, 3=XOR, 4=NAND), `convert` | Boolean logic on channels. |
| Filter CHOP | `filterChop` | `type` (0=Low Pass, 1=Band Pass, 2=High Pass, 3=Notch), `cutofffreq`, `filterwidth` | Smooth, dampen, filter signals. |
| Lag CHOP | `lagChop` | `lag1/2`, `overshoot1/2` | Smooth transitions with overshoot. |
| Limit CHOP | `limitChop` | `type` (0=Clamp, 1=Loop, 2=ZigZag), `min/max` | Clamp or wrap channel values. |
| Speed CHOP | `speedChop` | (none significant) | Integrate values (velocity to position, acceleration to velocity). |
| Trigger CHOP | `triggerChop` | `attack`, `peak`, `decay`, `sustain`, `release` | ADSR envelope from trigger events. |
| Select CHOP | `selectChop` | `chop` (path), `channames` | Reference channels from another CHOP. |
| Merge CHOP | `mergeChop` | `align` (0=Extend, 1=Trim to First, 2=Trim to Shortest) | Combine channels from multiple CHOPs. |
| Null CHOP | `nullChop` | (none significant) | Pass-through for organization and referencing. |
### Input Devices
| Operator | Type Name | Use |
|----------|-----------|-----|
| Mouse In CHOP | `mouseinChop` | Mouse position, buttons, wheel. |
| Keyboard In CHOP | `keyboardinChop` | Keyboard key states. |
| MIDI In CHOP | `midiinChop` | MIDI note/CC input. |
| OSC In CHOP | `oscinChop` | OSC message input (network). |
## SOPs — Surface Operators (Blue)
3D geometry: points, polygons, NURBS, meshes.
### Generators
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Grid SOP | `gridSop` | `rows`, `cols`, `sizex/y`, `type` (0=Polygon, 1=Mesh, 2=NURBS) | Flat grid mesh. Foundation for displacement, instancing. |
| Sphere SOP | `sphereSop` | `type`, `rows`, `cols`, `radius` | Sphere geometry. |
| Box SOP | `boxSop` | `sizex/y/z` | Box geometry. |
| Torus SOP | `torusSop` | `radiusx/y`, `rows`, `cols` | Donut shape. |
| Circle SOP | `circleSop` | `type`, `radius`, `divs` | Circle/ring geometry. |
| Line SOP | `lineSop` | `dist`, `points` | Line segments. |
| Text SOP | `textSop` | `text`, `fontsizex`, `fontfile`, `extrude` | 3D text geometry. |
### Modifiers
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Transform SOP | `transformSop` | `tx/ty/tz`, `rx/ry/rz`, `sx/sy/sz` | Transform geometry (translate, rotate, scale). |
| Noise SOP | `noiseSop` | `type`, `amp`, `period`, `roughness` | Deform geometry with noise. |
| Sort SOP | `sortSop` | `ptsort`, `primsort` | Reorder points/primitives. |
| Facet SOP | `facetSop` | `unique`, `consolidate`, `computenormals` | Normals, consolidation, unique points. |
| Merge SOP | `mergeSop` | (none significant) | Combine multiple geometry inputs. |
| Null SOP | `nullSop` | (none significant) | Pass-through. |
## DATs — Data Operators (White)
Text, tables, scripts, network data.
### Core
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Table DAT | `tableDat` | (edit content directly) | Spreadsheet-like data tables. |
| Text DAT | `textDat` | (edit content directly) | Arbitrary text content. Shader code, configs, scripts. |
| Script DAT | `scriptDat` | `language` (0=Python, 1=C++) | Custom callbacks and DAT processing. |
| CHOP Execute DAT | `chopexecDat` | `chop` (path to watch), callbacks | Trigger Python on CHOP value changes. |
| DAT Execute DAT | `datexecDat` | `dat` (path to watch) | Trigger Python on DAT content changes. |
| Panel Execute DAT | `panelexecDat` | `panel` | Trigger Python on UI panel events. |
### I/O
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Web DAT | `webDat` | `url`, `fetchmethod` (0=GET, 1=POST) | HTTP requests. API integration. |
| TCP/IP DAT | `tcpipDat` | `address`, `port`, `mode` | TCP networking. |
| OSC In DAT | `oscinDat` | `port` | Receive OSC as text messages. |
| Serial DAT | `serialDat` | `port`, `baudrate` | Serial port communication (Arduino, etc.). |
| File In DAT | `fileinDat` | `file` | Read text files. |
| File Out DAT | `fileoutDat` | `file`, `write` | Write text files. |
### Conversions
| Operator | Type Name | Direction | Use |
|----------|-----------|-----------|-----|
| DAT to CHOP | `dattochopChop` | DAT -> CHOP | Convert table data to channels. |
| CHOP to DAT | `choptodatDat` | CHOP -> DAT | Convert channel data to table rows. |
| SOP to DAT | `soptodatDat` | SOP -> DAT | Extract geometry data as table. |
## MATs — Material Operators (Yellow)
Materials for 3D rendering in Render TOP / Geometry COMP.
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Phong MAT | `phongMat` | `diff_colorr/g/b`, `spec_colorr/g/b`, `shininess`, `colormap`, `normalmap` | Classic Phong shading. Simple, fast. |
| PBR MAT | `pbrMat` | `basecolorr/g/b`, `metallic`, `roughness`, `normalmap`, `emitcolorr/g/b` | Physically-based rendering. Realistic materials. |
| GLSL MAT | `glslMat` | `dat` (shader DAT), custom uniforms | Custom vertex + fragment shaders for 3D. |
| Constant MAT | `constMat` | `colorr/g/b`, `colormap` | Flat unlit color/texture. No shading. |
| Point Sprite MAT | `pointspriteMat` | `colormap`, `scale` | Render points as camera-facing sprites. Great for particles. |
| Wireframe MAT | `wireframeMat` | `colorr/g/b`, `width` | Wireframe rendering. |
| Depth MAT | `depthMat` | `near`, `far` | Render depth buffer as grayscale. |
## COMPs — Component Operators (Gray)
Containers, 3D scene elements, UI components.
### 3D Scene
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Geometry COMP | `geometryComp` | `material` (path), `instancechop` (path), `instancing` (toggle) | Renders geometry with material. Instancing host. |
| Camera COMP | `cameraComp` | `tx/ty/tz`, `rx/ry/rz`, `fov`, `near/far` | Camera for Render TOP. |
| Light COMP | `lightComp` | `lighttype` (0=Point, 1=Directional, 2=Spot, 3=Cone), `dimmer`, `colorr/g/b` | Lighting for 3D scenes. |
| Ambient Light COMP | `ambientlightComp` | `dimmer`, `colorr/g/b` | Ambient lighting. |
| Environment Light COMP | `envlightComp` | `envmap` | Image-based lighting (IBL). |
### Containers
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Container COMP | `containerComp` | `w`, `h`, `bgcolor1/2/3` | UI container. Holds other COMPs for panel layouts. |
| Base COMP | `baseComp` | (none significant) | Generic container. Networks-inside-networks. |
| Replicator COMP | `replicatorComp` | `template`, `operatorsdat` | Clone a template operator N times from a table. |
### Utilities
| Operator | Type Name | Key Parameters | Use |
|----------|-----------|---------------|-----|
| Window COMP | `windowComp` | `winw/h`, `winoffsetx/y`, `monitor`, `borders` | Output window for display/projection. |
| Select COMP | `selectComp` | `rowcol`, `panel` | Select and display content from elsewhere. |
| Engine COMP | `engineComp` | `tox`, `externaltox` | Load external .tox components. Sub-process isolation. |
## Cross-Family Converter Summary
| From | To | Operator | Type Name |
|------|-----|----------|-----------|
| CHOP | TOP | CHOP to TOP | `choptopTop` |
| TOP | CHOP | TOP to CHOP | `topchopChop` |
| DAT | CHOP | DAT to CHOP | `dattochopChop` |
| CHOP | DAT | CHOP to DAT | `choptodatDat` |
| SOP | CHOP | SOP to CHOP | `soptochopChop` |
| CHOP | SOP | CHOP to SOP | `choptosopSop` |
| SOP | DAT | SOP to DAT | `soptodatDat` |
| DAT | SOP | DAT to SOP | `dattosopSop` |
| SOP | TOP | (use Render TOP + Geometry COMP) | — |
| TOP | SOP | TOP to SOP | `toptosopSop` |
@@ -0,0 +1,508 @@
# TouchDesigner MCP — Pitfalls & Lessons Learned
Hard-won knowledge from real TD sessions. Read this before building anything.
## Parameter Names
### 1. NEVER hardcode parameter names — always discover
Parameter names change between TD versions. What works in one build may not work in another. ALWAYS use td_get_par_info to discover actual names from TD.
The agent's LLM training data contains WRONG parameter names. Do not trust them.
Known historical differences (may vary further — always verify):
| What docs/training say | Actual in some versions | Notes |
|---------------|---------------|-------|
| `dat` | `pixeldat` | GLSL TOP pixel shader DAT |
| `colora` | `alpha` | Constant TOP alpha |
| `sizex` / `sizey` | `size` | Blur TOP (single value) |
| `fontr/g/b/a` | `fontcolorr/g/b/a` | Text TOP font color (r/g/b) |
| `fontcolora` | `fontalpha` | Text TOP font alpha (NOT `fontcolora`) |
| `bgcolora` | `bgalpha` | Text TOP bg alpha |
| `value1name` | `vec0name` | GLSL TOP uniform name |
### 2. twozero td_execute_python response format
When calling `td_execute_python` via twozero MCP, successful responses return `(ok)` followed by FPS/error summary (e.g. `[fps 60.0/60] [0 err/0 warn]`), NOT the raw Python `result` dict. If you're parsing responses programmatically, check for the `(ok)` prefix — don't pattern-match on Python variable names from the script. Use `td_get_operator_info` or separate inspection calls to read back values.
### 3. When using td_set_operator_pars, param names must match exactly
Use td_get_par_info to discover them. The MCP tool validates parameter names and returns clear errors explaining what went wrong, unlike raw Python which crashes the whole script with tdAttributeError and stops execution. Always discover before setting.
### 4. Use `safe_par()` pattern for cross-version compatibility
```python
def safe_par(node, name, value):
p = getattr(node.par, name, None)
if p is not None:
p.val = value
return True
return False
```
### 5. `td.tdAttributeError` crashes the whole script — use defensive access
If you do `node.par.nonexistent = value`, TD raises `tdAttributeError` and stops the entire script. Prevention is better than catching:
- Use `op()` instead of `opex()``op()` returns None on failure, `opex()` raises
- Use `hasattr(node.par, 'name')` before accessing any parameter
- Use `getattr(node.par, 'name', None)` with a default
- Use the `safe_par()` pattern from pitfall #3
```python
# WRONG — crashes if param doesn't exist:
node.par.nonexistent = value
# CORRECT — defensive access:
if hasattr(node.par, 'nonexistent'):
node.par.nonexistent = value
```
### 6. `outputresolution` is a string menu, not an integer
```
menuNames: ['useinput','eighth','quarter','half','2x','4x','8x','fit','limit','custom','parpanel']
```
Always use the string form. Setting `outputresolution = 9` may silently fail.
```python
node.par.outputresolution = 'custom' # correct
node.par.resolutionw = 1280; node.par.resolutionh = 720
```
Discover valid values: `list(node.par.outputresolution.menuNames)`
## GLSL Shaders
### 7. `uTDCurrentTime` does NOT exist in GLSL TOP
There is NO built-in time uniform for GLSL TOPs. GLSL MAT has `uTDGeneral.seconds` but that's NOT available in GLSL TOP context.
**PRIMARY — GLSL TOP Vectors/Values page:**
```python
gl.par.value0name = 'uTime'
gl.par.value0.expr = "absTime.seconds"
# In GLSL: uniform float uTime;
```
**FALLBACK — Constant TOP texture (for complex time data):**
CRITICAL: set format to `rgba32float` — default 8-bit clamps to 0-1:
```python
t = root.create(constantTOP, 'time_driver')
t.par.format = 'rgba32float'
t.par.outputresolution = 'custom'
t.par.resolutionw = 1; t.par.resolutionh = 1
t.par.colorr.expr = "absTime.seconds % 1000.0"
t.outputConnectors[0].connect(glsl.inputConnectors[0])
```
### 8. GLSL compile errors are silent in the API
The GLSL TOP shows a yellow warning triangle in the UI but `node.errors()` may return empty string. Check `node.warnings()` too, and create an Info DAT pointed at the GLSL TOP to read the actual compiler output.
### 9. TD GLSL uses `vUV.st` not `gl_FragCoord` — and REQUIRES `TDOutputSwizzle()` on macOS
Standard GLSL patterns don't work. TD provides:
- `vUV.st` — UV coordinates (0-1)
- `uTDOutputInfo.res.zw` — resolution
- `sTD2DInputs[0]` — input textures
- `layout(location = 0) out vec4 fragColor` — output
CRITICAL on macOS: Always wrap output with `TDOutputSwizzle()`:
```glsl
fragColor = TDOutputSwizzle(color);
```
TD uses GLSL 4.60 (Vulkan backend). GLSL 3.30 and earlier removed.
### 10. Large GLSL shaders — write to temp file
GLSL code with special characters can corrupt JSON payloads. Write the shader to a temp file and load it in TD:
```python
# Agent side: write shader to /tmp/shader.glsl via write_file
# TD side:
sd = root.create(textDAT, 'shader_code')
with open('/tmp/shader.glsl', 'r') as f:
sd.text = f.read()
```
## Node Management
### 11. Destroying nodes while iterating `root.children` causes `tdError`
The iterator is invalidated when a child is destroyed. Always snapshot first:
```python
kids = list(root.children) # snapshot
for child in kids:
if child.valid: # check — earlier destroys may cascade
child.destroy()
```
### 11b. Split cleanup and creation into SEPARATE td_execute_python calls
Creating nodes with the same names you just destroyed in the SAME script causes "Invalid OP object" errors — even with `list()` snapshot. TD's internal references can go stale within one execution context.
**WRONG (single call):**
```python
# td_execute_python:
for c in list(root.children):
if c.valid and c.name.startswith('promo_'):
c.destroy()
# ... then create promo_audio, promo_shader etc. in same script → CRASHES
```
**CORRECT (two separate calls):**
```python
# Call 1: td_execute_python — clean only
for c in list(root.children):
if c.valid and c.name.startswith('promo_'):
c.destroy()
# Call 2: td_execute_python — build (separate MCP call)
audio = root.create(audiofileinCHOP, 'promo_audio')
# ... rest of build
```
### 12. Feedback TOP: use `top` parameter, NOT direct input wire
The feedbackTOP's `top` parameter references which TOP to delay. Do NOT also wire that TOP directly into the feedback's input — this creates a real cook dependency loop.
Correct setup:
```python
fb = root.create(feedbackTOP, 'fb_delay')
fb.par.top = comp.path # reference only — no wire to fb input
fb.outputConnectors[0].connect(xf) # fb output -> transform -> fade -> comp
```
The "Cook dependency loop detected" warning on the transform/fade chain is expected.
### 13. GLSL TOP auto-creates companion nodes
Creating a `glslTOP` also creates `name_pixel` (Text DAT), `name_info` (Info DAT), and `name_compute` (Text DAT). These are visible in the network. Don't be alarmed by "extra" nodes.
### 14. The default project root is `/project1`
New TD files start with `/project1` as the main container. System nodes live at `/`, `/ui`, `/sys`, `/local`, `/perform`. Don't create user nodes outside `/project1`.
### 15. Non-Commercial license caps resolution at 1280x1280
Setting `resolutionw=1920` silently clamps to 1280. Always check effective resolution after creation:
```python
n.cook(force=True)
actual = str(n.width) + 'x' + str(n.height)
```
## Recording & Codecs
### 16. MovieFileOut TOP: H.264/H.265/AV1 requires Commercial license
In Non-Commercial TD, these codecs produce an error. Recommended alternatives:
- `prores` — Apple ProRes, **best on macOS**, HW accelerated, NOT license-restricted. ~55MB/s at 1280x720 but lossless quality. **Use this as default on macOS.**
- `cineform` — GoPro Cineform, supports alpha
- `hap` — GPU-accelerated playback, large files
- `notchlc` — GPU-accelerated, good quality
- `mjpa` — Motion JPEG, legacy fallback (lossy, use only if ProRes unavailable)
For image sequences: `rec.par.type = 'imagesequence'`, `rec.par.imagefiletype = 'png'`
### 17. MovieFileOut `.record()` method may not exist
Use the toggle parameter instead:
```python
rec.par.record = True # start recording
rec.par.record = False # stop recording
```
When setting file path and starting recording in the same script, use delayFrames:
```python
rec.par.file = '/tmp/new_output.mov'
run("op('/project1/recorder').par.record = True", delayFrames=2)
```
### 18. TOP.save() captures same frame when called rapidly
Use MovieFileOut for real-time recording. Set `project.realTime = False` for frame-accurate output.
### 19. AudioFileIn CHOP: cue and recording sequence matters
The recording sequence must be done in exact order, or the recording will be empty, audio will start mid-file, or the file won't be written.
**Proven recording sequence:**
```python
# Step 1: Stop any existing recording
rec.par.record = False
# Step 2: Reset audio to beginning
audio.par.play = False
audio.par.cue = True
audio.par.cuepoint = 0 # may need cuepointunit=0 too
# Verify: audio.par.cue.eval() should be True
# Step 3: Set output file path
rec.par.file = '/tmp/output.mov'
# Step 4: Release cue + start playing + start recording (with frame delay)
audio.par.cue = False
audio.par.play = True
audio.par.playmode = 2 # Sequential — plays once through
run("op('/project1/recorder').par.record = True", delayFrames=3)
```
**Why each step matters:**
- `rec.par.record = False` first — if a previous recording is active, setting `par.file` may fail silently
- `audio.par.cue = True` + `cuepoint = 0` — guarantees audio starts from the beginning, otherwise the spectrum may be silent for the first few seconds
- `delayFrames=3` on the record start — setting `par.file` and `par.record = True` in the same script can race; the file path needs a frame to register before recording starts
- `playmode = 2` (Sequential) — plays the file once. Use `playmode = 0` (Locked to Timeline) if you want TD's timeline to control position
## TD Python API Patterns
### 20. COMP extension setup: ext0object format is CRITICAL
`ext0object` expects a CONSTANT string (NOT expression mode):
```python
comp.par.ext0object = "op('./myExtensionDat').module.MyClassName(me)"
```
NEVER set as just the DAT name. NEVER use ParMode.EXPRESSION. ALWAYS ensure the DAT has `par.language='python'`.
### 21. td.Panel is NOT subscriptable — use attribute access
```python
comp.panel.select # correct (attribute access, returns float)
comp.panel['select'] # WRONG — 'td.Panel' object is not subscriptable
```
### 22. ALWAYS use relative paths in script callbacks
In scriptTOP/CHOP/SOP/DAT callbacks, use paths relative to `scriptOp` or `me`:
```python
root = scriptOp.parent().parent()
dat = root.op('pixel_data')
```
NEVER hardcode absolute paths like `op('/project1/myComp/child')` — they break when containers are renamed or copied.
### 23. keyboardinCHOP channel names have 'k' prefix
Channel names are `kup`, `kdown`, `kleft`, `kright`, `ka`, `kb`, etc. — NOT `up`, `down`, `a`, `b`. Always verify with:
```python
channels = [c.name for c in op('/project1/keyboard1').chans()]
```
### 24. expressCHOP cook-only properties — false positive errors
`me.inputVal`, `me.chanIndex`, `me.sampleIndex` work ONLY in cook-context. Calling `par.expr0expr.eval()` from outside always raises an error — this is NOT a real operator error. Ignore these in error scans.
### 25. td.Vertex attributes — use index access not named attributes
In TD 2025.32, `td.Vertex` objects do NOT have `.x`, `.y`, `.z` attributes:
```python
# WRONG — crashes:
vertex.x, vertex.y, vertex.z
# CORRECT — index-based:
vertex.point.P[0], vertex.point.P[1], vertex.point.P[2]
# Or for SOP point positions:
pt = sop.points()[i]
pos = pt.P # use P[0], P[1], P[2]
```
## Audio
### 26. Audio Spectrum CHOP output is weak — boost it
Raw output is very small (0.001-0.05). Use built-in boost: `spectrum.par.highfrequencyboost = 3.0`
If still weak, add Math CHOP in Range mode: `fromrangehi=0.05, torangehi=1.0`
### 27. AudioSpectrum CHOP: timeslice and sample count are the #1 gotcha
AudioSpectrum at 44100Hz with `timeslice=False` outputs the ENTIRE audio file as samples (~24000+). CHOP-to-TOP then exceeds texture resolution max and warns/fails.
**Fix:** Keep `timeslice = True` (default) for real-time per-frame FFT. Set `fftsize` to control bin count (it's a STRING enum: `'256'` not `256`).
If the CHOP-to-TOP still gets too many samples, set `layout = 'rowscropped'` on the choptoTOP.
```python
spectrum.par.fftsize = '256' # STRING, not int — enum values
spectrum.par.timeslice = True # MUST be True for real-time audio reactivity
spectex.par.layout = 'rowscropped' # handles oversized CHOP inputs
```
**resampleCHOP has NO `numsamples` param.** It uses `rate`, `start`, `end`, `method`. Don't guess — always `td_get_par_info('resampleCHOP')` first.
### 28. CHOP To TOP has NO input connectors — use par.chop reference
```python
spec_tex = root.create(choptoTOP, 'spectrum_tex')
spec_tex.par.chop = resample # correct: parameter reference
# NOT: resample.outputConnectors[0].connect(spec_tex.inputConnectors[0]) # WRONG
```
## Workflow
### 29. Always verify after building — errors are silent
Node errors and broken connections produce no output. Always check:
```python
for c in list(root.children):
e = c.errors()
w = c.warnings()
if e: print(c.name, 'ERR:', e)
if w: print(c.name, 'WARN:', w)
```
### 30. Window COMP param for display target is `winop`
```python
win = root.create(windowCOMP, 'display')
win.par.winop = '/project1/logo_out'
win.par.winw = 1280; win.par.winh = 720
win.par.winopen.pulse()
```
### 31. `sample()` returns frozen pixels in rapid calls
`out.sample(x, y)` returns pixels from a single cook snapshot. Compare samples with 2+ second delays, or use screencapture on the display window.
### 32. Audio-reactive GLSL: dual-layer sync pipeline
For audio-synced visuals, use BOTH layers for maximum effect:
**Layer 1 (TD-side, real-time):** AudioFileIn → AudioSpectrum(timeslice=True, fftsize='256') → Math(gain=5) → choptoTOP(par.chop=math, layout='rowscropped') → GLSL input. The shader samples `sTD2DInputs[1]` at different x positions for bass/mid/hi. Record the TD output with MovieFileOut.
**Layer 2 (Python-side, post-hoc):** scipy FFT on the SAME audio file → per-frame features (rms, bass, mid, hi, beat detection) → drive ASCII brightness, chromatic aberration, beat flashes during the render pass.
Both layers locked to the same audio file = visuals genuinely sync to the beat at two independent stages.
**Key gotcha:** AudioFileIn must be cued (`par.cue=True``par.cuepulse.pulse()`) then uncued (`par.cue=False`, `par.play=True`) before recording starts. Otherwise the spectrum is silent for the first few seconds.
### 33. twozero MCP: benchmark and prefer native tools
Benchmarked April 2026: twozero MCP with 36 native tools. The old curl/REST method (port 9981) had zero native tools.
**Always prefer native MCP tools over td_execute_python:**
- `td_create_operator` over `root.create()` scripts (handles viewport positioning)
- `td_set_operator_pars` over `node.par.X = Y` scripts (validates param names)
- `td_get_par_info` over temp-node discovery dance (instant, no cleanup)
- `td_get_errors` over manual `c.errors()` loops
- `td_get_focus` for context awareness (no equivalent in old method)
Only fall back to `td_execute_python` for multi-step logic (wiring chains, conditional builds, loops).
### 34. twozero td_execute_python response wrapping
twozero wraps `td_execute_python` responses with status info: `(ok)\n\n[fps 60.0/60] [0 err/0 warn]`. Your Python `result` variable value may not appear verbatim in the response text. If you need to check results programmatically, use `print()` statements in the script — they appear in the response. Don't rely on string-matching the `result` dict.
### 35. Audio-reactive chain: DO NOT use Lag CHOP or Filter CHOP for spectrum smoothing
The Derivative docs and tutorials suggest using Lag CHOP (lag1=0.2, lag2=0.5) to smooth raw FFT output before passing to a shader. **This does NOT work with AudioSpectrum → CHOP to TOP → GLSL.**
What happens: Lag CHOP operates in timeslice mode. A 256-sample spectrum input gets expanded to 1600-2400 samples. The Lag averaging drives all values to near-zero (~1e-06). The CHOP to TOP produces a 2400x2 texture instead of 256x2. The shader receives effectively zero audio data.
**The correct chain is: Spectrum(outlength=256) → Math(gain=10) → CHOPtoTOP → GLSL.** No CHOP smoothing at all. If you need smoothing, do it in the GLSL shader via temporal lerp with a feedback texture.
Verified values with audio playing:
- Without Lag CHOP: bass bins = 5.0-5.4, mid bins = 1.0-1.7 (strong, usable)
- With Lag CHOP: ALL bins = 0.000001-0.00004 (dead, zero audio reactivity)
### 36. AudioSpectrum Output Length: set manually to avoid CHOP to TOP overflow
AudioSpectrum in Visualization mode with FFT 8192 outputs 22,050 samples by default (1 per Hz, 022050). CHOP to TOP cannot handle this — you get "Number of samples exceeded texture resolution max".
Fix: `spectrum.par.outputmenu = 'setmanually'` and `spectrum.par.outlength = 256`. This gives 256 frequency bins — plenty for visual FFT.
DO NOT set `timeslice = False` as a workaround — that processes the entire audio file at once and produces even more samples.
### 37. GLSL spectrum texture from CHOP to TOP is 256x2 not 256x1
AudioSpectrum outputs 2 channels (stereo: chan1, chan2). CHOP to TOP with `dataformat='r'` creates a 256x2 texture — one row per channel. Sample the first channel at `y=0.25` (center of first row), NOT `y=0.5` (boundary between rows):
```glsl
float bass = texture(sTD2DInputs[1], vec2(0.05, 0.25)).r; // correct
float bass = texture(sTD2DInputs[1], vec2(0.05, 0.5)).r; // WRONG — samples between rows
```
### 38. FPS=0 doesn't mean ops aren't cooking — check play state
TD can show `fps:0` in `td_get_perf` while ops still cook and `TOP.save()` still produces valid screenshots. The two most common causes:
**a) Project is paused (playbar stopped).** TD's playbar can be toggled with spacebar. The `root` at `/` has no `.playbar` attribute (it's on the perform COMP). The easiest fix is sending a spacebar keypress via `td_input_execute`, though this tool can sometimes error. As a workaround, `TOP.save()` always works regardless of play state — use it to verify rendering is actually happening before spending time debugging FPS.
**b) Audio device CHOP blocking the main thread.** An `audiooutCHOP` with an active audio device can consume 300-400ms/s (2000%+ of frame budget), stalling the cook loop at FPS=0. Fix: keep the CHOP active but set `volume=0` to prevent the audio driver from blocking. Disabling it entirely (`active=False`) may also work but can prevent downstream audio processing CHOPs from cooking.
Diagnostic sequence when FPS=0:
1. `td_get_perf` — check if any op has extreme CPU/s
2. `TOP.save()` on the output — if it produces a valid image, the pipeline works, just not at real-time rate
3. Check for blocking CHOPs (audioout, audiodevin, etc.)
4. Toggle play state (spacebar, or check if absTime.seconds is advancing)
### 39. Recording while FPS=0 produces empty or near-empty files
This is the #1 cause of "I recorded for 30 seconds but got a 2-frame video." If TD's cook loop is stalled (FPS=0 or very low), MovieFileOut has nothing to record. Unlike `TOP.save()` which captures the last cooked frame regardless, MovieFileOut only writes frames that actually cook.
**Always verify FPS before starting a recording:**
```python
# Check via td_get_perf first
# If FPS < 30, do NOT start recording — fix the performance issue first
# If FPS=0, the playbar is likely paused — see pitfall #37
```
Common causes of recording empty video:
- Playbar paused (FPS=0) — see pitfall #37
- Audio device CHOP blocking the main thread — see pitfall #37b
- Recording started before audio was cued — audio is silent, GLSL outputs black, MovieFileOut records black frames that look empty
- `par.file` set in the same script as `par.record = True` — see pitfall #18
### 40. GLSL shader produces black output — test before committing to a long render
New GLSL shaders can fail silently (see pitfall #7). Before recording a long take, always:
1. **Write a minimal test shader first** that just outputs a solid color or pass-through:
```glsl
void main() {
vec2 uv = vUV.st;
fragColor = TDOutputSwizzle(vec4(uv, 0.0, 1.0));
}
```
2. **Verify the test renders correctly** via `td_get_screenshot` on the GLSL TOP's output.
3. **Swap in the real shader** and screenshot again immediately. If black, the shader has a compile error or logic issue.
4. **Only then start recording.** A 90-second ProRes recording is ~5GB. Recording black frames wastes disk and time.
Common causes of black GLSL output:
- Missing `TDOutputSwizzle()` on macOS (pitfall #8)
- Time uniform not connected — shader uses default 0.0, fractal stays at origin
- Spectrum texture not connected — audio values all 0.0, driving everything to black
- Integer division where float division was expected (`1/2 = 0` not `0.5`)
- `absTime.seconds % 1000.0` rolled over past 1000 and the modulo produces unexpected values
### 41. td_write_dat uses `text` parameter, NOT `content`
The MCP tool `td_write_dat` expects a `text` parameter for full replacement. Passing `content` returns an error: `"Provide either 'text' for full replace, or 'old_text'+'new_text' for patching"`.
If `td_write_dat` fails, fall back to `td_execute_python`:
```python
op("/project1/shader_code").text = shader_string
```
### 42. td_execute_python does NOT return stdout or print() output
Despite what earlier versions of pitfall #33 stated, `print()` and `debug()` output from `td_execute_python` scripts does NOT appear in the MCP response. The response is always just `(ok)` + FPS/error summary. To read values back, use dedicated inspection tools (`td_get_operator_info`, `td_read_dat`, `td_read_chop`) instead of trying to print from within a script.
### 43. td_get_operator_info JSON is appended with `[fps X.X/X]` — breaks json.loads()
The response text from `td_get_operator_info` has `[fps 60.0/60]` appended after the JSON object. This causes `json.loads()` to fail with "Extra data" errors. Strip it before parsing:
```python
clean = response_text.rsplit('[fps', 1)[0]
data = json.loads(clean)
```
### 44. td_get_screenshot is asynchronous — returns `{"status": "pending"}`
Screenshots don't complete instantly. The tool returns `{"status": "pending", "requestId": "..."}` and the actual file appears later. Wait a few seconds before checking for the file. There is no callback or completion notification — poll the filesystem.
### 45. Recording duration is manual — no auto-stop at audio end
MovieFileOut records until `par.record = False` is set. If audio ends before you stop recording, the file keeps growing with repeated frames. Always stop recording promptly after the audio duration. For precision: set a timer on the agent side matching the audio length, then send `par.record = False`. Trim excess with ffmpeg as a safety net:
```bash
ffmpeg -i raw.mov -t 25 -c copy trimmed.mov
```
@@ -0,0 +1,463 @@
# TouchDesigner Python API Reference
## The td Module
TouchDesigner's Python environment auto-imports the `td` module. All TD-specific classes, functions, and constants live here. Scripts inside TD (Script DATs, CHOP/DAT Execute callbacks, Extensions) have full access.
When using the MCP `execute_python_script` tool, these globals are pre-loaded:
- `op` — shortcut for `td.op()`, finds operators by path
- `ops` — shortcut for `td.ops()`, finds multiple operators by pattern
- `me` — the operator running the script (via MCP this is the twozero internal executor)
- `parent` — shortcut for `me.parent()`
- `project` — the root project component
- `td` — the full td module
## Finding Operators: op() and ops()
### op(path) — Find a single operator
```python
# Absolute path (always works from MCP)
node = op('/project1/noise1')
# Relative path (relative to current operator — only in Script DATs)
node = op('noise1') # sibling
node = op('../noise1') # parent's sibling
# Returns None if not found (does NOT raise)
node = op('/project1/nonexistent') # None
```
### ops(pattern) — Find multiple operators
```python
# Glob patterns
nodes = ops('/project1/noise*') # all nodes starting with "noise"
nodes = ops('/project1/*') # all direct children
nodes = ops('/project1/container1/*') # all children of container1
# Returns a tuple of operators (may be empty)
for n in ops('/project1/*'):
print(n.name, n.OPType)
```
### Navigation from a node
```python
node = op('/project1/noise1')
node.name # 'noise1'
node.path # '/project1/noise1'
node.OPType # 'noiseTop'
node.type # <class 'noiseTop'>
node.family # 'TOP'
# Parent / children
node.parent() # the parent COMP
node.parent().children # all siblings + self
node.parent().findChildren(name='noise*') # filtered
# Type checking
node.isTOP # True
node.isCHOP # False
node.isSOP # False
node.isDAT # False
node.isMAT # False
node.isCOMP # False
```
## Parameters
Every operator has parameters accessed via the `.par` attribute.
### Reading parameters
```python
node = op('/project1/noise1')
# Direct access
node.par.seed.val # current evaluated value (may be an expression result)
node.par.seed.eval() # same as .val
node.par.seed.default # default value
node.par.monochrome.val # boolean parameters: True/False
# List all parameters
for p in node.pars():
print(f"{p.name}: {p.val} (default: {p.default})")
# Filter by page (parameter group)
for p in node.pars('Noise'): # page name
print(f"{p.name}: {p.val}")
```
### Setting parameters
```python
# Direct value setting
node.par.seed.val = 42
node.par.monochrome.val = True
node.par.resolutionw.val = 1920
node.par.resolutionh.val = 1080
# String parameters
op('/project1/text1').par.text.val = 'Hello World'
# File paths
op('/project1/moviefilein1').par.file.val = '/path/to/video.mp4'
# Reference another operator (for "dat", "chop", "top" type parameters)
op('/project1/glsl1').par.dat.val = '/project1/shader_code'
```
### Parameter expressions
```python
# Python expressions that evaluate dynamically
node.par.seed.expr = "me.time.frame"
node.par.tx.expr = "math.sin(me.time.seconds * 2)"
# Reference another parameter
node.par.brightness1.expr = "op('/project1/constant1').par.value0.val"
# Export (one-way binding from CHOP to parameter)
# This makes the parameter follow a CHOP channel value
op('/project1/noise1').par.seed.val # can also be driven by exports
```
### Parameter types
| Type | Python Type | Example |
|------|------------|---------|
| Float | `float` | `node.par.brightness1.val = 0.5` |
| Int | `int` | `node.par.seed.val = 42` |
| Toggle | `bool` | `node.par.monochrome.val = True` |
| String | `str` | `node.par.text.val = 'hello'` |
| Menu | `int` (index) or `str` (label) | `node.par.type.val = 'sine'` |
| File | `str` (path) | `node.par.file.val = '/path/to/file'` |
| OP reference | `str` (path) | `node.par.dat.val = '/project1/text1'` |
| Color | separate r/g/b/a floats | `node.par.colorr.val = 1.0` |
| XY/XYZ | separate x/y/z floats | `node.par.tx.val = 0.5` |
## Creating and Deleting Operators
```python
# Create via parent component
parent = op('/project1')
new_node = parent.create(noiseTop) # using class reference
new_node = parent.create(noiseTop, 'my_noise') # with custom name
# The MCP create_td_node tool handles this automatically:
# create_td_node(parentPath="/project1", nodeType="noiseTop", nodeName="my_noise")
# Delete
node = op('/project1/my_noise')
node.destroy()
# Copy
original = op('/project1/noise1')
copy = parent.copy(original, name='noise1_copy')
```
## Connections (Wiring Operators)
### Output to Input connections
```python
# Connect noise1's output to level1's input
op('/project1/noise1').outputConnectors[0].connect(op('/project1/level1'))
# Connect to specific input index (for multi-input operators like Composite)
op('/project1/noise1').outputConnectors[0].connect(op('/project1/composite1').inputConnectors[0])
op('/project1/text1').outputConnectors[0].connect(op('/project1/composite1').inputConnectors[1])
# Disconnect all outputs
op('/project1/noise1').outputConnectors[0].disconnect()
# Query connections
node = op('/project1/level1')
inputs = node.inputs # list of connected input operators
outputs = node.outputs # list of connected output operators
```
### Connection patterns for common setups
```python
# Linear chain: A -> B -> C -> D
ops_list = [op(f'/project1/{name}') for name in ['noise1', 'level1', 'blur1', 'null1']]
for i in range(len(ops_list) - 1):
ops_list[i].outputConnectors[0].connect(ops_list[i+1])
# Fan-out: A -> B, A -> C, A -> D
source = op('/project1/noise1')
for target_name in ['level1', 'composite1', 'transform1']:
source.outputConnectors[0].connect(op(f'/project1/{target_name}'))
# Merge: A + B + C -> Composite
comp = op('/project1/composite1')
for i, source_name in enumerate(['noise1', 'text1', 'ramp1']):
op(f'/project1/{source_name}').outputConnectors[0].connect(comp.inputConnectors[i])
```
## DAT Content Manipulation
### Text DATs
```python
dat = op('/project1/text1')
# Read
content = dat.text # full text as string
# Write
dat.text = "new content"
dat.text = '''multi
line
content'''
# Append
dat.text += "\nnew line"
```
### Table DATs
```python
dat = op('/project1/table1')
# Read cell
val = dat[0, 0] # row 0, col 0
val = dat[0, 'name'] # row 0, column named 'name'
val = dat['key', 1] # row named 'key', col 1
# Write cell
dat[0, 0] = 'value'
# Read row/col
row = dat.row(0) # list of Cell objects
col = dat.col('name') # list of Cell objects
# Dimensions
rows = dat.numRows
cols = dat.numCols
# Append row
dat.appendRow(['col1_val', 'col2_val', 'col3_val'])
# Clear
dat.clear()
# Set entire table
dat.clear()
dat.appendRow(['name', 'value', 'type'])
dat.appendRow(['frequency', '440', 'float'])
dat.appendRow(['amplitude', '0.8', 'float'])
```
## Time and Animation
```python
# Global time
td.absTime.frame # absolute frame number (never resets)
td.absTime.seconds # absolute seconds
# Timeline time (affected by play/pause/loop)
me.time.frame # current frame on timeline
me.time.seconds # current seconds on timeline
me.time.rate # FPS setting
# Timeline control (via execute_python_script)
project.play = True
project.play = False
project.frameRange = (1, 300) # set timeline range
# Cook frame (when operator was last computed)
node.cookFrame
node.cookTime
```
## Extensions (Custom Python Classes on Components)
Extensions add custom Python methods and attributes to COMPs.
```python
# Create extension on a Base COMP
base = op('/project1/myBase')
# The extension class is defined in a Text DAT inside the COMP
# Typically named 'ExtClass' with the extension code:
extension_code = '''
class MyExtension:
def __init__(self, ownerComp):
self.ownerComp = ownerComp
self.counter = 0
def Reset(self):
self.counter = 0
def Increment(self):
self.counter += 1
return self.counter
@property
def Count(self):
return self.counter
'''
# Write extension code to DAT inside the COMP
op('/project1/myBase/extClass').text = extension_code
# Configure the extension on the COMP
base.par.extension1 = 'extClass' # name of the DAT
base.par.promoteextension1 = True # promote methods to parent
# Call extension methods
base.Increment() # calls MyExtension.Increment()
count = base.Count # accesses MyExtension.Count property
base.Reset()
```
## Useful Built-in Modules
### tdu — TouchDesigner Utilities
```python
import tdu
# Dependency tracking (reactive values)
dep = tdu.Dependency(initial_value)
dep.val = new_value # triggers dependents to recook
# File path utilities
tdu.expandPath('$HOME/Desktop/output.mov')
# Math
tdu.clamp(value, min, max)
tdu.remap(value, from_min, from_max, to_min, to_max)
```
### TDFunctions
```python
from TDFunctions import *
# Commonly used utilities
clamp(value, low, high)
remap(value, inLow, inHigh, outLow, outHigh)
interp(value1, value2, t) # linear interpolation
```
### TDStoreTools — Persistent Storage
```python
from TDStoreTools import StorageManager
# Store data that survives project reload
me.store('myKey', 'myValue')
val = me.fetch('myKey', default='fallback')
# Storage dict
me.storage['key'] = value
```
## Common Patterns via execute_python_script
### Build a complete chain
```python
# Create a complete audio-reactive noise chain
parent = op('/project1')
# Create operators
audio_in = parent.create(audiofileinChop, 'audio_in')
spectrum = parent.create(audiospectrumChop, 'spectrum')
chop_to_top = parent.create(choptopTop, 'chop_to_top')
noise = parent.create(noiseTop, 'noise1')
level = parent.create(levelTop, 'level1')
null_out = parent.create(nullTop, 'out')
# Wire the chain
audio_in.outputConnectors[0].connect(spectrum)
spectrum.outputConnectors[0].connect(chop_to_top)
noise.outputConnectors[0].connect(level)
level.outputConnectors[0].connect(null_out)
# Set parameters
audio_in.par.file = '/path/to/music.wav'
audio_in.par.play = True
spectrum.par.size = 512
noise.par.type = 1 # Sparse
noise.par.monochrome = False
noise.par.resolutionw = 1920
noise.par.resolutionh = 1080
level.par.opacity = 0.8
level.par.gamma1 = 0.7
```
### Query network state
```python
# Get all TOPs in the project
tops = [c for c in op('/project1').findChildren(type=TOP)]
for t in tops:
print(f"{t.path}: {t.OPType} {'ERROR' if t.errors() else 'OK'}")
# Find all operators with errors
def find_errors(parent_path='/project1'):
parent = op(parent_path)
errors = []
for child in parent.findChildren(depth=-1):
if child.errors():
errors.append((child.path, child.errors()))
return errors
result = find_errors()
```
### Batch parameter changes
```python
# Set parameters on multiple nodes at once
settings = {
'/project1/noise1': {'seed': 42, 'monochrome': False, 'resolutionw': 1920},
'/project1/level1': {'brightness1': 1.2, 'gamma1': 0.8},
'/project1/blur1': {'sizex': 5, 'sizey': 5},
}
for path, params in settings.items():
node = op(path)
if node:
for key, val in params.items():
setattr(node.par, key, val)
```
## Python Version and Packages
TouchDesigner bundles Python 3.11+ with these pre-installed:
- **numpy** — array operations, fast math
- **scipy** — signal processing, FFT
- **OpenCV** (cv2) — computer vision
- **PIL/Pillow** — image processing
- **requests** — HTTP client
- **json**, **re**, **os**, **sys** — standard library
**IMPORTANT:** Parameter names in examples below are illustrative. Always run discovery (SKILL.md Step 0) to get actual names for your TD version. Do NOT copy param names from these examples verbatim.
Custom packages can be installed to TD's Python site-packages directory. See TD documentation for the exact path per platform.
## SOP Vertex/Point Access (TD 2025.32)
In TD 2025.32, `td.Vertex` does NOT have `.x`, `.y`, `.z` attributes. Use index access:
```python
# WRONG — crashes in TD 2025.32:
vertex.x, vertex.y, vertex.z
# CORRECT — index/attribute access:
pt = sop.points()[i]
pos = pt.P # Position object
x, y, z = pos[0], pos[1], pos[2]
# Always introspect first:
dir(sop.points()[0]) # see what attributes actually exist
dir(sop.points()[0].P) # see Position object interface
```
@@ -0,0 +1,244 @@
# TouchDesigner Troubleshooting (twozero MCP)
> See `references/pitfalls.md` for the comprehensive lessons-learned list.
## 1. Connection Issues
### Port 40404 not responding
Check these in order:
1. Is TouchDesigner running?
```bash
pgrep TouchDesigner
```
1b. Quick hub health check (no JSON-RPC needed):
A plain GET to the MCP URL returns instance info:
```
curl -s http://localhost:40404/mcp
```
Returns: `{"hub": true, "pid": ..., "instances": {"127.0.0.1_PID": {"project": "...", "tdVersion": "...", ...}}}`
If this returns JSON but `instances` is empty, TD is running but twozero hasn't registered yet.
2. Is twozero installed in TD?
Open TD Palette Browser > twozero should be listed. If not, install it.
3. Is MCP enabled in twozero settings?
In TD, open twozero preferences and confirm MCP server is toggled ON.
4. Test the port directly:
```bash
nc -z 127.0.0.1 40404
```
5. Test the MCP endpoint:
```bash
curl -s http://localhost:40404/mcp
```
Should return JSON with hub info. If it does, the server is running.
### Hub responds but no TD instances
The twozero MCP hub is running but TD hasn't registered. Causes:
- TD project not loaded yet (still on splash screen)
- twozero COMP not initialized in the current project
- twozero version mismatch
Fix: Open/reload a TD project that contains the twozero COMP. Use td_list_instances
to check which TD instances are registered.
### Multi-instance setup
twozero auto-assigns ports for multiple TD instances:
- First instance: 40404
- Second instance: 40405
- Third instance: 40406
- etc.
Use `td_list_instances` to discover all running instances and their ports.
## 2. MCP Tool Errors
### td_execute_python returns error
The error message from td_execute_python often contains the Python traceback.
If it's unclear, use `td_read_textport` to see the full TD console output —
Python exceptions are always printed there.
Common causes:
- Syntax error in the script
- Referencing a node that doesn't exist (op() returns None, then you call .par on None)
- Using wrong parameter names (see pitfalls.md)
### td_set_operator_pars fails
Parameter name mismatch is the #1 cause. The tool validates param names and
returns clear errors, but you must use exact names.
Fix: ALWAYS call `td_get_par_info` first to discover the real parameter names:
```
td_get_par_info(op_type='glslTOP')
td_get_par_info(op_type='noiseTOP')
```
### td_create_operator type name errors
Operator type names use camelCase with family suffix:
- CORRECT: noiseTOP, glslTOP, levelTOP, compositeTOP, audiospectrumCHOP
- WRONG: NoiseTOP, noise_top, NOISE TOP, Noise
### td_get_operator_info for deep inspection
If unsure about any aspect of an operator (params, inputs, outputs, state):
```
td_get_operator_info(path='/project1/noise1', detail='full')
```
## 3. Parameter Discovery
CRITICAL: ALWAYS use td_get_par_info to discover parameter names.
The agent's LLM training data contains WRONG parameter names for TouchDesigner.
Do not trust them. Known wrong names include dat vs pixeldat, colora vs alpha,
sizex vs size, and many more. See pitfalls.md for the full list.
Workflow:
1. td_get_par_info(op_type='glslTOP') — get all params for a type
2. td_get_operator_info(path='/project1/mynode', detail='full') — get params for a specific instance
3. Use ONLY the names returned by these tools
## 4. Performance
### Diagnosing slow performance
Use `td_get_perf` to see which operators are slow. Look at cook times —
anything over 1ms per frame is worth investigating.
Common causes:
- Resolution too high (especially on Non-Commercial)
- Complex GLSL shaders
- Too many TOP-to-CHOP or CHOP-to-TOP transfers (GPU-CPU memory copies)
- Feedback loops without decay (values accumulate, memory grows)
### Non-Commercial license restrictions
- Resolution cap: 1280x1280. Setting resolutionw=1920 silently clamps to 1280.
- H.264/H.265/AV1 encoding requires Commercial license. Use ProRes or Hap instead.
- No commercial use of output.
Always check effective resolution after creation:
```python
n.cook(force=True)
actual = str(n.width) + 'x' + str(n.height)
```
## 5. Hermes Configuration
### Config location
`$HERMES_HOME/config.yaml` (defaults to `~/.hermes/config.yaml` when `HERMES_HOME` is unset)
### MCP entry format
The twozero TD entry should look like:
```yaml
mcpServers:
twozero_td:
url: http://localhost:40404/mcp
```
### After config changes
Restart the Hermes session for changes to take effect. The MCP connection is
established at session startup.
### Verifying MCP tools are available
After restarting, the session log should show twozero MCP tools registered.
If tools show as registered but aren't callable, check:
- The twozero MCP hub is still running (curl test above)
- TD is still running with a project loaded
- No firewall blocking localhost:40404
## 6. Node Creation Issues
### "Node type not found" error
Wrong type string. Use camelCase with family suffix:
- Wrong: NoiseTop, noise_top, NOISE TOP
- Right: noiseTOP
### Node created but not visible
Check parentPath — use absolute paths like /project1. The default project
root is /project1. System nodes live at /, /ui, /sys, /local, /perform.
Don't create user nodes outside /project1.
### Cannot create node inside a non-COMP
Only COMP operators (Container, Base, Geometry, etc.) can contain children.
You cannot create nodes inside a TOP, CHOP, SOP, DAT, or MAT.
## 7. Wiring Issues
### Cross-family wiring
TOPs connect to TOPs, CHOPs to CHOPs, SOPs to SOPs, DATs to DATs.
Use converter operators to bridge: choptoTOP, topToCHOP, soptoDAT, etc.
Note: choptoTOP has NO input connectors. Use par.chop reference instead:
```python
spec_tex.par.chop = resample_node # correct
# NOT: resample.outputConnectors[0].connect(spec_tex.inputConnectors[0])
```
### Feedback loops
Never create A -> B -> A directly. Use a Feedback TOP:
```python
fb = root.create(feedbackTOP, 'fb')
fb.par.top = comp.path # reference only, no wire to fb input
fb.outputConnectors[0].connect(next_node)
```
"Cook dependency loop detected" warning on the chain is expected and correct.
## 8. GLSL Issues
### Shader compilation errors are silent
GLSL TOP shows a yellow warning in the UI but node.errors() may return empty.
Check node.warnings() too. Create an Info DAT pointed at the GLSL TOP for
full compiler output.
### TD GLSL specifics
- Uses GLSL 4.60 (Vulkan backend). GLSL 3.30 and earlier removed.
- UV coordinates: vUV.st (not gl_FragCoord)
- Input textures: sTD2DInputs[0]
- Output: layout(location = 0) out vec4 fragColor
- macOS CRITICAL: Always wrap output with TDOutputSwizzle(color)
- No built-in time uniform. Pass time via GLSL TOP Values page or Constant TOP.
## 9. Recording Issues
### H.264/H.265/AV1 requires Commercial license
Use Apple ProRes on macOS (hardware accelerated, not license-restricted):
```python
rec.par.videocodec = 'prores' # Preferred on macOS — lossless, Non-Commercial OK
# rec.par.videocodec = 'mjpa' # Fallback — lossy, works everywhere
```
### MovieFileOut has no .record() method
Use the toggle parameter:
```python
rec.par.record = True # start
rec.par.record = False # stop
```
### All exported frames identical
TOP.save() captures same frame when called rapidly. Use MovieFileOut for
real-time recording. Set project.realTime = False for frame-accurate output.
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# setup.sh — Automated setup for twozero MCP plugin for TouchDesigner
# Idempotent: safe to run multiple times.
set -euo pipefail
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
OK="${GREEN}${NC}"; FAIL="${RED}${NC}"; WARN="${YELLOW}${NC}"
TWOZERO_URL="https://www.404zero.com/pisang/twozero.tox"
TOX_PATH="$HOME/Downloads/twozero.tox"
HERMES_HOME_DIR="${HERMES_HOME:-$HOME/.hermes}"
HERMES_CFG="${HERMES_HOME_DIR}/config.yaml"
MCP_PORT=40404
MCP_ENDPOINT="http://localhost:${MCP_PORT}/mcp"
manual_steps=()
echo -e "\n${CYAN}═══ twozero MCP for TouchDesigner — Setup ═══${NC}\n"
# ── 1. Check if TouchDesigner is running ──
# Match on process *name* (not full cmdline) to avoid self-matching shells
# that happen to have "TouchDesigner" in their args. macOS and Linux pgrep
# both support -x for exact name match.
if pgrep -x TouchDesigner >/dev/null 2>&1 || pgrep -x TouchDesignerFTE >/dev/null 2>&1; then
echo -e " ${OK} TouchDesigner is running"
td_running=true
else
echo -e " ${WARN} TouchDesigner is not running"
td_running=false
fi
# ── 2. Ensure twozero.tox exists ──
if [[ -f "$TOX_PATH" ]]; then
echo -e " ${OK} twozero.tox already exists at ${TOX_PATH}"
else
echo -e " ${WARN} twozero.tox not found — downloading..."
if curl -fSL -o "$TOX_PATH" "$TWOZERO_URL" 2>/dev/null; then
echo -e " ${OK} Downloaded twozero.tox to ${TOX_PATH}"
else
echo -e " ${FAIL} Failed to download twozero.tox from ${TWOZERO_URL}"
echo " Please download manually and place at ${TOX_PATH}"
manual_steps+=("Download twozero.tox from ${TWOZERO_URL} to ${TOX_PATH}")
fi
fi
# ── 3. Ensure Hermes config has twozero_td MCP entry ──
if [[ ! -f "$HERMES_CFG" ]]; then
echo -e " ${FAIL} Hermes config not found at ${HERMES_CFG}"
manual_steps+=("Create ${HERMES_CFG} with twozero_td MCP server entry")
elif grep -q 'twozero_td' "$HERMES_CFG" 2>/dev/null; then
echo -e " ${OK} twozero_td MCP entry exists in Hermes config"
else
echo -e " ${WARN} Adding twozero_td MCP entry to Hermes config..."
python3 -c "
import yaml, sys, copy
cfg_path = '$HERMES_CFG'
with open(cfg_path, 'r') as f:
cfg = yaml.safe_load(f) or {}
if 'mcp_servers' not in cfg:
cfg['mcp_servers'] = {}
if 'twozero_td' not in cfg['mcp_servers']:
cfg['mcp_servers']['twozero_td'] = {
'url': '${MCP_ENDPOINT}',
'timeout': 120,
'connect_timeout': 60
}
with open(cfg_path, 'w') as f:
yaml.dump(cfg, f, default_flow_style=False, sort_keys=False)
" 2>/dev/null && echo -e " ${OK} twozero_td MCP entry added to config" \
|| { echo -e " ${FAIL} Could not update config (is PyYAML installed?)"; \
manual_steps+=("Add twozero_td MCP entry to ${HERMES_CFG} manually"); }
manual_steps+=("Restart Hermes session to pick up config change")
fi
# ── 4. Test if MCP port is responding ──
if nc -z 127.0.0.1 "$MCP_PORT" 2>/dev/null; then
echo -e " ${OK} Port ${MCP_PORT} is open"
# ── 5. Verify MCP endpoint responds ──
resp=$(curl -s --max-time 3 "$MCP_ENDPOINT" 2>/dev/null || true)
if [[ -n "$resp" ]]; then
echo -e " ${OK} MCP endpoint responded at ${MCP_ENDPOINT}"
else
echo -e " ${WARN} Port open but MCP endpoint returned empty response"
manual_steps+=("Verify MCP is enabled in twozero settings")
fi
else
echo -e " ${WARN} Port ${MCP_PORT} is not open"
if [[ "$td_running" == true ]]; then
manual_steps+=("In TD: drag twozero.tox into network editor → click Install")
manual_steps+=("Enable MCP: twozero icon → Settings → mcp → 'auto start MCP' → Yes")
else
manual_steps+=("Launch TouchDesigner")
manual_steps+=("Drag twozero.tox into the TD network editor and click Install")
manual_steps+=("Enable MCP: twozero icon → Settings → mcp → 'auto start MCP' → Yes")
fi
fi
# ── Status Report ──
echo -e "\n${CYAN}═══ Status Report ═══${NC}\n"
if [[ ${#manual_steps[@]} -eq 0 ]]; then
echo -e " ${OK} ${GREEN}Fully configured! twozero MCP is ready to use.${NC}\n"
exit 0
else
echo -e " ${WARN} ${YELLOW}Manual steps remaining:${NC}\n"
for i in "${!manual_steps[@]}"; do
echo -e " $((i+1)). ${manual_steps[$i]}"
done
echo ""
exit 1
fi
@@ -7,7 +7,7 @@ license: MIT
metadata:
hermes:
tags: [atropos, rl, environments, training, reinforcement-learning, reward-functions]
related_skills: [axolotl, grpo-rl-training, trl-fine-tuning, lm-evaluation-harness]
related_skills: [axolotl, fine-tuning-with-trl, lm-evaluation-harness]
---
# Hermes Agent Atropos Environments
+252 -53
View File
@@ -19,6 +19,7 @@ import json
import logging
import re
import threading
import time
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
@@ -206,13 +207,19 @@ class HonchoMemoryProvider(MemoryProvider):
self._turn_count = 0
self._injection_frequency = "every-turn" # or "first-turn"
self._context_cadence = 1 # minimum turns between context API calls
self._dialectic_cadence = 3 # minimum turns between dialectic API calls
self._dialectic_cadence = 1 # backwards-compat fallback; wizard writes 2 on new configs
self._dialectic_depth = 1 # how many .chat() calls per dialectic cycle (1-3)
self._dialectic_depth_levels: list[str] | None = None # per-pass reasoning levels
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "medium", "high"
self._reasoning_heuristic: bool = True # scale base level by query length
self._reasoning_level_cap: str = "high" # ceiling for auto-selected level
self._last_context_turn = -999
self._last_dialectic_turn = -999
# Liveness + observability state
self._prefetch_thread_started_at: float = 0.0 # monotonic ts of current thread
self._prefetch_result_fired_at: int = -999 # turn the pending result was fired at
self._dialectic_empty_streak: int = 0 # consecutive empty returns
# Port #1957: lazy session init for tools-only mode
self._session_initialized = False
self._lazy_init_kwargs: Optional[dict] = None
@@ -286,14 +293,6 @@ class HonchoMemoryProvider(MemoryProvider):
logger.debug("Honcho not configured — plugin inactive")
return
# Override peer_name with gateway user_id for per-user memory scoping.
# Only when no explicit peerName was configured — an explicit peerName
# means the user chose their identity; a raw user_id (e.g. Telegram
# chat ID) should not silently replace it.
_gw_user_id = kwargs.get("user_id")
if _gw_user_id and not cfg.peer_name:
cfg.peer_name = _gw_user_id
self._config = cfg
# ----- B1: recall_mode from config -----
@@ -305,12 +304,16 @@ class HonchoMemoryProvider(MemoryProvider):
raw = cfg.raw or {}
self._injection_frequency = raw.get("injectionFrequency", "every-turn")
self._context_cadence = int(raw.get("contextCadence", 1))
self._dialectic_cadence = int(raw.get("dialecticCadence", 3))
# Backwards-compat: unset dialecticCadence falls back to 1
# (every turn) so existing honcho.json configs without the key
# behave as they did before. New setups via `hermes honcho setup`
# get dialecticCadence=2 written explicitly by the wizard.
self._dialectic_cadence = int(raw.get("dialecticCadence", 1))
self._dialectic_depth = max(1, min(cfg.dialectic_depth, 3))
self._dialectic_depth_levels = cfg.dialectic_depth_levels
cap = raw.get("reasoningLevelCap")
if cap and cap in ("minimal", "low", "medium", "high"):
self._reasoning_level_cap = cap
self._reasoning_heuristic = cfg.reasoning_heuristic
if cfg.reasoning_level_cap in self._LEVEL_ORDER:
self._reasoning_level_cap = cfg.reasoning_level_cap
except Exception as e:
logger.debug("Honcho cost-awareness config parse error: %s", e)
@@ -352,6 +355,7 @@ class HonchoMemoryProvider(MemoryProvider):
honcho=client,
config=cfg,
context_tokens=cfg.context_tokens,
runtime_user_peer_name=kwargs.get("user_id") or None,
)
# ----- B3: resolve_session_name -----
@@ -391,14 +395,45 @@ class HonchoMemoryProvider(MemoryProvider):
except Exception as e:
logger.debug("Honcho memory file migration skipped: %s", e)
# ----- B7: Pre-warming context at init -----
# ----- B7: Pre-warming at init -----
# Context prewarm warms peer.context() (base layer), consumed via
# pop_context_result() in prefetch(). Dialectic prewarm runs the
# full configured depth and writes into _prefetch_result so turn 1
# consumes the result directly.
if self._recall_mode in ("context", "hybrid"):
try:
self._manager.prefetch_context(self._session_key)
self._manager.prefetch_dialectic(self._session_key, "What should I know about this user?")
logger.debug("Honcho pre-warm threads started for session: %s", self._session_key)
except Exception as e:
logger.debug("Honcho pre-warm failed: %s", e)
logger.debug("Honcho context prewarm failed: %s", e)
_prewarm_query = (
"Summarize what you know about this user. "
"Focus on preferences, current projects, and working style."
)
def _prewarm_dialectic() -> None:
try:
r = self._run_dialectic_depth(_prewarm_query)
except Exception as exc:
logger.debug("Honcho dialectic prewarm failed: %s", exc)
self._dialectic_empty_streak += 1
return
if r and r.strip():
with self._prefetch_lock:
self._prefetch_result = r
self._prefetch_result_fired_at = 0
# Treat prewarm as turn 0 so cadence gating starts clean.
self._last_dialectic_turn = 0
self._dialectic_empty_streak = 0
else:
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
)
self._prefetch_thread.start()
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
def _ensure_session(self) -> bool:
"""Lazily initialize the Honcho session (for tools-only mode).
@@ -487,7 +522,8 @@ class HonchoMemoryProvider(MemoryProvider):
"# Honcho Memory\n"
"Active (tools-only mode). Use honcho_profile for a quick factual snapshot, "
"honcho_search for raw excerpts, honcho_context for raw peer context, "
"honcho_reasoning for synthesized answers, "
"honcho_reasoning for synthesized answers (pass reasoning_level "
"minimal/low/medium/high/max — you pick the depth per call), "
"honcho_conclude to save facts about the user. "
"No automatic context injection — you must use tools to access memory."
)
@@ -497,7 +533,8 @@ class HonchoMemoryProvider(MemoryProvider):
"Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. "
"Use honcho_profile for a quick factual snapshot, "
"honcho_search for raw excerpts, honcho_context for raw peer context, "
"honcho_reasoning for synthesized answers, "
"honcho_reasoning for synthesized answers (pass reasoning_level "
"minimal/low/medium/high/max — you pick the depth per call), "
"honcho_conclude to save facts about the user."
)
@@ -526,6 +563,10 @@ class HonchoMemoryProvider(MemoryProvider):
if self._injection_frequency == "first-turn" and self._turn_count > 1:
return ""
# Trivial prompts ("ok", "yes", slash commands) carry no semantic signal.
if self._is_trivial_prompt(query):
return ""
parts = []
# ----- Layer 1: Base context (representation + card) -----
@@ -560,43 +601,72 @@ class HonchoMemoryProvider(MemoryProvider):
# On the very first turn, no queue_prefetch() has run yet so the
# dialectic result is empty. Run with a bounded timeout so a slow
# Honcho connection doesn't block the first response indefinitely.
# On timeout the result is skipped and queue_prefetch() will pick it
# up at the next cadence-allowed turn.
# On timeout we let the thread keep running and write its result into
# _prefetch_result under the lock, so the next turn picks it up.
#
# Skip if the session-start prewarm already filled _prefetch_result —
# firing another .chat() would be duplicate work.
with self._prefetch_lock:
_prewarm_landed = bool(self._prefetch_result)
if _prewarm_landed and self._last_dialectic_turn == -999:
self._last_dialectic_turn = self._turn_count
if self._last_dialectic_turn == -999 and query:
_first_turn_timeout = (
self._config.timeout if self._config and self._config.timeout else 8.0
)
_result_holder: list[str] = []
_fired_at = self._turn_count
def _run_first_turn() -> None:
try:
_result_holder.append(self._run_dialectic_depth(query))
r = self._run_dialectic_depth(query)
except Exception as exc:
logger.debug("Honcho first-turn dialectic failed: %s", exc)
_t = threading.Thread(target=_run_first_turn, daemon=True)
_t.start()
_t.join(timeout=_first_turn_timeout)
if not _t.is_alive():
first_turn_dialectic = _result_holder[0] if _result_holder else ""
if first_turn_dialectic and first_turn_dialectic.strip():
self._dialectic_empty_streak += 1
return
if r and r.strip():
with self._prefetch_lock:
self._prefetch_result = first_turn_dialectic
self._last_dialectic_turn = self._turn_count
else:
self._prefetch_result = r
self._prefetch_result_fired_at = _fired_at
# Advance cadence only on a non-empty result so the next
# turn retries when the call returned nothing.
self._last_dialectic_turn = _fired_at
self._dialectic_empty_streak = 0
else:
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
)
self._prefetch_thread.start()
self._prefetch_thread.join(timeout=_first_turn_timeout)
if self._prefetch_thread.is_alive():
logger.debug(
"Honcho first-turn dialectic timed out (%.1fs)"
"will inject at next cadence-allowed turn",
"Honcho first-turn dialectic still running after %.1fs — "
"will surface on next turn",
_first_turn_timeout,
)
# Don't update _last_dialectic_turn: queue_prefetch() will
# retry at the next cadence-allowed turn via the async path.
if self._prefetch_thread and self._prefetch_thread.is_alive():
self._prefetch_thread.join(timeout=3.0)
with self._prefetch_lock:
dialectic_result = self._prefetch_result
fired_at = self._prefetch_result_fired_at
self._prefetch_result = ""
self._prefetch_result_fired_at = -999
# Discard stale pending results: if the fire happened more than
# cadence × multiplier turns ago (e.g. a run of trivial-prompt turns
# passed without consumption), the content likely no longer tracks
# the current conversational pivot.
stale_limit = self._dialectic_cadence * self._STALE_RESULT_MULTIPLIER
if dialectic_result and fired_at >= 0 and (self._turn_count - fired_at) > stale_limit:
logger.debug(
"Honcho pending dialectic discarded as stale: fired_at=%d, "
"turn=%d, limit=%d", fired_at, self._turn_count, stale_limit,
)
dialectic_result = ""
if dialectic_result and dialectic_result.strip():
parts.append(dialectic_result)
@@ -641,6 +711,10 @@ class HonchoMemoryProvider(MemoryProvider):
if self._recall_mode == "tools":
return
# Trivial prompts don't warrant either a context refresh or a dialectic call.
if self._is_trivial_prompt(query):
return
# ----- Context refresh (base layer) — independent cadence -----
if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence:
self._last_context_turn = self._turn_count
@@ -650,24 +724,46 @@ class HonchoMemoryProvider(MemoryProvider):
logger.debug("Honcho context prefetch failed: %s", e)
# ----- Dialectic prefetch (supplement layer) -----
# B5: cadence check — skip if too soon since last dialectic call
if self._dialectic_cadence > 1:
if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence:
logger.debug("Honcho dialectic prefetch skipped: cadence %d, turns since last: %d",
self._dialectic_cadence, self._turn_count - self._last_dialectic_turn)
return
# Thread-alive guard with stale-thread recovery: a hung Honcho call
# older than timeout × multiplier is treated as dead so it can't
# block subsequent fires.
if self._thread_is_live():
logger.debug("Honcho dialectic prefetch skipped: prior thread still running")
return
self._last_dialectic_turn = self._turn_count
# Cadence gate, widened by the empty-streak backoff so a persistently
# silent backend doesn't retry every turn forever.
effective = self._effective_cadence()
if (self._turn_count - self._last_dialectic_turn) < effective:
logger.debug(
"Honcho dialectic prefetch skipped: effective cadence %d "
"(base %d, empty streak %d), turns since last: %d",
effective, self._dialectic_cadence, self._dialectic_empty_streak,
self._turn_count - self._last_dialectic_turn,
)
return
# Cadence advances only on a non-empty result so empty returns
# (transient API error, sparse representation) retry next turn.
_fired_at = self._turn_count
def _run():
try:
result = self._run_dialectic_depth(query)
if result and result.strip():
with self._prefetch_lock:
self._prefetch_result = result
except Exception as e:
logger.debug("Honcho prefetch failed: %s", e)
self._dialectic_empty_streak += 1
return
if result and result.strip():
with self._prefetch_lock:
self._prefetch_result = result
self._prefetch_result_fired_at = _fired_at
self._last_dialectic_turn = _fired_at
self._dialectic_empty_streak = 0
else:
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
self._prefetch_thread = threading.Thread(
target=_run, daemon=True, name="honcho-prefetch"
)
@@ -692,11 +788,91 @@ class HonchoMemoryProvider(MemoryProvider):
_LEVEL_ORDER = ("minimal", "low", "medium", "high", "max")
def _resolve_pass_level(self, pass_idx: int) -> str:
# Char-count thresholds for the query-length reasoning heuristic.
_HEURISTIC_LENGTH_MEDIUM = 120
_HEURISTIC_LENGTH_HIGH = 400
# Liveness constants. A thread older than timeout × multiplier is treated
# as dead so a hung Honcho call can't block future retries indefinitely.
_STALE_THREAD_MULTIPLIER = 2.0
# Pending result whose fire-turn is older than cadence × multiplier is
# discarded on read so we don't inject context for a stale conversational
# pivot after a gap of trivial-prompt turns.
_STALE_RESULT_MULTIPLIER = 2
# Cap on the empty-streak backoff so a persistently silent backend
# eventually settles on a ceiling instead of unbounded widening.
_BACKOFF_MAX = 8
def _thread_is_live(self) -> bool:
"""Thread-alive guard that treats threads older than the stale
threshold as dead, so a hung Honcho request can't block new fires."""
if not self._prefetch_thread or not self._prefetch_thread.is_alive():
return False
timeout = (self._config.timeout if self._config and self._config.timeout else 8.0)
age = time.monotonic() - self._prefetch_thread_started_at
if age > timeout * self._STALE_THREAD_MULTIPLIER:
logger.debug(
"Honcho prefetch thread age %.1fs exceeds stale threshold "
"%.1fs — treating as dead", age, timeout * self._STALE_THREAD_MULTIPLIER,
)
return False
return True
def _effective_cadence(self) -> int:
"""Cadence plus empty-streak backoff, capped at _BACKOFF_MAX × base."""
if self._dialectic_empty_streak <= 0:
return self._dialectic_cadence
widened = self._dialectic_cadence + self._dialectic_empty_streak
ceiling = self._dialectic_cadence * self._BACKOFF_MAX
return min(widened, ceiling)
def liveness_snapshot(self) -> dict:
"""In-process snapshot of dialectic liveness state for diagnostics.
Returns current turn, last successful dialectic turn, pending-result
fire turn, empty streak, effective cadence, and thread status.
"""
thread_age = None
if self._prefetch_thread and self._prefetch_thread.is_alive():
thread_age = time.monotonic() - self._prefetch_thread_started_at
return {
"turn_count": self._turn_count,
"last_dialectic_turn": self._last_dialectic_turn,
"pending_result_fired_at": self._prefetch_result_fired_at,
"empty_streak": self._dialectic_empty_streak,
"effective_cadence": self._effective_cadence(),
"thread_alive": thread_age is not None,
"thread_age_seconds": thread_age,
}
def _apply_reasoning_heuristic(self, base: str, query: str) -> str:
"""Scale `base` up by query length, clamped at reasoning_level_cap.
Char-count heuristic: +1 at >=120 chars, +2 at >=400.
"""
if not self._reasoning_heuristic or not query:
return base
if base not in self._LEVEL_ORDER:
return base
n = len(query)
if n < self._HEURISTIC_LENGTH_MEDIUM:
bump = 0
elif n < self._HEURISTIC_LENGTH_HIGH:
bump = 1
else:
bump = 2
base_idx = self._LEVEL_ORDER.index(base)
cap_idx = self._LEVEL_ORDER.index(self._reasoning_level_cap)
return self._LEVEL_ORDER[min(base_idx + bump, cap_idx)]
def _resolve_pass_level(self, pass_idx: int, query: str = "") -> str:
"""Resolve reasoning level for a given pass index.
Uses dialecticDepthLevels if configured, otherwise proportional
defaults relative to dialecticReasoningLevel.
Precedence:
1. dialecticDepthLevels (explicit per-pass) wins absolutely
2. _PROPORTIONAL_LEVELS table (depth>1 lighter-early passes)
3. Base level = dialecticReasoningLevel, optionally scaled by the
reasoning heuristic when the mapping falls through to 'base'
"""
if self._dialectic_depth_levels and pass_idx < len(self._dialectic_depth_levels):
return self._dialectic_depth_levels[pass_idx]
@@ -704,7 +880,7 @@ class HonchoMemoryProvider(MemoryProvider):
base = (self._config.dialectic_reasoning_level if self._config else "low")
mapping = self._PROPORTIONAL_LEVELS.get((self._dialectic_depth, pass_idx))
if mapping is None or mapping == "base":
return base
return self._apply_reasoning_heuristic(base, query)
return mapping
def _build_dialectic_prompt(self, pass_idx: int, prior_results: list[str], is_cold: bool) -> str:
@@ -791,7 +967,7 @@ class HonchoMemoryProvider(MemoryProvider):
break
prompt = self._build_dialectic_prompt(i, results, is_cold)
level = self._resolve_pass_level(i)
level = self._resolve_pass_level(i, query=query)
logger.debug("Honcho dialectic depth %d: pass %d, level=%s, cold=%s",
self._dialectic_depth, i, level, is_cold)
@@ -808,6 +984,29 @@ class HonchoMemoryProvider(MemoryProvider):
return r
return ""
# Prompts that carry no semantic signal — trivial acknowledgements, slash
# commands, empty input. Skipping injection here saves tokens and prevents
# stale user-model context from derailing one-word replies.
_TRIVIAL_PROMPT_RE = re.compile(
r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|'
r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)$',
re.IGNORECASE,
)
@classmethod
def _is_trivial_prompt(cls, text: str) -> bool:
"""Return True if the prompt is too trivial to warrant context injection."""
if not text:
return True
stripped = text.strip()
if not stripped:
return True
if stripped.startswith("/"):
return True
if cls._TRIVIAL_PROMPT_RE.match(stripped):
return True
return False
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
"""Track turn count for cadence and injection_frequency logic."""
self._turn_count = turn_number
+27 -4
View File
@@ -460,17 +460,37 @@ def cmd_setup(args) -> None:
pass # keep current
# --- 7b. Dialectic cadence ---
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3")
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "2")
print("\n Dialectic cadence:")
print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).")
print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.")
print(" 1 = every turn, 2 = every other turn, 3+ = sparser.")
print(" Recommended: 1-5.")
new_dialectic = _prompt("Dialectic cadence", default=current_dialectic)
try:
val = int(new_dialectic)
if val >= 1:
hermes_host["dialecticCadence"] = val
except (ValueError, TypeError):
hermes_host["dialecticCadence"] = 3
hermes_host["dialecticCadence"] = 2
# --- 7c. Dialectic reasoning level ---
current_reasoning = (
hermes_host.get("dialecticReasoningLevel")
or cfg.get("dialecticReasoningLevel")
or "low"
)
print("\n Dialectic reasoning level:")
print(" Depth Honcho uses when synthesizing user context on auto-injected calls.")
print(" minimal -- quick factual lookups")
print(" low -- straightforward questions (default)")
print(" medium -- multi-aspect synthesis")
print(" high -- complex behavioral patterns")
print(" max -- thorough audit-level analysis")
new_reasoning = _prompt("Reasoning level", default=current_reasoning)
if new_reasoning in ("minimal", "low", "medium", "high", "max"):
hermes_host["dialecticReasoningLevel"] = new_reasoning
else:
hermes_host["dialecticReasoningLevel"] = "low"
# --- 8. Session strategy ---
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session")
@@ -636,8 +656,11 @@ def cmd_status(args) -> None:
print(f" Recall mode: {hcfg.recall_mode}")
print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens")
raw = getattr(hcfg, "raw", None) or {}
dialectic_cadence = raw.get("dialecticCadence") or 3
dialectic_cadence = raw.get("dialecticCadence") or 1
print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}")
reasoning_cap = raw.get("reasoningLevelCap") or hcfg.reasoning_level_cap
heuristic_on = "on" if hcfg.reasoning_heuristic else "off"
print(f" Reasoning: base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={heuristic_on}")
print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})")
print(f" Write freq: {hcfg.write_frequency}")
+15
View File
@@ -251,6 +251,11 @@ class HonchoClientConfig:
# matching dialectic_depth length. When None, uses proportional defaults
# derived from dialectic_reasoning_level.
dialectic_depth_levels: list[str] | None = None
# When true, the auto-injected dialectic scales reasoning level up on
# longer queries. See HonchoMemoryProvider for thresholds.
reasoning_heuristic: bool = True
# Ceiling for the heuristic-selected reasoning level.
reasoning_level_cap: str = "high"
# Honcho API limits — configurable for self-hosted instances
# Max chars per message sent via add_messages() (Honcho cloud: 25000)
message_max_chars: int = 25000
@@ -446,6 +451,16 @@ class HonchoClientConfig:
raw.get("dialecticDepthLevels"),
depth=_parse_dialectic_depth(host_block.get("dialecticDepth"), raw.get("dialecticDepth")),
),
reasoning_heuristic=_resolve_bool(
host_block.get("reasoningHeuristic"),
raw.get("reasoningHeuristic"),
default=True,
),
reasoning_level_cap=(
host_block.get("reasoningLevelCap")
or raw.get("reasoningLevelCap")
or "high"
),
message_max_chars=int(
host_block.get("messageMaxChars")
or raw.get("messageMaxChars")
+13 -42
View File
@@ -78,6 +78,7 @@ class HonchoSessionManager:
honcho: Honcho | None = None,
context_tokens: int | None = None,
config: Any | None = None,
runtime_user_peer_name: str | None = None,
):
"""
Initialize the session manager.
@@ -87,10 +88,12 @@ class HonchoSessionManager:
context_tokens: Max tokens for context() calls (None = Honcho default).
config: HonchoClientConfig from global config (provides peer_name, ai_peer,
write_frequency, observation, etc.).
runtime_user_peer_name: Gateway user identity for per-user memory scoping.
"""
self._honcho = honcho
self._context_tokens = context_tokens
self._config = config
self._runtime_user_peer_name = runtime_user_peer_name
self._cache: dict[str, HonchoSession] = {}
self._peers_cache: dict[str, Any] = {}
self._sessions_cache: dict[str, Any] = {}
@@ -100,9 +103,11 @@ class HonchoSessionManager:
self._write_frequency = write_frequency
self._turn_counter: int = 0
# Prefetch caches: session_key → last result (consumed once per turn)
# Prefetch cache: session_key → last context result (consumed once per turn).
# Dialectic results are cached on the plugin side (HonchoMemoryProvider
# ._prefetch_result) so session-start prewarm and turn-driven fires share
# one source of truth; see __init__.py _do_session_init for the prewarm.
self._context_cache: dict[str, dict] = {}
self._dialectic_cache: dict[str, str] = {}
self._prefetch_cache_lock = threading.Lock()
self._dialectic_reasoning_level: str = (
config.dialectic_reasoning_level if config else "low"
@@ -272,8 +277,10 @@ class HonchoSessionManager:
logger.debug("Local session cache hit: %s", key)
return self._cache[key]
# Use peer names from global config when available
if self._config and self._config.peer_name:
# Gateway sessions should use the runtime user identity when available.
if self._runtime_user_peer_name:
user_peer_id = self._sanitize_id(self._runtime_user_peer_name)
elif self._config and self._config.peer_name:
user_peer_id = self._sanitize_id(self._config.peer_name)
else:
# Fallback: derive from session key
@@ -499,8 +506,8 @@ class HonchoSessionManager:
Query Honcho's dialectic endpoint about a peer.
Runs an LLM on Honcho's backend against the target peer's full
representation. Higher latency than context() call async via
prefetch_dialectic() to avoid blocking the response.
representation. Higher latency than context() callers run this in
a background thread (see HonchoMemoryProvider) to avoid blocking.
Args:
session_key: The session key to query against.
@@ -555,42 +562,6 @@ class HonchoSessionManager:
logger.warning("Honcho dialectic query failed: %s", e)
return ""
def prefetch_dialectic(self, session_key: str, query: str) -> None:
"""
Fire a dialectic_query in a background thread, caching the result.
Non-blocking. The result is available via pop_dialectic_result()
on the next call (typically the following turn). Reasoning level
is selected dynamically based on query complexity.
Args:
session_key: The session key to query against.
query: The user's current message, used as the query.
"""
def _run():
result = self.dialectic_query(session_key, query)
if result:
self.set_dialectic_result(session_key, result)
t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True)
t.start()
def set_dialectic_result(self, session_key: str, result: str) -> None:
"""Store a prefetched dialectic result in a thread-safe way."""
if not result:
return
with self._prefetch_cache_lock:
self._dialectic_cache[session_key] = result
def pop_dialectic_result(self, session_key: str) -> str:
"""
Return and clear the cached dialectic result for this session.
Returns empty string if no result is ready yet.
"""
with self._prefetch_cache_lock:
return self._dialectic_cache.pop(session_key, "")
def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
"""
Fire get_prefetch_context in a background thread, caching the result.
+4 -4
View File
@@ -39,7 +39,7 @@ dependencies = [
[project.optional-dependencies]
modal = ["modal>=1.0.0,<2"]
daytona = ["daytona>=0.148.0,<1"]
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "pytest-split>=0.9,<1", "mcp>=1.2.0,<2"]
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"]
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
cron = ["croniter>=6.0.0,<7"]
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
@@ -76,8 +76,8 @@ termux = [
"hermes-agent[honcho]",
"hermes-agent[acp]",
]
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
feishu = ["lark-oapi>=1.5.3,<2"]
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
@@ -126,7 +126,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
hermes_cli = ["web_dist/**/*"]
[tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+502 -68
View File
@@ -353,12 +353,50 @@ def _sanitize_surrogates(text: str) -> str:
return text
def _sanitize_structure_surrogates(payload: Any) -> bool:
"""Replace surrogate code points in nested dict/list payloads in-place.
Mirror of ``_sanitize_structure_non_ascii`` but for surrogate recovery.
Used to scrub nested structured fields (e.g. ``reasoning_details`` an
array of dicts with ``summary``/``text`` strings) that flat per-field
checks don't reach. Returns True if any surrogates were replaced.
"""
found = False
def _walk(node):
nonlocal found
if isinstance(node, dict):
for key, value in node.items():
if isinstance(value, str):
if _SURROGATE_RE.search(value):
node[key] = _SURROGATE_RE.sub('\ufffd', value)
found = True
elif isinstance(value, (dict, list)):
_walk(value)
elif isinstance(node, list):
for idx, value in enumerate(node):
if isinstance(value, str):
if _SURROGATE_RE.search(value):
node[idx] = _SURROGATE_RE.sub('\ufffd', value)
found = True
elif isinstance(value, (dict, list)):
_walk(value)
_walk(payload)
return found
def _sanitize_messages_surrogates(messages: list) -> bool:
"""Sanitize surrogate characters from all string content in a messages list.
Walks message dicts in-place. Returns True if any surrogates were found
and replaced, False otherwise. Covers content/text, name, and tool call
metadata/arguments so retries don't fail on a non-content field.
and replaced, False otherwise. Covers content/text, name, tool call
metadata/arguments, AND any additional string or nested structured fields
(``reasoning``, ``reasoning_content``, ``reasoning_details``, etc.) so
retries don't fail on a non-content field. Byte-level reasoning models
(xiaomi/mimo, kimi, glm) can emit lone surrogates in reasoning output
that flow through to ``api_messages["reasoning_content"]`` on the next
turn and crash json.dumps inside the OpenAI SDK.
"""
found = False
for msg in messages:
@@ -398,6 +436,21 @@ def _sanitize_messages_surrogates(messages: list) -> bool:
if isinstance(fn_args, str) and _SURROGATE_RE.search(fn_args):
fn["arguments"] = _SURROGATE_RE.sub('\ufffd', fn_args)
found = True
# Walk any additional string / nested fields (reasoning,
# reasoning_content, reasoning_details, etc.) — surrogates from
# byte-level reasoning models (xiaomi/mimo, kimi, glm) can lurk
# in these fields and aren't covered by the per-field checks above.
# Matches _sanitize_messages_non_ascii's coverage (PR #10537).
for key, value in msg.items():
if key in {"content", "name", "tool_calls", "role"}:
continue
if isinstance(value, str):
if _SURROGATE_RE.search(value):
msg[key] = _SURROGATE_RE.sub('\ufffd', value)
found = True
elif isinstance(value, (dict, list)):
if _sanitize_structure_surrogates(value):
found = True
return found
@@ -778,6 +831,26 @@ class AIAgent:
self._execution_thread_id: int | None = None # Set at run_conversation() start
self._interrupt_thread_signal_pending = False
self._client_lock = threading.RLock()
# /steer mechanism — inject a user note into the next tool result
# without interrupting the agent. Unlike interrupt(), steer() does
# NOT set _interrupt_requested; it waits for the current tool batch
# to finish naturally, then the drain hook appends the text to the
# last tool result's content so the model sees it on its next
# iteration. Message-role alternation is preserved (we modify an
# existing tool message rather than inserting a new user turn).
self._pending_steer: Optional[str] = None
self._pending_steer_lock = threading.Lock()
# Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent`
# runs each tool on its own ThreadPoolExecutor worker — those worker
# threads have tids distinct from `_execution_thread_id`, so
# `_set_interrupt(True, _execution_thread_id)` alone does NOT cause
# `is_interrupted()` inside the worker to return True. Track the
# workers here so `interrupt()` / `clear_interrupt()` can fan out to
# their tids explicitly.
self._tool_worker_threads: set[int] = set()
self._tool_worker_threads_lock = threading.Lock()
# Subagent delegation state
self._delegate_depth = 0 # 0 = top-level agent, incremented for children
@@ -1233,31 +1306,6 @@ class AIAgent:
try:
_mem_provider_name = mem_config.get("provider", "") if mem_config else ""
# Auto-migrate: if Honcho was actively configured (enabled +
# credentials) but memory.provider is not set, activate the
# honcho plugin automatically. Just having the config file
# is not enough — the user may have disabled Honcho or the
# file may be from a different tool.
if not _mem_provider_name:
try:
from plugins.memory.honcho.client import HonchoClientConfig as _HCC
_hcfg = _HCC.from_global_config()
if _hcfg.enabled and (_hcfg.api_key or _hcfg.base_url):
_mem_provider_name = "honcho"
# Persist so this only auto-migrates once
try:
from hermes_cli.config import load_config as _lc, save_config as _sc
_cfg = _lc()
_cfg.setdefault("memory", {})["provider"] = "honcho"
_sc(_cfg)
except Exception:
pass
if not self.quiet_mode:
print(" ✓ Auto-migrated Honcho to memory provider plugin.")
print(" Your config and data are preserved.\n")
except Exception:
pass
if _mem_provider_name:
from agent.memory_manager import MemoryManager as _MemoryManager
from plugins.memory import load_memory_provider as _load_mem
@@ -1868,13 +1916,16 @@ class AIAgent:
def _should_emit_quiet_tool_messages(self) -> bool:
"""Return True when quiet-mode tool summaries should print directly.
When the caller provides ``tool_progress_callback`` (for example the CLI
TUI or a gateway progress renderer), that callback owns progress display.
Emitting quiet-mode summary lines here duplicates progress and leaks tool
previews into flows that are expected to stay silent, such as
``hermes chat -q``.
Quiet mode is used by both the interactive CLI and embedded/library
callers. The CLI may still want compact progress hints when no callback
owns rendering. Embedded/library callers, on the other hand, expect
quiet mode to be truly silent.
"""
return self.quiet_mode and not self.tool_progress_callback
return (
self.quiet_mode
and not self.tool_progress_callback
and getattr(self, "platform", "") == "cli"
)
def _emit_status(self, message: str) -> None:
"""Emit a lifecycle status message to both CLI and gateway channels.
@@ -2099,17 +2150,49 @@ class AIAgent:
return bool(cleaned.strip())
def _strip_think_blocks(self, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text."""
"""Remove reasoning/thinking blocks from content, returning only visible text.
Handles four cases:
1. Closed tag pairs (``<think></think>``) the common path when
the provider emits complete reasoning blocks.
2. Unterminated open tag at a block boundary (start of text or
after a newline) e.g. MiniMax M2.7 / NIM endpoints where the
closing tag is dropped. Everything from the open tag to end
of string is stripped. The block-boundary check mirrors
``gateway/stream_consumer.py``'s filter so models that mention
``<think>`` in prose aren't over-stripped.
3. Stray orphan open/close tags that slip through.
4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,
``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all
case-insensitive.
"""
if not content:
return ""
# Strip all reasoning tag variants: <think>, <thinking>, <THINKING>,
# <reasoning>, <REASONING_SCRATCHPAD>, <thought> (Gemma 4)
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
# 1. Closed tag pairs — case-insensitive for all variants so
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
# the unterminated-tag pass and take trailing content with them.
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL)
content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL)
content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'<thought>.*?</thought>', '', content, flags=re.DOTALL | re.IGNORECASE)
content = re.sub(r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*', '', content, flags=re.IGNORECASE)
# 2. Unterminated reasoning block — open tag at a block boundary
# (start of text, or after a newline) with no matching close.
# Strip from the tag to end of string. Fixes #8878 / #9568
# (MiniMax M2.7 leaking raw reasoning into assistant content).
content = re.sub(
r'(?:^|\n)[ \t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*$',
'',
content,
flags=re.DOTALL | re.IGNORECASE,
)
# 3. Stray orphan open/close tags that slipped through.
content = re.sub(
r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*',
'',
content,
flags=re.IGNORECASE,
)
return content
@staticmethod
@@ -3138,6 +3221,25 @@ class AIAgent:
# interrupt signal until startup completes instead of targeting
# the caller thread by mistake.
self._interrupt_thread_signal_pending = True
# Fan out to concurrent-tool worker threads. Those workers run tools
# on their own tids (ThreadPoolExecutor workers), so `is_interrupted()`
# inside a tool only sees an interrupt when their specific tid is in
# the `_interrupted_threads` set. Without this propagation, an
# already-running concurrent tool (e.g. a terminal command hung on
# network I/O) never notices the interrupt and has to run to its own
# timeout. See `_run_tool` for the matching entry/exit bookkeeping.
# `getattr` fallback covers test stubs that build AIAgent via
# object.__new__ and skip __init__.
_tracker = getattr(self, "_tool_worker_threads", None)
_tracker_lock = getattr(self, "_tool_worker_threads_lock", None)
if _tracker is not None and _tracker_lock is not None:
with _tracker_lock:
_worker_tids = list(_tracker)
for _wtid in _worker_tids:
try:
_set_interrupt(True, _wtid)
except Exception:
pass
# Propagate interrupt to any running child agents (subagent delegation)
with self._active_children_lock:
children_copy = list(self._active_children)
@@ -3156,6 +3258,146 @@ class AIAgent:
self._interrupt_thread_signal_pending = False
if self._execution_thread_id is not None:
_set_interrupt(False, self._execution_thread_id)
# Also clear any concurrent-tool worker thread bits. Tracked
# workers normally clear their own bit on exit, but an explicit
# clear here guarantees no stale interrupt can survive a turn
# boundary and fire on a subsequent, unrelated tool call that
# happens to get scheduled onto the same recycled worker tid.
# `getattr` fallback covers test stubs that build AIAgent via
# object.__new__ and skip __init__.
_tracker = getattr(self, "_tool_worker_threads", None)
_tracker_lock = getattr(self, "_tool_worker_threads_lock", None)
if _tracker is not None and _tracker_lock is not None:
with _tracker_lock:
_worker_tids = list(_tracker)
for _wtid in _worker_tids:
try:
_set_interrupt(False, _wtid)
except Exception:
pass
# A hard interrupt supersedes any pending /steer — the steer was
# meant for the agent's next tool-call iteration, which will no
# longer happen. Drop it instead of surprising the user with a
# late injection on the post-interrupt turn.
_steer_lock = getattr(self, "_pending_steer_lock", None)
if _steer_lock is not None:
with _steer_lock:
self._pending_steer = None
def steer(self, text: str) -> bool:
"""
Inject a user message into the next tool result without interrupting.
Unlike interrupt(), this does NOT stop the current tool call. The
text is stashed and the agent loop appends it to the LAST tool
result's content once the current tool batch finishes. The model
sees the steer as part of the tool output on its next iteration.
Thread-safe: callable from gateway/CLI/TUI threads. Multiple calls
before the drain point concatenate with newlines.
Args:
text: The user text to inject. Empty strings are ignored.
Returns:
True if the steer was accepted, False if the text was empty.
"""
if not text or not text.strip():
return False
cleaned = text.strip()
_lock = getattr(self, "_pending_steer_lock", None)
if _lock is None:
# Test stubs that built AIAgent via object.__new__ skip __init__.
# Fall back to direct attribute set; no concurrent callers expected
# in those stubs.
existing = getattr(self, "_pending_steer", None)
self._pending_steer = (existing + "\n" + cleaned) if existing else cleaned
return True
with _lock:
if self._pending_steer:
self._pending_steer = self._pending_steer + "\n" + cleaned
else:
self._pending_steer = cleaned
return True
def _drain_pending_steer(self) -> Optional[str]:
"""Return the pending steer text (if any) and clear the slot.
Safe to call from the agent execution thread after appending tool
results. Returns None when no steer is pending.
"""
_lock = getattr(self, "_pending_steer_lock", None)
if _lock is None:
text = getattr(self, "_pending_steer", None)
self._pending_steer = None
return text
with _lock:
text = self._pending_steer
self._pending_steer = None
return text
def _apply_pending_steer_to_tool_results(self, messages: list, num_tool_msgs: int) -> None:
"""Append any pending /steer text to the last tool result in this turn.
Called at the end of a tool-call batch, before the next API call.
The steer is appended to the last ``role:"tool"`` message's content
with a clear marker so the model understands it came from the user
and NOT from the tool itself. Role alternation is preserved
nothing new is inserted, we only modify existing content.
Args:
messages: The running messages list.
num_tool_msgs: Number of tool results appended in this batch;
used to locate the tail slice safely.
"""
if num_tool_msgs <= 0 or not messages:
return
steer_text = self._drain_pending_steer()
if not steer_text:
return
# Find the last tool-role message in the recent tail. Skipping
# non-tool messages defends against future code appending
# something else at the boundary.
target_idx = None
for j in range(len(messages) - 1, max(len(messages) - num_tool_msgs - 1, -1), -1):
msg = messages[j]
if isinstance(msg, dict) and msg.get("role") == "tool":
target_idx = j
break
if target_idx is None:
# No tool result in this batch (e.g. all skipped by interrupt);
# put the steer back so the caller's fallback path can deliver
# it as a normal next-turn user message.
_lock = getattr(self, "_pending_steer_lock", None)
if _lock is not None:
with _lock:
if self._pending_steer:
self._pending_steer = self._pending_steer + "\n" + steer_text
else:
self._pending_steer = steer_text
else:
existing = getattr(self, "_pending_steer", None)
self._pending_steer = (existing + "\n" + steer_text) if existing else steer_text
return
marker = f"\n\n[USER STEER (injected mid-run, not tool output): {steer_text}]"
existing_content = messages[target_idx].get("content", "")
if not isinstance(existing_content, str):
# Anthropic multimodal content blocks — preserve them and append
# a text block at the end.
try:
blocks = list(existing_content) if existing_content else []
blocks.append({"type": "text", "text": marker.lstrip()})
messages[target_idx]["content"] = blocks
except Exception:
# Fall back to string replacement if content shape is unexpected.
messages[target_idx]["content"] = f"{existing_content}{marker}"
else:
messages[target_idx]["content"] = existing_content + marker
logger.info(
"Delivered /steer to agent after tool batch (%d chars): %s",
len(steer_text),
steer_text[:120] + ("..." if len(steer_text) > 120 else ""),
)
def _touch_activity(self, desc: str) -> None:
"""Update the last-activity timestamp and description (thread-safe)."""
@@ -5459,7 +5701,7 @@ class AIAgent:
raise result["error"]
return result["response"]
result = {"response": None, "error": None}
result = {"response": None, "error": None, "partial_tool_names": []}
request_client_holder = {"client": None}
first_delta_fired = {"done": False}
deltas_were_sent = {"yes": False} # Track if any deltas were fired (for fallback)
@@ -5615,7 +5857,15 @@ class AIAgent:
entry["id"] = tc_delta.id
if tc_delta.function:
if tc_delta.function.name:
entry["function"]["name"] += tc_delta.function.name
# Use assignment, not +=. Function names are
# atomic identifiers delivered complete in the
# first chunk (OpenAI spec). Some providers
# (MiniMax M2.7 via NVIDIA NIM) resend the full
# name in every chunk; concatenation would
# produce "read_fileread_file". Assignment
# (matching the OpenAI Node SDK / LiteLLM /
# Vercel AI patterns) is immune to this.
entry["function"]["name"] = tc_delta.function.name
if tc_delta.function.arguments:
entry["function"]["arguments"] += tc_delta.function.arguments
extra = getattr(tc_delta, "extra_content", None)
@@ -5631,6 +5881,14 @@ class AIAgent:
tool_gen_notified.add(idx)
_fire_first_delta()
self._fire_tool_gen_started(name)
# Record the partial tool-call name so the outer
# stub-builder can surface a user-visible warning
# if streaming dies before this tool's arguments
# are fully delivered. Without this, a stall
# during tool-call JSON generation lets the stub
# at line ~6107 return `tool_calls=None`, silently
# discarding the attempted action.
result["partial_tool_names"].append(name)
if chunk.choices[0].finish_reason:
finish_reason = chunk.choices[0].finish_reason
@@ -5841,6 +6099,7 @@ class AIAgent:
)
except Exception:
pass
self._emit_status("🔄 Reconnected — resuming…")
continue
self._emit_status(
"❌ Connection to provider failed after "
@@ -5996,13 +6255,44 @@ class AIAgent:
_partial_text = (
getattr(self, "_current_streamed_assistant_text", "") or ""
).strip() or None
logger.warning(
"Partial stream delivered before error; returning stub "
"response with %s chars of recovered content to prevent "
"duplicate messages: %s",
len(_partial_text or ""),
result["error"],
)
# If the stream died while the model was emitting a tool call,
# the stub below will silently set `tool_calls=None` and the
# agent loop will treat the turn as complete — the attempted
# action is lost with no user-facing signal. Append a
# human-visible warning to the stub content so (a) the user
# knows something failed, and (b) the next turn's model sees
# in conversation history what was attempted and can retry.
_partial_names = list(result.get("partial_tool_names") or [])
if _partial_names:
_name_str = ", ".join(_partial_names[:3])
if len(_partial_names) > 3:
_name_str += f", +{len(_partial_names) - 3} more"
_warn = (
f"\n\n⚠ Stream stalled mid tool-call "
f"({_name_str}); the action was not executed. "
f"Ask me to retry if you want to continue."
)
_partial_text = (_partial_text or "") + _warn
# Also fire as a streaming delta so the user sees it now
# instead of only in the persisted transcript.
try:
self._fire_stream_delta(_warn)
except Exception:
pass
logger.warning(
"Partial stream dropped tool call(s) %s after %s chars "
"of text; surfaced warning to user: %s",
_partial_names, len(_partial_text or ""), result["error"],
)
else:
logger.warning(
"Partial stream delivered before error; returning stub "
"response with %s chars of recovered content to prevent "
"duplicate messages: %s",
len(_partial_text or ""),
result["error"],
)
_stub_msg = SimpleNamespace(
role="assistant", content=_partial_text, tool_calls=None,
reasoning_content=None,
@@ -6744,6 +7034,14 @@ class AIAgent:
"messages": sanitized_messages,
"timeout": float(os.getenv("HERMES_API_TIMEOUT", 1800.0)),
}
try:
from agent.auxiliary_client import _fixed_temperature_for_model
except Exception:
_fixed_temperature_for_model = None
if _fixed_temperature_for_model is not None:
fixed_temperature = _fixed_temperature_for_model(self.model)
if fixed_temperature is not None:
api_kwargs["temperature"] = fixed_temperature
if self._is_qwen_portal():
api_kwargs["metadata"] = {
"sessionId": self.session_id or "hermes",
@@ -6752,8 +7050,20 @@ class AIAgent:
if self.tools:
api_kwargs["tools"] = self.tools
if self.max_tokens is not None:
# ── max_tokens for chat_completions ──────────────────────────────
# Priority: ephemeral override (error recovery / length-continuation
# boost) > user-configured max_tokens > provider-specific defaults.
_ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None)
if _ephemeral_out is not None:
self._ephemeral_max_output_tokens = None # consume immediately
api_kwargs.update(self._max_tokens_param(_ephemeral_out))
elif self.max_tokens is not None:
api_kwargs.update(self._max_tokens_param(self.max_tokens))
elif "integrate.api.nvidia.com" in self._base_url_lower:
# NVIDIA NIM defaults to a very low max_tokens when omitted,
# causing models like GLM-4.7 to truncate immediately (thinking
# tokens alone exhaust the budget). 16384 provides adequate room.
api_kwargs.update(self._max_tokens_param(16384))
elif self._is_qwen_portal():
# Qwen Portal defaults to a very low max_tokens when omitted.
# Reasoning models (qwen3-coder-plus) exhaust that budget on
@@ -6949,7 +7259,7 @@ class AIAgent:
# (gateway, batch, quiet) still get reasoning.
# Any reasoning that wasn't shown during streaming is caught by the
# CLI post-response display fallback (cli.py _reasoning_shown_this_turn).
if not self.stream_delta_callback:
if not self.stream_delta_callback and not self._stream_callback:
try:
self.reasoning_callback(reasoning_text)
except Exception:
@@ -6962,6 +7272,20 @@ class AIAgent:
if reasoning_text:
reasoning_text = _sanitize_surrogates(reasoning_text)
# Strip inline reasoning tags (<think>…</think> etc.) from the stored
# assistant content. Reasoning was already captured into
# ``reasoning_text`` above (either from structured fields or the
# inline-block fallback), so the raw tags in content are redundant.
# Leaving them in place caused reasoning to leak to messaging
# platforms (#8878, #9568), inflate context on subsequent turns
# (#9306 observed 16% content-size reduction on a real MiniMax
# session), and pollute generated session titles. One strip at the
# storage boundary cleans content for every downstream consumer:
# API replay, session transcript, gateway delivery, CLI display,
# compression, title generation.
if isinstance(_san_content, str) and _san_content:
_san_content = self._strip_think_blocks(_san_content).strip()
msg = {
"role": "assistant",
"content": _san_content,
@@ -7154,14 +7478,22 @@ class AIAgent:
# Use auxiliary client for the flush call when available --
# it's cheaper and avoids Codex Responses API incompatibility.
from agent.auxiliary_client import call_llm as _call_llm
from agent.auxiliary_client import (
call_llm as _call_llm,
_fixed_temperature_for_model,
)
_aux_available = True
# Use the fixed-temperature override (e.g. kimi-for-coding → 0.6) if
# the model has a strict contract; otherwise the historical 0.3 default.
_flush_temperature = _fixed_temperature_for_model(self.model)
if _flush_temperature is None:
_flush_temperature = 0.3
try:
response = _call_llm(
task="flush_memories",
messages=api_messages,
tools=[memory_tool_def],
temperature=0.3,
temperature=_flush_temperature,
max_tokens=5120,
# timeout resolved from auxiliary.flush_memories.timeout config
)
@@ -7173,7 +7505,7 @@ class AIAgent:
# No auxiliary client -- use the Codex Responses path directly
codex_kwargs = self._build_api_kwargs(api_messages)
codex_kwargs["tools"] = self._responses_tools([memory_tool_def])
codex_kwargs["temperature"] = 0.3
codex_kwargs["temperature"] = _flush_temperature
if "max_output_tokens" in codex_kwargs:
codex_kwargs["max_output_tokens"] = 5120
response = self._run_codex_stream(codex_kwargs)
@@ -7192,7 +7524,7 @@ class AIAgent:
"model": self.model,
"messages": api_messages,
"tools": [memory_tool_def],
"temperature": 0.3,
"temperature": _flush_temperature,
**self._max_tokens_param(5120),
}
from agent.auxiliary_client import _get_task_timeout
@@ -7583,6 +7915,22 @@ class AIAgent:
def _run_tool(index, tool_call, function_name, function_args):
"""Worker function executed in a thread."""
# Register this worker tid so the agent can fan out an interrupt
# to it — see AIAgent.interrupt(). Must happen first thing, and
# must be paired with discard + clear in the finally block.
_worker_tid = threading.current_thread().ident
with self._tool_worker_threads_lock:
self._tool_worker_threads.add(_worker_tid)
# Race: if the agent was interrupted between fan-out (which
# snapshotted an empty/earlier set) and our registration, apply
# the interrupt to our own tid now so is_interrupted() inside
# the tool returns True on the next poll.
if self._interrupt_requested:
try:
from tools.interrupt import set_interrupt as _sif
_sif(True, _worker_tid)
except Exception:
pass
# Set the activity callback on THIS worker thread so
# _wait_for_process (terminal commands) can fire heartbeats.
# The callback is thread-local; the main thread's callback
@@ -7605,6 +7953,16 @@ class AIAgent:
else:
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
results[index] = (function_name, function_args, result, duration, is_error)
# Tear down worker-tid tracking. Clear any interrupt bit we may
# have set so the next task scheduled onto this recycled tid
# starts with a clean slate.
with self._tool_worker_threads_lock:
self._tool_worker_threads.discard(_worker_tid)
try:
from tools.interrupt import set_interrupt as _sif
_sif(False, _worker_tid)
except Exception:
pass
# Start spinner for CLI mode (skip when TUI handles tool progress)
spinner = None
@@ -7749,6 +8107,13 @@ class AIAgent:
turn_tool_msgs = messages[-num_tools:]
enforce_turn_budget(turn_tool_msgs, env=get_active_env(effective_task_id))
# ── /steer injection ──────────────────────────────────────────────
# Append any pending user steer text to the last tool result so the
# agent sees it on its next iteration. Runs AFTER budget enforcement
# so the steer marker is never truncated. See steer() for details.
if num_tools > 0:
self._apply_pending_steer_to_tool_results(messages, num_tools)
def _execute_tool_calls_sequential(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools."""
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
@@ -7960,7 +8325,7 @@ class AIAgent:
elif self._context_engine_tool_names and function_name in self._context_engine_tool_names:
# Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.)
spinner = None
if self.quiet_mode and not self.tool_progress_callback:
if self._should_emit_quiet_tool_messages():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
@@ -7978,7 +8343,7 @@ class AIAgent:
cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_ce_result)
if spinner:
spinner.stop(cute_msg)
elif self.quiet_mode:
elif self._should_emit_quiet_tool_messages():
self._vprint(f" {cute_msg}")
elif self._memory_manager and self._memory_manager.has_tool(function_name):
# Memory provider tools (hindsight_retain, honcho_search, etc.)
@@ -8128,6 +8493,12 @@ class AIAgent:
if num_tools_seq > 0:
enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id))
# ── /steer injection ──────────────────────────────────────────────
# See _execute_tool_calls_parallel for the rationale. Same hook,
# applied to sequential execution as well.
if num_tools_seq > 0:
self._apply_pending_steer_to_tool_results(messages, num_tools_seq)
def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:
@@ -8165,6 +8536,15 @@ class AIAgent:
api_messages.insert(sys_offset + idx, pfm.copy())
summary_extra_body = {}
try:
from agent.auxiliary_client import _fixed_temperature_for_model
except Exception:
_fixed_temperature_for_model = None
_summary_temperature = (
_fixed_temperature_for_model(self.model)
if _fixed_temperature_for_model is not None
else None
)
_is_nous = "nousresearch" in self._base_url_lower
if self._supports_reasoning_extra_body():
if self.reasoning_config is not None:
@@ -8188,6 +8568,8 @@ class AIAgent:
"model": self.model,
"messages": api_messages,
}
if _summary_temperature is not None:
summary_kwargs["temperature"] = _summary_temperature
if self.max_tokens is not None:
summary_kwargs.update(self._max_tokens_param(self.max_tokens))
@@ -8253,6 +8635,8 @@ class AIAgent:
"model": self.model,
"messages": api_messages,
}
if _summary_temperature is not None:
summary_kwargs["temperature"] = _summary_temperature
if self.max_tokens is not None:
summary_kwargs.update(self._max_tokens_param(self.max_tokens))
if summary_extra_body:
@@ -8688,6 +9072,7 @@ class AIAgent:
{
"name": tc["function"]["name"],
"result": _results_by_id.get(tc.get("id")),
"arguments": tc["function"].get("arguments"),
}
for tc in _m["tool_calls"]
if isinstance(tc, dict)
@@ -9302,8 +9687,7 @@ class AIAgent:
"and had none left for the actual response.\n\n"
"To fix this:\n"
"→ Lower reasoning effort: `/thinkon low` or `/thinkon minimal`\n"
"Increase the output token limit: "
"set `model.max_tokens` in config.yaml"
"Or switch to a larger/non-reasoning model with `/model`"
)
self._cleanup_task_resources(effective_task_id)
self._persist_session(messages, conversation_history)
@@ -9570,13 +9954,51 @@ class AIAgent:
if isinstance(api_error, UnicodeEncodeError) and getattr(self, '_unicode_sanitization_passes', 0) < 2:
_err_str = str(api_error).lower()
_is_ascii_codec = "'ascii'" in _err_str or "ascii" in _err_str
# Detect surrogate errors — utf-8 codec refusing to
# encode U+D800..U+DFFF. The error text is:
# "'utf-8' codec can't encode characters in position
# N-M: surrogates not allowed"
_is_surrogate_error = (
"surrogate" in _err_str
or ("'utf-8'" in _err_str and not _is_ascii_codec)
)
# Sanitize surrogates from both the canonical `messages`
# list AND `api_messages` (the API-copy, which may carry
# `reasoning_content`/`reasoning_details` transformed
# from `reasoning` — fields the canonical list doesn't
# have directly). Also clean `api_kwargs` if built and
# `prefill_messages` if present. Mirrors the ASCII
# codec recovery below.
_surrogates_found = _sanitize_messages_surrogates(messages)
if _surrogates_found:
if isinstance(api_messages, list):
if _sanitize_messages_surrogates(api_messages):
_surrogates_found = True
if isinstance(api_kwargs, dict):
if _sanitize_structure_surrogates(api_kwargs):
_surrogates_found = True
if isinstance(getattr(self, "prefill_messages", None), list):
if _sanitize_messages_surrogates(self.prefill_messages):
_surrogates_found = True
# Gate the retry on the error type, not on whether we
# found anything — _force_ascii_payload / the extended
# surrogate walker above cover all known paths, but a
# new transformed field could still slip through. If
# the error was a surrogate encode failure, always let
# the retry run; the proactive sanitizer at line ~8781
# runs again on the next iteration. Bounded by
# _unicode_sanitization_passes < 2 (outer guard).
if _surrogates_found or _is_surrogate_error:
self._unicode_sanitization_passes += 1
self._vprint(
f"{self.log_prefix}⚠️ Stripped invalid surrogate characters from messages. Retrying...",
force=True,
)
if _surrogates_found:
self._vprint(
f"{self.log_prefix}⚠️ Stripped invalid surrogate characters from messages. Retrying...",
force=True,
)
else:
self._vprint(
f"{self.log_prefix}⚠️ Surrogate encoding error — retrying after full-payload sanitization...",
force=True,
)
continue
if _is_ascii_codec:
self._force_ascii_payload = True
@@ -9753,7 +10175,7 @@ class AIAgent:
_dhh = _dhh_fn()
print(f"{self.log_prefix} • Check ANTHROPIC_TOKEN in {_dhh}/.env for Hermes-managed OAuth/setup tokens")
print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in {_dhh}/.env for API keys or legacy token values")
print(f"{self.log_prefix} • For API keys: verify at https://console.anthropic.com/settings/keys")
print(f"{self.log_prefix} • For API keys: verify at https://platform.claude.com/settings/keys")
print(f"{self.log_prefix} • For Claude Code: run 'claude /login' to refresh, then retry")
print(f"{self.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
@@ -10344,9 +10766,9 @@ class AIAgent:
pass
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
if is_rate_limited:
self._emit_status(f"⏱️ Rate limit reached. Waiting {wait_time}s before retry (attempt {retry_count + 1}/{max_retries})...")
self._emit_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
else:
self._emit_status(f"⏳ Retrying in {wait_time}s (attempt {retry_count}/{max_retries})...")
self._emit_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
logger.warning(
"Retrying API call in %ss (attempt %s/%s) %s error=%s",
wait_time,
@@ -10397,6 +10819,12 @@ class AIAgent:
continue
if restart_with_length_continuation:
# Progressively boost the output token budget on each retry.
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
# Applies to all providers via _ephemeral_max_output_tokens.
_boost_base = self.max_tokens if self.max_tokens else 4096
_boost = _boost_base * (length_continue_retries + 1)
self._ephemeral_max_output_tokens = min(_boost, 32768)
continue
# Guard: if all retries exhausted without a successful response
@@ -10759,7 +11187,7 @@ class AIAgent:
self._last_content_tools_all_housekeeping = _all_housekeeping
if _all_housekeeping and self._has_stream_consumers():
self._mute_post_response = True
elif self.quiet_mode:
elif self._should_emit_quiet_tool_messages():
clean = self._strip_think_blocks(turn_content).strip()
if clean:
self._vprint(f" ┊ 💬 {clean}")
@@ -11350,6 +11778,12 @@ class AIAgent:
"cost_status": self.session_cost_status,
"cost_source": self.session_cost_source,
}
# If a /steer landed after the final assistant turn (no more tool
# batches to drain into), hand it back to the caller so it can be
# delivered as the next user turn instead of being silently lost.
_leftover_steer = self._drain_pending_steer()
if _leftover_steer:
result["pending_steer"] = _leftover_steer
self._response_was_previewed = False
# Include interrupt message if one triggered the interrupt

Some files were not shown because too many files have changed in this diff Show More