Fixed
- The git-checkout self-updater no longer advertises an update it cannot apply
(server/src/updater-engine.mjs,server/src/updater-engine.d.mts,
server/tests/server-lib/updater-engine.test.ts; kit-synced). A checkout that had DIVERGED
from the update remote (local commits the remote lacks, and remote commits it lacks) was told
an update existed and then failed to apply it withpull --ff-only, every cycle; a checkout on
a branch the remote does not have pulled the LOCAL branch name and failed with "couldn't find
remote ref", every cycle. The check now proves the fast-forward before advertising it (a
fetch of just that branch intoFETCH_HEADandmerge-base --is-ancestor), reports
divergedandremoteBranch, and apply pulls the branch the check verified, by name. A
fetch that fails is its own reason rather than a guess either way. - "Move chats to account" moves ALL of an account's chats, and a chat it cannot land is left
where it was instead of vanishing (web/src/components/InstancesView.vue,
web/src/lib/move-chats.ts,server/src/routes/desktop-sessions.ts,
server/src/session-launch.ts,server/src/chat-title.ts,server/src/chat-dossier.ts).
Reported 2026-09-08 as "it's not moving all the chats", and it had four causes, each of which
lost chats in a different way:- The plan was built from the session list, not from the account. That list is scoped by the
instance name a transcript's desktop record happens to carry (the default install's is
default, never its folder name, so the regular install always counted zero), keeps one
preferred record per session id across every profile (a chat that ever lived on two accounts
is attributed to whichever file is newer), and drops a transcript with no substantive turn.
Chats plainly sitting on the account were simply not in the plan. The move now reads the
account's own chat store, the same read the "Chats" dialog makes, and says up front how many
rows it leaves behind and why (no transcript to import, or already handed off).ChatListRow
gaineddonefor that. - The route archived the source BEFORE the landing was known. The app-side import answers ok
when its 20-second wait for the app to create the record runs out, so a target app still busy
with the previous chat of a bulk move, or an engine the source app respawned so the import was
refused as a live writer, left the chat archived on the old account, absent from the new one,
and counted as moved. The route now lands first, verifies by reading the record back from the
target's store (up to 45 seconds for a running app), and archives the source only then; an
unverified landing leaves the chat visible where it was and says so. The orchestrator's
migrate_chatalways worked in this order; the route finally does too. - The naming door knew one of the chat's two names. A chat's session-list title (from the
transcript) and the app's own record title (the sidebar's, the "Chats" list's) routinely
differ, andconfirm_titlewas compared only against the first, so every move planned from
the name a person had actually read was refused with "does not match". Either current name
restated exactly is accepted now; the chat lands under the confirmed name when it is real,
under the other when only that one is, and never under a generic one. - Residency was read off the wrong store. "Does the target already show this chat?" asked the
cross-profile index, which prefers the newest file, so a live copy already in the target was
invisible whenever the source's copy was newer, which is exactly the state a move starts in,
and a retry could create a second row for one chat, the row that makes it unreachable. Both
importers now read the target's own store. A move into an account that still holds an
archived copy of the chat also un-hides that record rather than completing with the chat
hidden on the account it just arrived at.
- The plan was built from the session list, not from the account. That list is scoped by the
- A dead host session is no longer a confident identity, so
to: "here"cannot land chats on the
wrong account (server/src/core/self-identity.ts,server/src/mcp.ts,
server/tests/self-identity.test.ts). When the daemon is reached over HTTP it is ONE long-lived
process serving every instance, and itsCLAUDE_CODE_HOST_SESSION_IDis frozen to whichever chat
started it. On 2026-09-08 that madewhoamianswer instance #12 atconfidence: 'exact'while
the caller was a live chat on #5, and amove_chats { to: "here" }migrated 13 chats onto the
wrong account. The 2026-09-07storeConflictguard could not catch it: the frozen id had no
lineage link to the real caller, so the chat store AGREED with the file. The new invariant is
that a process genuinely hosting a live caller cannot have an ARCHIVED host session, so that case
now reportsstaleHostSession, drops toassumed, andresolveMoveTargetrefuses"here"and
demands an explicit instance. Not fully general - a still-open launcher chat would fool it - so
the docs now say plainly never to resolve "here" fromwhoamifor a mutating action. desktop-archiveis scoped to one instance, and refuses rather than hiding a chat in use
(server/src/routes/desktop-sessions.ts,server/src/session-launch.ts,server/src/mcp.ts,
server/tests/migrate-never-archives-the-target.test.ts). The 2026-09-04 fix scoped
POST /migrate; the sibling door,POST /:id/desktop-archiveand thearchive_desktop_chatMCP
tool behind it, still flipped the flag in EVERY profile carrying that session id. After a
migration the source profile holds the leftover and the target holds the real chat, so an
unscoped call cannot tell "put the leftover away" apart from "hide the chat the owner is using" -
and on 2026-09-08 it archived two chats in the instance they had just been migrated to, one with
a running engine, whose app had already dropped them from its in-memory sidebar. The route now
takesinstance_ref, returns 409 naming every carrier when several hold the session and none was
named, and refuses to archive a chat with a live engine unlessforce. New primitive:
desktopChatCarriers.manage_desktop_chat.ps1archives on a non-English app. It matched menu items by display
text, so a Portuguese Claude Desktop (Arquivar/Apagar) refused every archive - correctly,
since it will not guess by position when Delete sits next to Archive. The app's CSS classes do
not localize and only Delete carries the danger palette (menu-danger/text-danger), so the
destructive item is now excluded POSITIVELY in any language and Archive is the last item left,
refusing unless exactly one danger item proves the menu shape. Adds-Action DumpMenu, which
prints every menu item's non-localized properties and invokes nothing.
Added
-
move_chatandmove_chatsconfirm the resolved account by name AND email
(server/src/mcp.ts,server/tests/move-chat-mcp.test.ts).targetNoteused to carry a bare
name and nickname, read only after every chat had already imported, and a stale identity
signal landed three chats on the wrong account that way (2026-09-07). It now names the
instance number, the name, the tier and the account's email, is built from ONE resolve before the
orchestrator run is posted (one per batch, not per chat), reads identically for adry_run
and the real move, and is reported on a refusal too. The tool descriptions point a caller at
dry_run: true+targetNoteas the pre-flight check wheneverto/"here" is not obviously
right. -
AgentHydra registers itself as an MCP server with Claude Code, on by default
(server/src/mcp-register.ts,server/tests/mcp-register.test.ts, Settings → MCP server). On
every start the daemon writes one entry into Claude Code's user-scope config:
{ "type": "http", "url": "<bound daemon>/api/mcp" }. Until now the only documented route was
claude mcp add --scope user agenthydra -- bun run --cwd <path-to-agenthydra> mcp, which is
wrong for everyone who did not clone the repo: a downloaded release has no checkout to point
--cwdat and usually no Bun to run it with. So the people most likely to want the tools were
the least able to get them, and the failure is silent: a client with no entry simply has no
tools, and nothing anywhere says why. Measured on a release install 2026-09-07: three other MCP
servers registered by their own installers, and noagenthydraentry at all.It registers the HTTP transport, never stdio: stdio is one server process per client, each one a
relay to this daemon over HTTP anyway. Re-registering on every boot is deliberate, because the
entry carries the port the daemon actually bound, so a hop off a busy 7787 cannot leave a stale
URL behind. It writes that one key and nothing else: every other server and every unrelated
key is preserved, the write is a temp file and a rename that keeps the target's permission bits
and follows a symlink to its target, and nothing is written when the entry is already correct.
A~/.claude.jsonthat does not parse is REPORTED rather than replaced, because that file holds
the user's logins and project history and a naive read-default-write would destroy all of it to
add a convenience. Claude Code writes the same file, so each write is bracketed by a size+mtime
check and abandoned rather than allowed to clobber a concurrent one. Turning the switch off
removes the entry; the panel shows what the config file actually says, not merely what the switch
says, because a read-only file or a hand-written entry can make the two disagree. -
A row's ⋮ menu lists the chats on that account (
web/src/components/InstancesView.vue,
getInstanceChatsinweb/src/lib/api.ts). "Chats" opens a dialog naming every chat the account
holds: title, project, when it was last active, and whether an engine is running in it right now,
with a count of active / archived / total and an "Include archived" toggle. Answers the question
you have to settle before any move on a fleet of near-identically named rows: which account is
holding the chat you are looking for. It reads/api/chats, the account's own store, and not the
session list: a session listing is scoped by period and by the instance name a transcript records,
so a chat nobody has touched this week is not in it, and an account with twenty chats would look
empty, which is the one wrong answer this panel must never give. A chat with a CLI transcript
opens in Sessions; one without has no button rather than a broken one.
Changed
- A moved chat's settled source record is tombstoned on disk, not merely flagged
(orchestrator/scripts/migrate_chat.py,orchestrator/scripts/tests/test_migrate_rename.py).
Settling only flippedisArchived, so the source'slocal_<id>.jsonsurvived forever under its
original name and kept answering two filename-keyed lookups (the daemon's own host-session
identity check, and the toolbox's metadata glob), so every later scan re-discovered the same
stale twin and re-decided it was stale. After a verified settle the file is renamed to
<name>.tombstonewithtombstoned,tombstonedAtandmovedTowritten into it: out of both
globs at the source, content kept for a human, idempotent on a retry, and a copy a running app
resurrects from memory is removed again without ever winning back its name. Best-effort: a
tombstone failure is a note on the report, never a failed move.chat-dossier.tsnow reads the
store throughcore/chat-store-scan.ts(the pure file scan, extracted 2026-09-07) and re-exports
the same names, so no caller changed. misc/Rebuild.batismisc/rebuild_agenthydra.bat, without thepausethat held a
console open on failure; the tray, the reference docs and the root ignore rule follow the rename.- The "Move chats to account" submenu is one line per destination and lists running accounts
only, until asked (web/src/components/InstancesView.vue,
web/src/i18n/locales/en/instances.ts). A green dot marks a running app, the same mark the
row's icon carries; the "Not running - lands in its store, ready when it starts" line under
every closed account is gone; and a "Show not running" switch at the top, off on every page
load, brings the closed ones back. On a fleet of twenty accounts the old list was a scroll. - "Move all chats to another account" is now "Move chats to account"
(web/src/i18n/locales/en/instances.ts). It was a sentence, and it sits one line below the new
"Chats" item. The long form made the two look unrelated when they are the two things you do with
an account's chats. The submenu names the destination, so the label does not have to.
Fixed
-
chatStoreLabeldisagreed with itself off Windows, and the linux CI leg was red for it
(server/src/routes/sessions.ts). It trimmed a trailing separator with a pattern accepting
BOTH slashes, then took the folder name with node'sbasename, which off Windows does not
treat a backslash as a separator at all - so an isolated instance handed back the entire
C:/Users/me/.claude-instances/carlosas its "folder name" instead ofcarlos. It splits on
either separator now, which is what the trim beside it already assumed. This is not academic:
the daemon ships linux and darwin binaries, and both legs of its own CI failed on it. -
The new MCP race test was a coin flip on disk timestamps, and it kept
mainred
(server/tests/mcp-register.test.ts). Its stand-in for Claude Code sliced a string with a
bound that began at that string's own length, so the clamp swallowed every increment and
attempts 2 and 3 rewrote byte-identical content. A file whose bytes have not changed has not
changed, so no race guard of any kind could flag those attempts - the test only passed when
the filesystem clock happened to tick mid-rewrite (measured 4 pass / 4 fail locally; red on
windows-latest while ubuntu's finer mtimes went green). The hook pads with one more newline
each try instead, so every attempt changes the file's SIZE and is caught on any platform
regardless of timestamp resolution: 10 runs, 10 passes. The implementation is untouched and
every assertion stands, includinglanded === 3.Hardening
stamp()to hash content instead was tried and rejected: built as a probe, it makes
this test fail 10 times out of 10, because a byte-identical write is identical to a hash too.
It would also be wrong - a byte-identical concurrent write loses nothing, so declining it is a
false positive that would turn a correct merge into a spurious boot-time failure. -
An install missing a release-owned folder can be repaired without waiting for a new version
(missingComponents/resolveUpdateToApplyinserver/src/github-updater.ts, a Repair
install button in Settings → MCP server). The component-aware updater landed in v0.39.0, so
the update that installed 0.39.0 was performed by its predecessor and brought the executable
alone. The result, measured on a real install 2026-09-07: the newest version running with no
orchestrator/beside it, somove_chat,move_chatsand everyorchestrator_*tool answered
no orch.py under <dir>while every other tool worked perfectly. And because it IS the newest
version, the update check said "up to date" and no update could ever repair it. An updater that
can only fix a component while also bumping a version cannot fix the install its own predecessor
broke. Reinstalling the current release is now allowed when a component is missing.Three things it refuses, because a repair must never make an install worse. A complete install on
the current version is still "already up to date". A repair is offered only when the latest
release IS this version, never when the newest release is OLDER (a yanked tag), where
"reinstall the latest" would be a silent downgrade. And the expected component set is
per-platform:misc/ships only on Windows, so a healthy Linux or macOS install is complete
without it, where treating it as missing would have made every apply a reinstall-and-restart that
could never converge. On Windows alone, an install with NO components at all is left alone, being
the bare single-file.exerather than a damaged bundle; Unix publishes only tarballs, which
always carryorchestrator/, so there a missing folder is unambiguously damage. -
The MCP docs no longer hand a release user an instruction that cannot work
(docs/REFERENCE.md). The section now leads with the automatic registration and the HTTP
transport, keeps the manualclaude mcp addfor anyone who turns the automatic one off, and says
out loud that moving chats needs the Python toolbox beside the executable, the dependency that
made a working MCP server look broken. -
/api/chats?instance=answers for the regular Claude Desktop install (chatStoreLabelin
server/src/routes/sessions.ts). The route mapped an instance to its chat-store label with
basename(), which is right for every isolated instance and wrong for the one everybody has:
the default install is filed under the literaldefault, while the basename of its user-data dir
isClaude. The route therefore answered 404 "no desktop instance matched" for the account most
people are using, which reads as "that account does not exist". Found by review before the new
Chats dialog shipped on top of it.
Which Windows download?
| file | tray icon | orchestrator | notes |
|---|---|---|---|
...-windows-x64.zip |
yes | yes | The executable plus the misc\ tray toolkit and the orchestrator\ toolbox. Double-click AgentHydra.exe and the daemon starts the tray launcher for you; or run misc\Create-Shortcut.ps1 once for a shortcut that launches through it directly. |
...-windows-x64.exe |
no | no | One portable file, nothing to unpack. A single file carries no misc\ or orchestrator\ folder, so no tray icon can appear whatever the in-app setting says, and the orchestrator tools report themselves as not installed. |
Both self-update, and both are the same daemon. The zip is the complete install; the
single file is the daemon alone, without the two folders that sit beside it — the tray
toolkit and the orchestrator. Each says so plainly rather than looking broken: the .exe
tells you about the tray icon once on first start, and GET /api/orchestrator answers
present:false and names the folder it looked for.