Salvage of PR #99679 by 686f6c61, rebased onto current main.
/new and idle/daily resets keep parent_session_id for durable lineage,
and the sidebar's flattenSessionsWithBranches nested on that field
alone — so a platform's chats collapsed into one growing nested chain
of branches even though none of them were /branch forks. The backend
records the distinction on disk already (model_config._reset_from vs
_branched_from, gateway/session_recovery.py:433) but list payloads
strip model_config before any UI can read it.
- forkParentId(): nest only genuine forks — _branched_from wins, a
_reset_from parent means top-level sibling, legacy/optimistic rows
with only parent_session_id keep nesting.
- _session_row_dict lifts _reset_from/_branched_from out of
model_config so compact list rows (which strip that heavy field)
carry the distinction; tui_gateway project-tree rows project the two
markers too.
- Optimistic desktop /branch rows stamp _branched_from so the flat
render stays correct before the authoritative row arrives.
Disk lineage is unchanged. Tests: a _reset_from chain renders flat,
a genuine fork still nests beside a reset sharing the same parent,
and list_sessions_rich promotes both markers (also under
compact_rows=True).
Closes#99648.
Salvage of PR #98475 by kokhlo, rebased onto current main (deps array
re-merged after display.resume_last_session / disk-plugins landed).
The cleanup effect in useDesktopIntegrations drops the remembered
navigation once a bounded resume retry exhausts, but the persistence
effect re-runs on ordinary session-list refreshes with no idea the
session is stranded — it writes the dead routed id straight back into
hermes.desktop.lastSessionId/lastRoute, so every relaunch reopens the
resume-error screen until the user manually hits New chat.
Treat resumeExhaustedSessionId as a write barrier in the persistence
effect: skip the setRememberedSessionId/setRememberedRoute writes while
the routed id is the exhausted one, and add the latch to the effect's
deps. Regression test covers the routed-at-the-dead-session shape plus
a later session-list refresh that re-runs only the persistence effect.
Closes#98467.
Salvage of PR #102346 by teamster22, rebased onto current main.
The sidebar fetch splits its rows into three mutually exclusive
source-scoped slices ($sessions recents / $cronSessions /
$messagingSessions), and recents excludes every messaging and cron
source. All three tab-title resolution sites (tileStoredRow,
syncWorkspaceTitle, workspaceDragPayload) searched recents only, so a
telegram/matrix/discord-origin session tab read "New session" forever
even while its sidebar row showed the real title — a partition miss,
not a timing miss, so no activity could ever fix it.
Route the three resolvers through ownerLookupSessionRows() (the
three-slice concat added for this bug class in #95633, which preserves
$sessions' array identity when the other slices are empty) and add
$cronSessions / $messagingSessions to watchSessionTiles' `also` array
and syncWorkspaceTitle's listeners so the late-arriving slices re-sync
the label.
Closes#95096.
Checkpoint refresh treated a failed start-time check as a collected exit
and queued a completion. A live PID whose start time matches, or whose
start time cannot be read, stays running. A reused PID is closed without
being signalled. A gone PID is pruned. A completion is emitted only when
an exit status was collected.
Review of the run-history fallback:
- Merge agent sessions with script-only output docs per execution instead of
branching on 'any sessions exist': a job converted to no_agent keeps its id
(update_job supports it), so a surviving historical agent session must not
hide newer script-only fires. Same-execution rows dedupe by session span.
- Decode output-doc filenames and read the execution ledger inside the owner
profile's home scope: hermes_time resolves the configured zone through the
current HERMES_HOME, so a cross-profile request decoded with the
dashboard's zone and read the dashboard's executions.db.
- Attach per-run status from the execution ledger (one row per fire) matched
by claim window instead of last_run_at proximity, and surface terminal
attempts with no surviving doc as their own rows — the latest failed run
no longer disappears behind (or relabels) an older successful document.
- Read output docs with encoding='utf-8-sig' (Windows footgun: PowerShell and
some editors BOM files; plain utf-8 breaks on BOM-prefixed docs).
Three regressions cover the converted-job merge, the per-profile timezone
decode, and the missing-newest-doc case.
The cron detail rendered only the prompt, so script-only (no_agent) jobs —
which have prompt: "" — showed a blank description; the script field, the one
meaningful detail, was never displayed. The detail now shows a muted 'script'
badge next to the state pill and renders the script under a Script label when
the prompt is empty. Output-doc run rows (source='cron_output') have no backing
session, so they render as plain rows without a chat-navigation affordance in
both the detail run history and the sidebar quick-peek.
Fixes#42433 (blank-detail and script-badge facets)
Salvaged from #77382 by @andrexibiza (jobDescription model + detail badge/label
and tests), adapted onto current main and extended to the sidebar peek.
Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Script-only (no_agent) jobs deliberately skip SessionDB, so their run history
was permanently empty — the desktop showed "No runs" next to hundreds of
completed fires. When no session rows exist, the runs endpoint now falls back to
the job's output docs under cron/output/<job_id>/, one row per fire, with the
latest status prefixed; with no surviving docs but a recorded last_run_at, a
single metadata row is surfaced instead. Rows mirror /api/sessions shape with
source='cron_output' and a cron_output: id prefix that cannot collide with
SessionDB cron_{job_id}_* session ids.
Filename timestamps are read back with hermes_time.get_timezone() — the same
configured zone save_job_output writes them with — not a fixed-offset snapshot
of today's local offset (wrong by hours when HERMES_TIMEZONE differs from the
server zone, and by an hour across DST).
Fixes#42433 (run-history facet)
Salvaged from #61403 by @LeonSGP43 (fallback design, metadata-only row, tests),
reworked per its review: timezone handling uses the configured zone and the
tests pin started_at under a configured-zone mismatch.
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Skill slashes that miss the 4018 gate print the loading banner and park
the prompt on unread _pending_input. Refuse before process_command, and
return command.dispatch's skill payload or a hard error when the skill
scan raises so fail-open cannot drop the turn.
Main stopped reading HERMES_DESKTOP_IGNORE_EXISTING when the PATH rung was
removed, so the ACTIVE_HERMES_ROOT rung always started a local backend.
Gate that rung in resolveHermesBackend so the flag falls through to the
first-run connect-or-install choice. The post-install re-resolve marks the
runtime as installed by this launch, so it and later resolves use it
instead of starting the installer again. Replace the unused rung model
(its PATH and system-Python rungs no longer exist) with the gate main.ts
calls.
HERMES_DESKTOP_IGNORE_EXISTING=1 only wrapped the PATH probe, so a usable
active install or system Python still started a local serve. Skipping those
rungs on every resolve would make the post-bootstrap re-resolve return
bootstrap-needed and start the installer again. The first pass now falls
through to connect/onboarding; the re-resolve keeps the runtime just installed.
Fixes#117682
click-session flake (#97982): a bare scrollIntoView() smooth scroll could be dropped under load, and the script
slept a fixed 3000ms before reading state. Extract click-session-helpers.mjs:
instant centered scroll, a bounded poll-for-composer loop instead of the
fixed sleep, and correct nested CDP envelope unwrapping (the old read logged
undefined).
Intel-Mac installer docs (#99033): the Hermes-Setup.dmg bootstrap installer
is built for Apple Silicon only, so Intel Macs hit "not supported on this
Mac". The desktop release pipeline already builds a native darwin-x64
bundle, so the docs now scope the arm64 limit to the bootstrap installer and
name the darwin-x64 bundle (or the CLI plus `hermes desktop`) as the Intel
path, in the desktop README and the platform-support build-targets section.
cua-driver autostart opt-in (#97389): Windows installs registered the
cua-driver-serve scheduled task on every install, with no opt-out, and
treated the task as an install-readiness requirement. Gate the
install-ready check and _repair_cua_driver_autostart_windows on the new
computer_use.autostart config key (default false = on-demand, fails
closed), extract the registration PowerShell into a testable helper, and
document the opt-in (EN + zh-Hans). Windows-only code path: unit tests
cover the registration args and the config gate; live Windows
verification pending.
A lagged profile store is still healed once. When that read fails, or the
one-shot heal is exhausted and the profile contributed no rows, the sidebar
slice omits sessions and sets failed+retry so Desktop can offer Retry.
A successful empty store and a corrupt store keep their existing shapes.
--status probed the port parsed from argv, so a backend launched with
--port 0 (Desktop SSH backends) was always skipped while --stop still
killed it. Use the bind the spawn ledger records after listening.
Desktop and TUI agents are built by tui_gateway.server._make_agent, which
never runs the classic CLI bootstrap and passed no prefill_messages to
AIAgent, so a configured prefill_messages_file was silently ignored there.
Resolve it with the CLI's own resolver/loader (env > prefill_messages_file >
legacy agent.prefill_messages_file). The loader takes an optional base_dir so
relative paths resolve against the profile building the agent, not the
launch home.
Co-authored-by: leakedd <66937184+leakedd@users.noreply.github.com>
`mcp add` now also says how to load the tools into open sessions. `mcp
configure` changes a live server's tool selection, which a new session
does not re-read, so it points at /reload-mcp instead of "start a new
session".
Co-authored-by: spfcraze <spfcraze@users.noreply.github.com>
A long-lived backend (Desktop's tui_gateway) runs MCP discovery once. Every
agent build re-enters start_background_mcp_discovery, but that returned as
soon as any server was connected, so a server added with `hermes mcp add`
after startup never reached a new session; only /reload-mcp or a restart
picked it up.
Re-entry now also runs discovery when an enabled configured server is not
live and its connect cooldown has lapsed. Discovery is additive, so live
servers and open sessions are untouched; the new session gets the tools.
The pending-server computation is factored out of
reconcile_mcp_servers_with_config so both callers share it.
Refs #76954
A marker proved a turn had started, never that its writer had died, so a
second backend resuming the same session over a shared HERMES_HOME read a
sibling's live marker as crash evidence and started a duplicate turn over it.
record_turn_start now stamps writer_pid and, when readable, writer_start_time
(process create time, so a recycled pid cannot pass as the original writer)
and logs the identity — never the prompt. New marker_writer_state(entry)
answers alive/dead/unknown from active_sessions._pid_liveness, and
_maybe_schedule_auto_continue holds back when the writer is alive and is not
this process: no continuation, no "Resuming interrupted turn…" frame, and the
marker stays for its owner to clear.
Adds the two-backend regression: a real second process writes the marker and
keeps running while B resumes S (nothing scheduled, marker intact), then exits
and the same resume does schedule — so the live-writer gate is what defers B,
not some other switch.
The existing provider-card cases only ever mark shared profiles primary:
true, so the branch where buildProviderKeyGroups must prefer the card's
own key over a primary:false alias was uncovered. Add the CN Coding Plan
shape: own index-0 var primary, shared DASHSCOPE_API_KEY contributed by
the same card as a fallback alias, and assert Save writes the CN-specific
var and never the shared one.
Also restore the plugins-store named imports the toggle tests use after
the rebase onto the catalog refactor.
_row() forwarded provider, provider_label and provider_profiles from the
catalog metadata but dropped provider_primary, so a provider card's own
index-0 credential (ALIBABA_CODING_PLAN_CN_API_KEY) reached the Keys tab
with provider_primary unset. buildProviderKeyGroups then fell through to
the first non-advanced key var — which, for the (Coding Plan, China) and
(Token Plan, China) cards, is a shared fallback alias (DASHSCOPE_API_KEY /
ALIBABA_TOKEN_PLAN_API_KEY) contributed by peer profiles with primary:
false. The card's Paste key wrote a foreign tier's credential and the
CN-specific var was rendered nowhere when unset.
Regression test: test_get_api_env_passes_provider_primary_through pins
the CN card's own key as provider_primary=True and the shared DASHSCOPE
alias as primary=False from that provider's profile.
The Desktop switch flipped the panel before the toolset PUT, so a rejected
write left panel on / tools off (or the reverse) with only a toast. Write the
toolset first and move the panel only to what the backend holds: on a
rejection re-read the toolsets list (a timeout may still have committed) and
follow it; if the re-read also fails, leave the panel untouched. The switch is
disabled while the write is in flight and live again for a retry.
The Plugins page's Desktop-relevant toggle was UI-only: flipping it never
told the backend, so kanban stayed active regardless. The switch now also
calls setToolsetEnabled('kanban', on, profile) — the same
PUT /api/tools/toolsets/kanban route the Toolsets tab uses — invalidates
the toolsets query cache, and confirms with a toast (en/fr/de/es).
Fixes#96969
Co-authored-by: previous contributors' direction via #101582
RunClock ticked from tasks.started_at (the task's first-ever start), so after
a review timeout + retry a healthy current run showed cumulative card age
(e.g. working · 2h for a minutes-old run).
_task_dict() now emits current_run_started_at from the run row
tasks.current_run_id points at (batched single query, same as latest
summaries), and RunClock prefers it, falling back to started_at for older
backends. Fixes#99819.
Help advertises /clear as start a new session, but desktop treated it
as a TUI-only screen clear so history and context usage never reset.
Alias it onto the existing /new action. Stop/interrupt is unchanged.
Existing directories were reported as success:true while the preview
pane opened nothing. Fail closed with an explicit error and do not
emit preview.open. HTTP(S) URLs and regular files are unchanged.
test_openssl_installs_once_and_rejects_damaged_shared_install and
test_native_build_command_preserves_failures_and_spaces each carried an
inline copy of the _powershell invocation with the pre-330ff28d44 30s
timeout. 330ff28d44 already established that a real powershell.exe child
on a cold CI runner can exceed 30s and raised the shared helper to 180s,
but these duplicated call sites kept the stale budget and timed out
intermittently on the windows-latest-32-arm-core lane.
Both now call _powershell(script, HELPER, tmp_path): identical argv and
environment construction (the inline setdefault calls were no-ops on
Windows), with the helper's documented cold-runner budget. No product
script changes.
Scope the #49645 initial-dial retry to LOCAL connections only: a remote
dial failure must stay a retryable boot failure (stage 'dialing') so the
bounded boot-retry loop re-runs the whole handshake, including a fresh
getConnection() against a possibly-rebuilt tunnel.
The local retry's first attempt reuses the WS URL minted at the boot
boundary and only re-mints on later attempts, keeping the mint count
observable for the reconnect-path contract tests.
The renderer made a single gateway.connect() call during boot, so a
freshly spawned backend that was still initializing (event loop blocked
15-30s by MCP connects and plugin discovery) made the one dial lose and
boot ended in the 'Could not connect to Hermes gateway' modal even
though the backend became healthy moments later. Retry the initial dial
with bounded attempts, re-minting the WS URL on every attempt (OAuth
tickets are single-use), and propagate reauth failures immediately.
Co-authored-by: Mani Saint-Victor, MD <drmani215@gmail.com>
Simple mode shadows fileBrowserOpen, so a flip on this row lands in the
session reveal layer and never persists. Show the same Simple-mode note the
other shadowed Appearance rows carry, so the row doesn't promise a standing
default it can't keep.
Settings > Appearance > Window & layout gets a File Browser toggle bound to the same persisted state as the titlebar toggle and Cmd+J, so the open/closed default is a visible, searchable preference.
A model switch persists display_kind=model_switch with role=user
(tui_gateway/server.py). Hydration renders that row as a system message
("model changed"), so the authoritative latest page holds one more message
than the window looking at the same chat. The stale-transcript guard measured
staleness as `remoteChat.length > localMessages.length`, so a session that had
switched models reported "This window was behind another view of the same
chat", refused the send, and repeated the refusal on every retry. No second
window existed, and nothing in the session was damaged.
Measure authored content instead of array length:
* hydration.ts marks a converted backend notice with ChatMessage.systemNotice,
via one NOTICE_DISPLAY_KINDS predicate that now also drives the existing
system-role decision.
* stale-transcript-guard.ts compares authoredMessageCount on both sides, so a
notice never counts as another view's work.
* use-session-actions/utils.ts classifies the new field in IGNORED_FIELDS: the
transcript paints the row from role + parts, and role is already COMPARED.
Notices still render unchanged. Tool rows folded into an assistant bubble keep
the existing behavior. A genuinely forked chat is still refused, covered by
tests.
Trade-off: a difference consisting only of notices no longer installs the page,
so a "model changed" row can wait for the next natural hydrate. The send
proceeds, which is the point of the guard.
Tests: new apps/desktop/src/lib/stale-transcript-guard.test.ts, 5 cases (the
notice regression, both real-fork cases, the identical-page case, and the
empty-page contracts). The guard had no test before. Verified with
`vitest run --project ui` (8944 passed; the 2 failures in voice-prefs.test.ts
are pre-existing and reproduce with these edits stashed) and `npm run
typecheck` (clean).
Regression case carried over from #124414 (@Yun-0000), the same
non-idempotent prefix graft reached from a page whose first row is an
orphan tool fold. The fix in this branch already covers it; the case
pins the exact shape reported in #124311 next to the module's other
graft tests.
A transcript hydrated from the newest page can open on a page-local tool
fold: `toChatMessages` flushes a tool batch with no active assistant into a
synthetic message whose id is not durable, so its `rowId` is undefined.
`graftRefreshedTailOntoBackfill` treated any unstored row in front of the
anchor as proof of earlier history and re-prepended it to the refreshed page.
When the window itself was hydrated from that same page, the page already
carried the row, so the graft returned `previous.slice(0, anchor)` plus the
page: one row longer than the window it was given, on every read.
`messagesIfTranscriptBehind` compares lengths, so a graft that is not
idempotent reports "behind" forever. Both submit paths then `return false`
before `prompt.submit` runs, the toast promises a refresh that cannot reach
the compared length, and the transcript gains another duplicate per retry.
Keep the prefix branch, but drop only the prefix copies the refreshed page
already carries. Durable prefix rows still travel in front of the refreshed
tail, and so does an unstored row the page has no copy of.
An owned path like skills/research/web-search/scripts has no SKILL.md of
its own, so the category rule merged it and left retired files behind.
Look for SKILL.md in the dir and its parents below skills/.
The owned-category merge (#123646) treated a shipped dir as a category only
when it held nothing but DESCRIPTION.md and dotfiles. A category that also
ships README.md, LICENSE or any other metadata file was classified as a root
and replaced wholesale on update, deleting skills that `hermes skills install`
or the agent had added to skills/<category>/.
Under skills/, classify by the skill marker instead: a dir without SKILL.md
is a category and is merged per skill; a dir with SKILL.md is an authored
skill and is still replaced whole. Outside skills/ the old rule stands. The
merge loop, the symlinked-container walk and the pre-write guard all share
_is_container, so the guard still covers exactly what the copy merges.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
A shell with `set -x` (user rc, BASH_ENV) traces `+ echo <sentinel>` into
the merged output. That line is an extra separator for _split_segments, so
the segment count mismatched and read_file_raw (the V4A/replace write-back
source) failed with "Failed to read file".
_fenced_read now turns xtrace off before the fence; `set +x`'s own trace
goes to the group's discarded stderr.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
Extends the archived_at test with a legacy archive flattened under its folder name (accelerate) whose record is keyed by the SKILL.md name (huggingface-accelerate). Red on 52a6835e2b^.
_archived_ts called get_record per archive dir, and each call re-read and
re-parsed .usage.json (N+1 reads). Load the map once before the scan and
index it; the state/archived_at checks read keys that _backfilled never
changes, so behaviour is identical. Also drop the separate _archive_dir
import in favour of the existing skill_usage module import and split the
~130-char conditional.