Skip to content

AgentHydra 0.41.0

Latest

Choose a tag to compare

@github-actions github-actions released this 09 Sep 05:11
· 9 commits to main since this release

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 with pull --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 into FETCH_HEAD and merge-base --is-ancestor), reports
    diverged and remoteBranch, 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
      gained done for 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_chat always 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, and confirm_title was 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.
  • 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 its CLAUDE_CODE_HOST_SESSION_ID is frozen to whichever chat
    started it. On 2026-09-08 that made whoami answer instance #12 at confidence: 'exact' while
    the caller was a live chat on #5, and a move_chats { to: "here" } migrated 13 chats onto the
    wrong account. The 2026-09-07 storeConflict guard 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 reports staleHostSession, drops to assumed, and resolveMoveTarget refuses "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" from whoami for a mutating action.
  • desktop-archive is 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-archive and the archive_desktop_chat MCP
    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
    takes instance_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 unless force. New primitive:
    desktopChatCarriers.
  • manage_desktop_chat.ps1 archives 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_chat and move_chats confirm the resolved account by name AND email
    (server/src/mcp.ts, server/tests/move-chat-mcp.test.ts). targetNote used 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 a dry_run
    and the real move, and is reported on a refusal too. The tool descriptions point a caller at
    dry_run: true + targetNote as the pre-flight check whenever to/"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
    --cwd at 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 no agenthydra entry 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.json that 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,
    getInstanceChats in web/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 flipped isArchived, so the source's local_<id>.json survived 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>.tombstone with tombstoned, tombstonedAt and movedTo written 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.ts now reads the
    store through core/chat-store-scan.ts (the pure file scan, extracted 2026-09-07) and re-exports
    the same names, so no caller changed.
  • misc/Rebuild.bat is misc/rebuild_agenthydra.bat, without the pause that 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

  • chatStoreLabel disagreed 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's basename, 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/carlos as its "folder name" instead of carlos. 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 main red
    (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, including landed === 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 / resolveUpdateToApply in server/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, so move_chat, move_chats and every orchestrator_* 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 .exe rather than a damaged bundle; Unix publishes only tarballs, which
    always carry orchestrator/, 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 manual claude mcp add for 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 (chatStoreLabel in
    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 literal default, while the basename of its user-data dir
    is Claude. 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.