Copy gateway notification subscriptions from parent tasks to child tasks created by create_task(..., parents=...), link_tasks(), and decompose_triage_task().
Inherited subscriptions start at the child's current event cursor, so linking an existing child does not replay pre-link task events.
Follow-up to @LionGateOS's #72135 salvage:
- Route ensure_mcp_discovery_started through hermes_cli.mcp_startup's
shared owner instead of a hand-rolled bare thread, keeping the start
lock, retry-after-zero-connected allowance, and interactive-OAuth
suppression. The shared owner now captures the caller's context-local
HERMES_HOME override and re-installs it inside the discovery thread,
so discovery reads the selected profile's mcp_servers (#67605).
- Restore stdio TUI startup discovery in main() and the
_mcp_discovery_enabled retry gate in wait_for_mcp_discovery, both
dropped by the original branch.
- Restore the 3 WSTransport regression tests (send serialization,
cross-batch ordering, drained-token ordering) deleted by the PR.
- Harden the profile-scoped discovery test against sibling-state leaks.
- hermes dashboard --status now verifies each matched PID is alive AND
bound to a listening socket before reporting it, so stale PIDs and the
desktop app's IPC-only 'serve --port 0' backends no longer masquerade
as running dashboards (#58578).
- The git and Windows ZIP update paths share one
_finish_dashboard_update_cleanup(), so the ZIP fallback gets the same
stop/restart reporting.
- _kill_stale_dashboard_processes returns a structured
{matched, killed, failed, unrecovered} result; the explicit was-stopped
notice fires only for processes that could NOT be auto-restarted,
meshing with the auto-respawn from #72192.
_tool_ctx switched to build_tool_label in #55166, so every tool.start
carried an already-phrased string ("Running sleep 70 + 2 commands").
Both clients then apply their own verb on top: the TUI renders
Terminal("Running sleep 70 + 2 commands") and the desktop row reads
"Ran Running sleep 70 + 2 commands". The friendly labels stay where they
belong — the CLI spinner and the gateway progress line, which compose
verb + preview at their own call sites.
'Refreshing cua-driver (Computer Use)...' could hang for minutes on
Windows: when the driver's native check-update verb returned an
indeterminate result (old driver without the verb, offline, GitHub
rate-limited, or the probe timing out), install_cua_driver(upgrade=True)
fell through to the full upstream installer — a silent, output-captured
run with a 660s ceiling, plus install.ps1's 600s concurrency-lock wait
on Windows on top. Every 'hermes update' paid that cost.
Two changes:
- install_cua_driver() grows require_confirmed_update: with it set, an
indeterminate check keeps the installed version and returns fast,
printing the force path (hermes computer-use install --upgrade).
'hermes update' passes it; the explicit --upgrade CLI keeps the old
fall-through so a force refresh still works when the check can't
answer.
- cua_driver_update_check() default timeout is now 25s on Windows
(8s unchanged on POSIX): first-spawn of the exe under Defender /
SmartScreen routinely exceeds 8s, and a false timeout is exactly the
indeterminate result that used to trigger the multi-minute reinstall.
Strengthen the salvaged regression test to prove the end-to-end claim in
#56580/#68874: a DM-created task's terminal wake must build the creator's
':dm:<chat_id>' session key via build_session_key(), not a group-scoped
key that forks a fresh session. Sabotage-verified: reverting the watcher
to the hardcoded chat_type='group' fails this test.
DEFAULT_CONFIG declared "kanban" twice. Python keeps only the last
literal for a duplicate key, so the first kanban block was silently
dropped and its "auto_subscribe_on_create": True default never made it
into DEFAULT_CONFIG. The consumer in tools/kanban_tools.py masks the
miss with cfg_get(..., default=True), but the documented default was
absent from DEFAULT_CONFIG (so config templates / 'hermes config show'
omit it), and the duplicate key is a standing hazard: any future key
added to the first block would also vanish.
Merge auto_subscribe_on_create into the single canonical kanban block.
Adds a regression test asserting both default sets survive and a guard
against any duplicate top-level DEFAULT_CONFIG key.
Follow-up wave to #72170 resolving the remaining open MEDIA-delivery gaps:
- #24032: add .kmz/.kml/.geojson/.gpx to MEDIA_DELIVERY_EXTS, and recover
unknown-extension paths containing spaces via _match_extensionless_path —
validation-gated forward extension across single spaces (bounded at 8
tokens, stops at newline / next MEDIA: keyword). The regex itself stays
non-greedy and whitespace-bounded so the #68773 absorption bug class
cannot return; the on-disk file check is the oracle.
- #16434 (streaming half): _strip_media_tag_directives now uses the same
mask-as-locator pattern as extract_media, so MEDIA tags inside fenced
code blocks, inline-code examples, and JSON string values survive in
streamed display text instead of being mangled. Display and delivery
now agree on every protected-span rule.
- Updated three stream_consumer display expectations that pinned the old
inconsistent behavior (backtick/double-quote tags stripped from display
while delivery never attempted them).
* feat(billing): add payment_method union to the billing-state wire type
* feat(billing): carry the payment-method union through the gateway
NAS now sends a typed `paymentMethod` union on /api/billing/state alongside
the legacy `card` field. The gateway parses payloads field-by-field, so the
new field was dropped on the floor before reaching TUI/Desktop.
Parse it into PaymentMethodInfo and re-emit it as snake_case `payment_method`,
matching the translation the rest of this payload already does. The payment
method id is deliberately not carried through — clients have no use for it.
No client rendering changes: `card` stays populated for cards, so every
existing consumer behaves exactly as before and the new field is inert until
a surface opts into reading it.
* fix(billing): send only the fields each payment-method kind declares
The serializer emitted every key for every kind, so a Link method went out
carrying brand, last4 and wallet set to null. That contradicts the shared
type, where each kind declares its own fields: a client testing `'brand' in
pm` would read every Link method as a card, and one trusting the declared
non-null `brand` could crash on it.
Send each kind's own fields, and forward an unrecognized kind by name alone
so a client that predates it can still say something honest. The shared type
gains the matching fallback arm its own comment already promised.
Tests now follow a payload from the server response through to the client
wire for each kind, rather than checking parsing and serializing separately —
which is why the old expectation locked in the wrong shape without noticing.
* fix(billing): keep the payment-method kind narrowable
Typing the fallback arm's kind as `string & {}` borrowed a trick that only
works on unions of plain strings. On a union of objects it makes the
discriminant non-literal, so TypeScript stops narrowing on every arm — even
`if (pm.kind === 'card')` no longer gives you `brand`. The first client to
use this would have hit a compile error and reached for a cast.
An unrecognised kind now arrives as `unknown`, carrying the real name
alongside it, so every arm has a literal discriminant. A type-level test
pins this: it fails to compile if the discriminant stops narrowing.
The parser settles which kind it is, the way the card parser already does,
so the record cannot hold fields that do not belong to its kind and the
serializer no longer re-checks. The type comment also stops claiming `card`
is a safe signal — it is null for Link, so `!card` does not mean "nothing on
file".
The sweeper review on #66435 flagged that the collector doesn't bind
session["profile_home"]. That binding is intentionally unnecessary: the
kanban board is shared across profiles by design — kanban_home() anchors
on get_default_hermes_root(), which resolves the process env and ignores
context-local profile overrides (see the kanban_db.py module docstring).
Add a regression test that claims a subscription while a foreign-profile
set_hermes_home_override() is active, proving delivery still works for
non-launch-profile Desktop sessions.
Covers the poller wiring above _collect_kanban_notifications, per the
hermes-sweeper review: status.update emission, agent-turn dispatch via
_run_prompt_submit when the session is idle, and the busy-session
pending buffer that flushes once the session goes idle.
kanban_create auto-subscribes TUI/desktop sessions with platform="tui" and
chat_id=HERMES_SESSION_KEY, and tools/kanban_tools.py documents that the
TUI notification poller (tui_gateway/server.py) reads kanban_notify_subs
and posts completion messages into the running session — but that reader
was never implemented. The poller only watched process_registry completion
events, and the gateway notifier skips "tui" rows because no such
messaging adapter exists. Result: subscriptions accumulate with
last_event_id=0 forever and no task event is ever delivered (18 subs,
29 terminal events, 0 deliveries in the report).
Implement the missing delivery path in the TUI notification poller:
- every 5s, claim unseen terminal events for this session's
platform="tui" subscriptions via claim_unseen_events_for_sub — the
same atomic cursor-claim the gateway notifier uses, so an event is
delivered exactly once even with a gateway polling the same board DB
- format events with the same wording as the gateway notifier
(done/blocked/gave up/crashed/timed out/status; archived and
unblocked are claimed but silent, so they can't wedge the cursor)
- emit a status.update for user visibility, then chain an agent turn
when the session is idle — mirroring process-completion handling;
claimed events buffer in the session until it goes idle since the
cursor (unlike the process queue) cannot re-queue
- unsubscribe only at a truly final task status (done/archived),
matching the gateway rule so respawned tasks keep notifying
- multi-board: iterate boards, polling each resolved DB path once
Fixes#59890
The default max_iterations/agent.max_turns budget was set when long
agentic runs were rare; complex tasks now routinely exceed 90 tool
calls. Raise the default to 500 across every surface that hardcodes
the fallback: AIAgent constructor, DEFAULT_CONFIG, CLI resolution
chain, gateway env bridge, cron scheduler, and TUI gateway. Explicit
user config values are unaffected (deep-merge preserves them; no
_config_version bump needed).
Docs (en + zh-Hans), CLI help text, tips, and pinned tests updated
to match.
Two remaining formatting variants that silently killed file delivery:
- A trailing sentence period (MEDIA:/x/data.csv.) failed the boundary
lookahead, so the tag neither extracted nor stripped. The period is now
accepted as a boundary only when followed by whitespace/EOL, keeping
multi-part extensions (.tar.gz) intact.
- A whole tag wrapped in inline code (`MEDIA:/x/data.csv`) was masked as
a prose example (#35695). Models routinely format paths as inline code,
eating real deliveries. Inline-code tags now deliver when the path
validates on disk; non-existent example paths stay masked and fenced
code blocks remain fully masked.
Adds a regression matrix covering both plus the salvaged contributor
fixes (emphasis wrap, glued tags, glued [[as_document]], dedupe,
unknown-extension and extensionless delivery).
Surface a user-visible delivery notice when non-streaming media dispatch
gets success=False after MEDIA tags were stripped, and validate forum
starter-message attachments the same way as direct channel sends (#66797).
Outbound MEDIA video/document tags on the Discord non-streaming path
were extracted (stripped from the visible text) but never delivered:
no attachment, no error, and no dispatch log line (#66797). The
open-handle discord.File plus singular file= form could race Discord's
multipart encoder after an earlier image batch on the same channel and
return a successful message carrying zero attachments.
Fix _send_file_attachment (used by send_video/send_document/
send_image_file) to:
- use a path-based discord.File via the plural files=[...] kwarg, the
same pattern as the working send_multiple_images batch path;
- pre-flight os.path.isfile and return a File not found result instead
of raising deep inside discord.File;
- fail loud when Discord accepts the message but attaches nothing, so
the dispatch loop surfaces a warning rather than a silent drop;
- add INFO dispatch logs (base non-image MEDIA fan-out, video send, and
file attachment) so a MEDIA:.mp4 cannot vanish without a trace.
Tests (tests/gateway/test_discord_send.py):
- path-based files=[...] kwarg used, singular file= unset;
- fail-loud when the returned message has no attachments;
- missing file fails fast without resolving the channel;
- forum-parent delivery routes through create_thread with files=[...];
- end-to-end image+video response routes the mp4 to send_video while
images still batch.
Fixes#66797
When the same file is referenced multiple times in one message (common
when the agent emits MEDIA tags both inline and in a summary footer),
the platform adapter uploads/sends the same file twice — visible as
duplicate attachments in Telegram, duplicate posts in Slack, etc.
Set-based dedup on the expanded path, preserving first-occurrence order.
Two MEDIA: path tags emitted back-to-back without a separator merged
into a single invalid path and were silently dropped. The same happened
for extension-less tags.
Root cause: both regexes in gateway/platforms/base.py used greedy
quantifiers in their path class, causing adjacent tags to be absorbed.
Fix: make both regexes non-greedy (add ? to quantifiers) and add
MEDIA: to the trailing lookahead boundary set so the next MEDIA:
keyword stops the current match cleanly.
Models routinely present a file to the user with the delivery tag wrapped in
Markdown emphasis — `**MEDIA:/path.pptx**`, `*MEDIA:/path*`, `_MEDIA:/path_`.
MEDIA_TAG_CLEANUP_RE only tolerated a single leading/trailing quote or backtick
(`[`"']?`), and its closing lookahead set excluded `*` and `_`, so an
emphasis-wrapped tag never matched. The file was then silently never delivered
and the literal `MEDIA:/path` text leaked into the chat instead — the user sees
a path, not the attachment.
Allow a short run of emphasis/quote markers (`[`"'*_]{0,3}`) on both sides of
the tag and add `*`/`_` to the closing lookahead. Code-block, inline-code and
blockquote contexts are still neutralised earlier by `_mask_protected_spans`
(#35695), so documentation/example tags remain non-deliverable; the
absolute-path anchor still rejects relative paths; `_` inside a filename is
unaffected.
Adds regression coverage in TestExtractMedia for bold/italic/underscore
wrapping, mid-prose bold, emphasis-wrapped .html, underscore-in-filename, and
emphasis-wrapped relative-path rejection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MEDIA_TAG_CLEANUP_RE failed to match when a directive like [[as_document]]
was concatenated directly to the file extension without whitespace
(e.g., MEDIA:/home/user/report.xlsx[[as_document]]). This caused the
file to be silently not delivered while the gateway reported success.
Root cause: the lookahead character class [\s`",;:)\}\]|$) did not
include [, so [[as_document]] immediately after the extension broke
the lookahead assertion.
Fix: add \[ to the lookahead class so directives can follow the path
without whitespace. This is safe because [ is already stripped elsewhere
in the same file via .replace("[[as_document]]", "").
Added regression tests covering:
- Directive glued to extension (issue case)
- Directive with whitespace (baseline)
- Tag at end of string ($ anchor)
The identity-refresh TTL used 0.0 as the "never checked" sentinel and
compared it against time.monotonic(). That epoch is arbitrary and starts
near zero on a freshly-booted host, so on CI runners and containers
`monotonic() - 0.0` was itself below the TTL — "never checked" read as
"checked just now" and the first identity refresh was suppressed for the
first 5 minutes of uptime. The stale-handle recovery therefore did nothing
on exactly the machines most likely to be freshly booted.
Invisible on a long-lived dev box (uptime >> TTL); caught by CI.
- Sentinel is now None, meaning never checked and always stale.
- Both TTL comparison sites route through _bot_identity_is_fresh().
- Regression test pins the invariant under a faked 12s-uptime clock.
Verified by re-running the recheck tests against a simulated 3s-uptime
host: green with the fix, and restoring the 0.0 sentinel reproduces the
exact CI failure locally.
Renaming a Telegram bot's @username in BotFather silently stopped the
gateway from answering in groups.
PTB caches getMe() in Bot._bot_user and only rewrites it inside get_me(),
so after a rename the adapter kept comparing mentions against the OLD
handle. The exclusive-mention gate then saw the new @handle, failed to
match itself, and concluded the message was addressed to a different bot
— dropping it before the reply and wake-word fallbacks could run. Native
replies to the bot were discarded too. Polling mode recovered on the next
90s heartbeat; webhook mode never calls get_me() again, so it stayed dead
until restart.
Separately, the bot-handle pattern assumed every bot username ends in
"bot". Collectible (Fragment) usernames can be assigned to bots and drop
that suffix (@jarvis, @pic), so such a bot could not recognise its own
handle in the entity-less fallback and was suppressed by any message that
also named another bot.
- Route every mention comparison through _current_bot_username(), which
prefers the last observed handle over PTB's cache.
- Learn the live handle from inbound updates: Telegram stamps the current
username on our own messages and on reply_to_message. Guarded by user
id, so another account's handle is never adopted.
- Re-check identity out of band (TTL-bounded, one getMe per 5 min) when
the exclusive gate is about to drop a message — the exact stale-handle
symptom — so the mistake self-corrects instead of persisting.
- Refresh identity in webhook mode via a dedicated low-frequency loop,
cancelled on the same teardown fence as the heartbeat.
- Match our own handle by identity rather than shape. Foreign handles keep
the deliberate "...bot" narrowing so human @handles still never act as
routing hints (the intent behind ce4d857021).
Validation: 11 regression tests; sabotage runs confirm each behavioral
test fails with the fix reverted. 157 tests green across the Telegram
gating, reconnect, and topic-mode suites.
Connecting Discord asked five questions when the platform needs one. The card
listed a home channel ID you need Developer Mode to copy, an allow-all-users
security toggle, a reply-threading preference, and a home channel display name
— all with working defaults, none discoverable from the form.
Drops them from the setup surfaces entirely: the dashboard/Desktop channel
cards and the `hermes setup gateway` wizard. Discord is now bot token +
allowlist. Matrix drops from 11 fields to 7, Mattermost from 6 to 3.
Suffix-matched (`*_HOME_CHANNEL*`, `*_ALLOW_ALL_USERS`, `*_REPLY_TO_MODE`,
`*_REQUIRE_MENTION`, `*_AUTO_THREAD`, `*_FREE_RESPONSE_*`, `*_PROXY`) so plugin
platforms nobody enumerated get the same treatment. Allowlists deliberately
stay — the gateway denies everyone until one is set, so that IS the decision a
new user has to make. Required credentials are never hidden.
Nothing is removed from the product. The vars still work through
`hermes config set`, .env, and config.yaml, the gateway reads them unchanged,
and dropping them from the cards hands them back to the Keys page rather than
orphaning them (Keys hides only what a Channels card owns).
CI slices 2 and 7 caught three tests broken by always-defer:
- /tools (CLI show_tools + TUI gateway tools.show) now passes
skip_tool_search_assembly=True — it's a discovery/inspection surface,
so users verifying an MCP installed must see deferred tools, not a
collapsed bridge row. This also fixes
test_slash_worker_mcp_discovery (profile MCP tool visible in /tools).
- test_plugins.py::test_plugin_tools_in_definitions: 'visible' becomes
'reachable' — direct schema OR listed in the bridge description;
scope-exclusion assertions unchanged (not direct AND not listed).
- test_discord_tool.py dynamic-schema-rebuild test reads the
pre-assembly list (the rebuilt schema is what tool_describe serves).
Banner/status tool counts intentionally keep the post-assembly view —
they reflect what the model actually sees.
The include/exclude filter matched exact names only — glob-style entries
('*_radar_*') silently matched nothing, so a Cloudflare flat-mode config
meant to trim 3,320 tools to ~1,900 actually registered 3,319. Unmatched
patterns produced no warning.
- matches_name_filter(): exact membership first (O(1) for literal lists),
then fnmatch.fnmatchcase for entries containing * ? [ — same pattern
semantics as approvals.deny. Entries without metacharacters stay
strictly literal ('docs' never matches 'docs_search').
- _should_register() uses it for both include and exclude (symmetric)
- hermes mcp tools picker (mcp_config.py) pre-selection uses the same
matcher so the UI agrees with runtime registration
E2E against the live Cloudflare capture with the real exclude list:
3,320 -> 1,905 surviving (1,415 excluded); radar/DLP gone,
purge_cache/dns_records kept. 220/220 mcp_tool tests (4 new).
Teknium review changes on the tiered policy:
1. threshold_pct default 10 -> 5 (listing budget = min(5% of context,
listing_max_tokens)); unknown-context fallback 20K -> 10K.
2. Tier 2 no longer leaves the model blind: when even names-only doesn't
fit, the bridge description now carries a one-line-per-server summary
('cloudflare (3320 tools)') plus an instruction to search FIRST rather
than substitute a generic tool or claim the capability is missing —
the measured tier-2 failure mode (core-tool substitution) at zero
meaningful token cost (~50 tokens/server).
3. Listing degradation is now PER SERVER, largest first: one oversized
server (Cloudflare) collapses to its summary line while small
co-attached servers (Linear) keep their full per-tool listings
('mixed' form). Previously global: attaching Cloudflare next to
Linear silently cost Linear its listing. Greedy fit is deterministic
(size then label) so the rendered block stays byte-stable per catalog
— prompt-prefix cache safe.
E2E on real captures (defaults, 200K ctx): linear alone -> tier 1 full;
unreal alone -> tier 2 groups (5% budget) / tier 1 names at 1M;
cloudflare alone -> tier 2 groups; linear+cloudflare -> tier 1 MIXED
(linear fully listed, cloudflare summarized). 48/48 tests.
Cloudflare's flat API MCP ships 61 property keys that violate Anthropic's
^[a-zA-Z0-9_.-]{1,64}$ pattern (query-filter params like 'issue_class~neq'
and 'meta.<field>[<operator>]'). One bad key anywhere in the tools array
400s the ENTIRE request — measured live: Anthropic, Bedrock, Google Vertex,
and Azure all rejected an eager 3,320-tool Cloudflare request at validation,
before token limits even applied.
- schema_sanitizer: rename non-conforming property keys deterministically
(bad chars -> '_', 64-char truncation, collision dedup with numeric
suffixes), nested schemas included; required[] remapped alongside
- unrename_tool_args(): reverse map applied in coerce_tool_args at dispatch,
so the MCP server receives the original wire names; recurses into object
values and array items
- deterministic on both sides: the rename map is recomputed from the
registry's original schema at dispatch time, no state carried
E2E on the real capture: 61 -> 0 violations across 3,320 tools; round-trip
verified on get_accounts_intel_attacksurfacereport_issues (5 '~neq' keys).
Tier 0: no MCP/plugin tools -> everything eager (pass-through).
Tier 1: deferred tools whose catalog listing fits min(threshold_pct%
of context, listing_max_tokens) -> bridge + skills-style
listing, degrading to names-only over budget.
Tier 2: listing over budget even names-only (Cloudflare's flat API
surface: 3,320 tools, names alone ~32K tokens) -> bare bridge,
discovery through tool_search only.
The old activation threshold (defer only when schemas > threshold_pct
of context) let mid-size catalogs ride eager and pay full schema cost;
with servers like Cloudflare (~597K tokens of schema, would not even
fit a 200K window) the binary gate is the wrong shape. Activation is
now driven purely by deferrable-tool presence; threshold_pct is
repurposed as the listing budget's context-relative leg.
- AssemblyResult gains tier + listing_form for observability
- listing_max_tokens default 4000 -> 20000 (cap 60000) so an 830-tool
catalog keeps a names-only listing while Cloudflare-scale drops to
bare bridge
- E2E verified against real captures: Linear 24 tools -> tier 1 full,
Epic UE 5.8 830 tools -> tier 1 names-only, Cloudflare 3,320 tools
-> tier 2 (both 200K and 1M context)
Deferred MCP/plugin tools become invisible once the tool_search bridge
activates — live benchmarking (48 runs, Claude Haiku 4.5) showed models
substituting visible core tools (terminal/web_search/browser) for deferred
capabilities or declaring them nonexistent instead of searching: 16/24
task success vs 24/24 with eager loading.
Skills never had this failure mode because every skill keeps a ~21-token
name+description listing line in the system prompt. This ports that exact
pattern to the tool bridge: when tool_search activates, a grouped manifest
of every deferred tool (name + first sentence of description, clipped to
60 chars, grouped per MCP server / toolset) is embedded in the tool_search
bridge description.
- tools/tool_search.py: build_catalog_listing() with deterministic
ordering (byte-stable across assemblies -> prompt prefix stays
cacheable); token-budget fallbacks full -> names-only -> legacy bare
count; bridge_tool_schemas(listing=...) embeds it and instructs the
model to skip tool_search when the exact name is visible (one fewer
round-trip per use)
- config: tools.tool_search.listing auto|on|off (default auto),
listing_max_tokens (default 4000, clamped 200..20000); legacy bool
shapes keep working
- tests: 8 new tests (config parsing/clamps, short-desc clipping,
deterministic rendering, budget fallbacks, bridge embedding, assembly
on/off paths); full file green (47 passed)
- docs: tool-search.md config table + rationale
- scripts/tool_search_livetest2.py: benchmark harness v2 with real
per-call token accounting (normalize_usage spy) and a third 'listing'
mode for A/B/C comparison
The sidebar strip and the Channels page could contradict each other on
the same page load — "Gateway running" next to "The gateway is not
running." /api/status and /api/messaging/platforms each open-coded their
own liveness ladder: status probed GATEWAY_HEALTH_URL and scoped its
PID/state reads to the requested profile, messaging did neither and used
the uncached raw PID probe.
Three deployments hit the split: a cross-container gateway (no local PID,
only the health probe can see it), a profile-scoped dashboard (messaging
borrowed a DIFFERENT profile's runtime state, reporting a false
"connected" that hides a real outage — #71211), and a launch-service
managed gateway with no PID file.
Adds resolve_gateway_liveness() in gateway/status.py as the single ladder
(cached PID -> HTTP health probe -> runtime-status PID with
expected_home) and routes both endpoints, /api/messaging/platforms/{id}/test,
and the kanban dispatcher-presence probe through it. Probe callables are
injectable so the existing monkeypatch seams keep working, and
GatewayLiveness.probe_error distinguishes "down" from "couldn't tell" so
the kanban warning keeps failing OPEN instead of crying wolf.
Closes#71211.
An agent told to work in a fresh git worktree does exactly that — creates
it, cds in, and runs every later command there — but the session stayed
pinned to the checkout it started in. The desktop kept labelling the chat
with the primary branch while all the work landed somewhere else.
The desktop half already existed: session.info carrying a moved cwd runs
followActiveSessionCwd, which refreshes the project tree and scopes the
sidebar into the new project. The backend just never reported the move.
Reconcile the session's cwd against terminal_tool's per-session record at
the end of a turn, when the agent has stopped moving and its recorded cwd
is a stable answer. A plain cd stays what it always was — not a workspace
move — so the reconcile only fires when the recorded cwd sits in a
different git working tree than the session's workspace.
CuaDriverBackend caches a long-lived cua-driver subprocess for the life of
the Hermes process, and stop() was never called from anywhere — the driver
outlived the session that spawned it. #69903 stopped the orphan from pegging
a core by disabling the cursor overlay, but left the process behind; this is
item 3 of #28152 ("Hermes does not keep the driver alive after tool
completion").
Register an atexit hook, mirroring browser_tool's
atexit.register(_emergency_cleanup_all_sessions). atexit only, no signal
handlers, for the prompt_toolkit reason documented there. reset_backend_for_tests
now reuses the same teardown instead of repeating it.
`preview` is the head of the first user message and the title fallback on
every surface — sidebar rows, pickers, exports, the desktop's sessionTitle().
An untitled /skill session therefore read `[IMPORTANT: The user has invoked
the "work" skill, indicatin...` wherever it appeared.
A scaffolded row now selects a wide enough excerpt to reach the typed
instruction (head + tail spliced for a long body) and shapes it through
describe_skill_invocation(). Because previews are computed on read, existing
sessions are corrected without a migration.
The six copies of the preview subquery and four copies of its shaping collapse
into one expression and one helper along the way, and the /rewind picker gets
the same treatment.
generate_title() sent the first 500 characters of the user turn to the
auxiliary model. On a /skill invocation those characters are the skill's own
opening prose, so the session got named after the skill instead of the request
— /work sessions came back as "Isolated Git Worktree Setup".
Route the turn through describe_skill_invocation() first, so the titler sees
what the user typed. Also keep only the first line of the response: a model
that ignores "return ONLY the title" and answers the prompt would otherwise
have a shell transcript stored as the title, truncated mid-command.
A /skill invocation expands into a message that embeds the whole skill body.
Anything that summarizes a user turn from its raw content reads the skill's
prose as if the user had written it.
describe_skill_invocation() sits next to the existing extractor and reuses its
markers, returning `/work — fix the title leak` for an invocation with an
instruction and `/work` for a bare one. It also exports the SQL LIKE pattern
and excerpt-joint sentinel that listing queries need to recognize scaffolding
before a row reaches Python.
Reported in Discord by @spherohero: `sessions recover --allow-partial` copied
20,817 of 20,824 message rows, then orphan cleanup deleted every one of them
because no session row was salvageable. Final output: 0 sessions, 0 messages.
The salvage worked and then threw the result away -- the exact opposite of
what --allow-partial exists to do.
_cleanup_partial_orphans() removed dependent rows whose session_id had no
matching sessions row. That is correct when a few sessions are lost; it is
catastrophic when the sessions b-tree is damaged worse than messages, which
is the common shape (sessions is small and hot, messages is large).
Reproduced exactly: 500 readable messages, unreadable sessions -> 500
copied, 500 removed, empty output.
Now _reconstruct_missing_sessions() runs FIRST, inside the same transaction,
synthesizing a minimal row per orphaned session_id (only id/source/started_at
are NOT NULL). started_at comes from the earliest surviving message.
Placeholders carry source='recovered' and an explicit title so a fabricated
session can never be mistaken for an original. Same repro now retains all
500 messages under 1 reconstructed session.
Reconstruction is reported as LOSS, not a clean recovery: session metadata
(title, model, timestamps, cost) is genuinely gone even though the
conversation text survived. loss_detected=True, partial=True,
complete=False, with a warning naming the counts.
Also fixes total_removed_or_relinked, which summed every dict value and would
now have counted retained messages as removed.
Sabotage-verified: removing the reconstruction call restores the wipe and
fails the test. 942 targeted tests green.
Two existing cases in tests/test_tui_gateway_server.py covered the row's cwd
contract and this change had to answer both.
`_persisted_session_cwd` reached through `_session_cwd`, which falls back to
the gateway-wide completion cwd when the session carries none. That belongs to
no session in particular, so a session that never had a directory was given
one. Read the session's own `cwd` instead.
The remaining case asserted that a terminal session's directory is discarded,
which is the behavior this branch deliberately changes. Split it in two: a
terminal session now records its workspace, and the desktop keeps the "No
workspace" default it was written to protect.