AgentHydra 0.40.0
Added
- The Instances toolbar's "Usage filter" is now just "Filter", and it asks three questions
instead of one (web/src/lib/instance-filter.ts,web/src/composables/useInstanceFilter.ts,
web/src/components/InstanceFilterMenu.vue). Alongside the two quota windows it now filters by
STATUS (open / closed) and by PLAN (Max 20×, Pro, Free, … - the list is built from the accounts
actually on screen, so it names your plans rather than a guessed catalogue). The three facets are
OR-ed: picking two narrows the tables rather than cancelling out. Both new facets are true
whichever columns are showing, so the button no longer appears only in usage mode - only the
QUOTA half stands down with the percentages it measures, and the flyout says so in place rather
than silently ceasing to apply. Existing settings are untouched: the storage keys keep their
usageFilterspelling (a preference key is a wire format, not a label), status defaults to
"any" and no plan is picked, so an upgraded install filters exactly as it did. - A row whose fact is not KNOWN is never filtered out. An unlinked CLI login has no window to
be open or closed and no account record of its own, and a desktop instance's plan arrives a beat
after its row does. Both were already the rule for an unread quota reading; extending it is what
stops rows blinking out of the table and back on every refresh, and what stops "show me the open
ones" emptying the CLI table. The Codex table joins the filter on the same terms.
Changed
- The two 5-hour quota cells are grey now; colour is spent on the weekly ones
(web/src/components/UsageBar.vue,UsageBadge.vue). A usage-mode row carried four coloured
cells, and four hues side by side average out to "busy" - the eye had to read each one to find
the alarming one. The 5-hour window is the one that gives its colour up, because it refills the
same afternoon: a spent session means "not right now", a spent week means "not at all", and only
the second is worth an alarm. Both 5-hour cells keep their number, their length and their
popover; the Session bar simply reads in the same neutral grey the Plan chip does.
Removed
fleet-git.ts, and with it every git call AgentHydra made on its own initiative
(server/src/fleet-git.tsandserver/tests/fleet-git.test.tsdeleted, thegitkey dropped
fromGET /api/fleet). That section ranrev-parse --show-toplevelper live session cwd, then
rev-parse --abbrev-ref+status --porcelain+rev-list --countper repo - concurrently,
uncapped, uncached, on EVERY request. Against a 22.7k-file checkout that is a full status walk
each time, and anything calling the endpoint in a loop buries the machine; the owner watched
exactly that happen. Nothing consumed the result: no web code, no orchestrator code and no
Python readgit,offMain,dirtyCount,notRepooraheadCount, and the module's own
header said what to DO about a dirty or off-main repo was "a later piece's business" - a piece
never built. A producer with no consumer paying the most expensive read in the process.
Cross-repo git state is Odin's job, and Odin only does it when a person runs a scan. Standing
rule (owner, 2026-09-07): nothing runs git unless it was explicitly asked for, for a specific
reason.path-key.tsstays -desktop-landingandcodex-desktopstill use it - with its
header de-referenced. The five remaining git callers are all explicitly triggered and untouched:
the source-mode version stamp, the updater's owngit pull, the ChatGPT context pack's
ls-files, and the tunnel'scheck-ignore.
Fixed
-
Every reply the courier tried to deliver failed with "delivery actuator missing"
(server/src/routes/session-message.ts). The message endpoint resolved its PowerShell actuator
fromprocess.cwd(), which is wherever the daemon happened to be started, not where the file
is: a daemon launched inserver/looked forserver/misc/Deliver-DesktopChat.ps1and refused
every delivery. It resolves fromAPP_ROOTnow, like the rest of the codebase'smisc/lookups,
which is also what makes it land beside the executable in a compiled bundle. Found when a batch
move'sresumenotes staged and then could not be delivered. -
The tray-invariant poll could take the whole daemon down, and the guardrail that exists to
catch exactly that could not see it (server/src/tray-invariant.ts). Its tick was an arrow
assigned to a const, sotimer-callback-can-kill-the-daemon- which only readsfunction NAME()
declarations - fell through to "unguarded" and gated CI red onmain. The tick is a declaration
whose body opens withtrynow, so the protection is one the check can actually verify rather
than one it has to take on trust; the overlap guard keeps its old meaning, withrunningstill
cleared only by the tick that set it. -
The permission picker never opened on a window that was minimized or not in front, so a chat
that moved accounts had to have "Bypass permissions" clicked by hand
(orchestrator/scripts/actuator/approve_prompt.ps1).Press-SpacepostsWM_KEYDOWN/WM_KEYUP
straight at the render widget; such a window drops the key with no error, and the function
returned true anyway because all it checked was thatPostMessagehad been called. Measured on
one live window with the picker forced closed and verified closed before each attempt:
minimized 0/2, background 1/2, foreground 2/2. Background is a coin flip because UIA
SetFocus()only sometimes activates the window as a side effect - which is why this read as
intermittent rather than broken, and why an earlier fix went after a confirmation dialog that
was never the problem (the dialog is raised BY the menu, and the menu was not opening). The
window is now restored and activated once, before the first read of the accessibility tree
rather than at the keypress - doing it at the keypress still failed one rail earlier, on
selected the row but the pane still does not show '<title>'- and the previous foreground
window is handed back on every exit path. Only a by-hand act reaches this: the picker stays
gated behind--forceand the fleet pass behind the tray icon, so no background lane pulls a
window onto your screen. -
A chat sitting in "Auto" or "Manual" could never be moved to bypass at all. The composer
button is matched against a name list that had gone stale: the app renders Auto / Manual /
Accept edits / Plan / Bypass permissions, while the list carried three names that exist nowhere
in the app and still called Plan "Plan mode". A non-matching chat reported "no permission picker
is showing". -
"opened the picker but no item appeared" was printed for a picker that had never opened,
which sent two separate investigations after an imaginary locale/label problem. The two failures
are now told apart and named, and the never-opened branch says so explicitly. -
manage_desktop_chat.ps1had the same defect, with two confidently wrong diagnoses. Against
a minimized window the kebab hunt reportednot rendered in any searched running instance (collapsed group or virtualized out)and the archive path reported an EMPTYmenu opened but no 'Archive' item matched a known label. Menu showed: .- neither naming the real cause. It now
restores and activates before searching (-Liststays passive and never pulls a window
forward), and the header no longer promises "zero focus theft", because that promise was costing
correctness.chip.ps1carried a byte-for-byte copy of the brokenPress-Spaceand is fixed the
same way. -
automation_chat.pyreportedAPP-CONFIRMED via its own pickerfor runs whose picker had
just REFUSED, quoting the refusal inside the confirmation. It computed the verdict from
state/mode-confirmed.json- a persistent ledger answering "has this chat ever been confirmed" -
after driving the picker, so any chat confirmed once claimed confirmation forever. A verdict
about this run now comes from this run, via an allow-list of the actuator's success lines so that
a new failure string reads as not-confirmed rather than silently as success; the ledger is
consulted only when no picker ran. Verified live from a fully minimized window: 5/5 mode flips
both directions, the archive and rename paths, and the full--forcechain. -
The "Bypass all permissions?" popup is finally clicked - it was an in-page modal all along
(orchestrator/scripts/actuator/approve_prompt.ps1). Every previous fix here assumed the
confirmation owned its own top-level HWND. Measured live 2026-09-07: it does not. It renders as a
ControlType.Windowelement INSIDE the main window's tree (nameBypass all permissions?, a
Canceland a confirm named for the mode), and while it is up the composer toolbar leaves the
tree entirely - which is what every failing run reported as "the picker now reads 'gone'". The
hunt did see the confirm and threw it away: a modal is CENTRED, so its confirm sat at x=1312
while theModel: ...anchor put the pane guard at 1586, and-ge $minXmrejected the one button
it came for - the same defect this file's own header records forFind-ModeBtn, repeated one
function down. In-page modals are now handed to the confirm hunt as non-main roots (no positional
guard, since inside a modal there is nothing to confuse the confirm with) and scanned first; the
deny list and the must-be-new rail are unchanged. Proven on two chats in two workspaces:
MODE SET 'Accept edits' -> 'Bypass permissions' ... (confirmed the app's 'Bypass permissions' prompt). -
A move can no longer claim
app-confirmedon a confirmation the OTHER account earned
(orchestrator/scripts/migrate_chat.py).mode-confirmed.jsonis keyed by session id alone, so
a chat confirmed long ago in the app it just LEFT still answered yes for the app it just joined.
Measured on the six-chat Andreea drain of 2026-09-07: all six reportedapp-confirmedwhile
their own evidence string beganREFUSED, and the four nobody checked were sitting on
acceptEdits. The adjudicator now voids any prior confirmation before driving the picker, so the
only entry that can exist is the one this run's actuator earned. -
A chat that landed a second ago is no longer "not here" (same actuator). The sidebar row is
rendered on the app's own clock, and one instant look turned that delay intono sidebar row,
losing the whole permission stamp - five of six chats in the same drain. It polls for 6s now; an
absent row still refuses, and a row already on screen costs the same single scan as before. -
A crashed orchestrator run no longer blocks every retry forever (
server/src/orchestrator.ts,
server/tests/orchestrator-stale-lock.test.ts). The daemon-sideinFlightmap held only a start
time, and thefinallythat clears it runs on every normal path - so the one way an entry could
survive was a spawn promise that never settles, and then the lock was immortal, because
nothing else ever removed it. Measured 2026-09-07: amigrate_batchdied with no python process
left anywhere on the machine, and the retry still returned409 already running (started 85s ago). That is the worst shape a lock can take - it turns a CRASH into a HANG, reports a dead run
as healthy, and the caller believes it. An entry now carries adeadlineof its own
timeoutMsplus a 60s grace, which is a fact rather than a heuristic:realSpawnenforces that
timeout by killing the child, so a lock outliving it cannot have a live run behind it. Past the
deadline the entry is reaped and its kill switch fired defensively first;orchestratorBusy()
reaps too, so an immortal lock cannot wedge an update either. Releasing is now an identity
check, not a name check - once a lock can be replaced, an abandoned promise settling late would
otherwise delete the SUCCESSOR's entry and hand out the concurrent acting pass this map exists to
prevent. Four tests, verified to fail (3 of 4, including the successor case) against the old
never-reap behaviour before being accepted. -
The permission-mode confirmation is hunted in every window the app owns, not just its main
one (orchestrator/scripts/actuator/approve_prompt.ps1). ⚠ This did NOT fix the by-hand
clicking, and the commit that landed it claimed a root cause that was wrong. The real cause,
measured separately the same day, is the background-window swallow now documented in that
script's header:Press-Spaceposts its key at the render widget, a BACKGROUND window never
opens the picker menu at all, andPress-Spacereturned$trueregardless because it only
ever checked thatPostMessagewas called - so the confirm hunt found nothing to click because
there was never a dialog (background = 0 picker items, foreground = 5, on one live window).
What is retained here is a narrower, still-true improvement: the hunt and its diagnostic no
longer assume a dialog must be a descendant ofMainWindowHandle. The old diagnostic scanned
only the main window, so its "buttons on screen" list printed the frame's own Minimize/Maximize
and read as "no dialog appeared" - which is equally consistent with "looking in the wrong place"
and with "nothing was ever raised", and that ambiguity is what sent the investigation at the
search root instead of at focus.Get-ProcRootsnow returns the main window plus every visible
top-level window of the same process, used in all three places - the pre-invoke RuntimeId
snapshot, the confirm poll, and the diagnostic (buttons outside the main window are tagged
[dialog]). Nothing widens about WHAT may be pressed:DENY_NAMESis still never pressed and
the must-be-new rail is intact, strictly so because the snapshot now covers the same roots the
hunt does. The pane/position guards that separate the dialog's confirm from the composer's
picker and the sidebar chips apply only in the main window, since those live there and requiring
a separate dialog's button to sit right of the pane would reject the very button wanted.
Get-ProcRootsis deliberately defined above its first caller: PowerShell binds functions as
the script runs, and a definition further down left the snapshot call in itscatch{}with an
EMPTY set, silently disarming must-be-new. Not yet exercised against a live dialog - and with
the foreground fix in place the dialog now appears where the old code was already looking, so
this widening is defence against a shape that has not yet been observed, not a proven path. -
The window actuators aim by identity, never by substring or position (every
orchestrator/scripts/actuator/*.ps1, plusspawn_chat.pyandmigrate_chat.py), after the
owner watched one click the project selector in the wrong account's window. A bare-Instance
now matches the profile dir's leaf name EXACTLY (pap3r rotateno longer also matches
pap3r rotate2), a blank-Instanceis refused for anything that clicks or types (it used to
scan every running account), zero or several matching windows is a refusal that names every
candidate, a sidebar row must equal the title exactly rather than end with it, a key is never
posted until the focused element is proven to be the target, and a dialog's confirm button must
be NEW since the action that opened it rather than any enabled button named OK/Continue/Yes
anywhere on screen.trust_dialog.ps1finally takes an-Instance. Python callers hand the
actuators the unique profile DIR rather than the fleet name. Proven live: the rename drill on
the hardened scripts round-trips a real chat by bare instance name in 9.4s. -
A batch move no longer reports a chat that landed and then crashed in a later phase as "NOT
moved" (orchestrator/scripts/migrate_batch.py). Phases two and three only run on a verified
landing, so a settle or stamp that raises keepslanded: true, marks the chatok: falsewith
what did not finish, keeps its OTHER tidy-up running, and the report gets a distinct
"LANDED but not finished" bucket instead of the re-run advice. Per-chatsecsalso stops
charging the first chat with every later chat's stamping. -
The naming door restates the daemon's own title (
migrate_chat.py). A chat renamed in the
app whose index row still carried its first message was refused twice with "confirm_title does
not match" - the second time through the breaker. The door compares against the ROW, so the row
is what is restated now, via the per-id route. -
A compiled
dist/AgentHydra.exerun from the repo finds the orchestrator one level up
(server/src/orchestrator.ts). Launched as the daemon it looked fordist/orchestrator, and
every orchestrator-backed tool (move_chats,orchestrator_menu) died with "no orch.py" while
the tree sat beside it. A release zip with no toolbox still reports honestly. -
The migrate stopwatch now splits the two slow phases into
settle-drive/settle-confirm
andstamp-doctrine/stamp-picker, so a slow move says which half is slow. First measurement:
7.8s driving the source app's archive control, 8.0s for the single-move bypass watch, 5.2s
driving the target app's picker. -
The per-window UI lock is reclaimed on proof of death, not on age (
orchestrator/scripts/ lib/windowlib.py).instance_lockdeleted any lock directory older than 15 minutes without
checking who held it, so a lane that legitimately ran long had its lock taken mid-gesture and
two lanes then drove one Electron window: the interleaved sidebar click the lock exists to
prevent. The actuator alone runs up to 240s per press andspawn_chatstacks several behind a
120s wait, so the ceiling was reachable in normal work. The lock now carries an owner record
(pid plus that pid's OS creation time, so a recycled pid cannot impersonate the holder) and
reusesjoblocklib's proof-of-death check: a provably live holder keeps its lock at any age, a
provably dead one is reclaimed at once instead of waiting the clock out, and age remains only
where liveness cannot be determined so a crash still cannot wedge a lane. Release is
token-checked, closing the second half of the same defect, where the original holder removed
the directory unconditionally on exit and deleted its successor's lock. -
The naming pass now takes that same lock instead of a private one it alone respected
(orchestrator/scripts/name_chats.py). It kept its ownstate/naming-<instance>.lock, which
excluded a second naming pass and nothing else, so it could drive a window while the courier,
archive_chat,migrate_chatorspawn_chatwas already driving it. The private copy carried
both age-reclaim defects above. Its non-blocking posture is unchanged (wait_secs=0): a pass
that finds the window busy steps back and says so.
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.