Repository navigation
Tags
Tags
fix(usage): read only the Hub account shown again, and keep hidden ac… …counts unread after a sign-in (#3364) Fixes #3361. ## What These are follow-ups to #3355 (the usage report and Hub rows): - **Showing a hidden Hub account again reads only that account.** Every other row, and its schedule, is kept. Before, the pass after an unhide re-read every Hub account at once, bypassing `FRESH_MS` and `MIN_REFRESH_MS` (`a,b,a,b`; now `a,b,a`). - The next scheduled or forced read skips an account read on its own less than `MIN_REFRESH_MS` ago. - A failed read after an unhide keeps the cached rows instead of emptying them. - **A same-provider sign-in keeps the provider's hidden accounts unread.** `invalidate(provider)` keeps hidden accounts' entries, with the hidden set taken from the current preferences, so an account hidden since the last report counts too. The account check from #3355 still re-reads a hidden place that now holds another account. ## Validation - Failing-first tests: - `server/cliproxy.test.ts`: - "reads only the Hub account shown again, keeping the other accounts' fresh rows" - "keeps every Hub row when the read after an account is shown again fails" - "does not read an account shown again a second time at the scheduled read that follows" - `server/usage.test.ts`: - "keeps a provider's hidden accounts unread when its own sign-in ends" - "keeps an account hidden since the last report unread when its provider's sign-in ends" - Each failed before its fix. - `bun run check fast`: passes, with unit tests at 13237 pass, 0 fail. ## Review A Codex adversarial review (gpt-6.1-terra, xhigh, route: axwith) ran before this PR opened. Its 3 MEDIUMs are fixed in the second commit: the hidden set at sign-in, the failed partial read, and the second read of the shown account. It found no HIGH. ## Not covered (follow-ups) - A fresh server still reads a hidden account once, so its row can be listed; changing that is a product decision about what an unread row shows. - The Hub's own `invalidate()` uses the last report's hidden set. - `gh auth token --user <hidden>` still runs every `FRESH_MS`, which would need a `signIns` interface change. --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
fix(status): keep a Claude rest when its last background task ends, a… …nd refuse a linked subagents folder (#3360) Fixes #3348. ## What In `server/claude-subagents.ts`: - **The last background task ending no longer brings back a stale `working`.** When the last background task ended (subagent, Bash command or Workflow) while herdr's last report was still `working`, the running count dropped to 0. `restsOn()` turned false, and herdr's old `working` came back even though the transcript said the turn was at rest. The pane now keeps the transcript's rest. That holds across a same-session re-lookup and clears on a reset. A newer herdr `working` event or a new turn still makes the pane work again. - **A linked folder is refused, and the listing is bounded.** The `<session>/subagents` reader followed a linked folder, so another session's records counted for this pane, and it read the whole directory before keeping 5,000 names. The listing now opens the folder through the same checked opening as the detail read (`openFolder`), refuses a linked `subagents` or `<session>` folder, lists nothing if a folder on the way is swapped, and stops at the cap. `subagentDetails()` (the chat cards' titles) goes through the same checked folder. ## Validation - Failing-first tests, each of which failed on main or on the first commit: - rest kept at the pane-status level, returned snapshot included, after the last task ends - rest kept after a same-session re-lookup - linked `subagents` and linked `<session>` folders, through both the listing and `subagentDetails()` - a folder swapped before and after its open - Guard tests: a herdr `working` event after the last task ends, with and without a poll first, and a next turn written late. Each one makes the pane work again. - **One existing #2072 test changed:** it expected `working` in exactly the case this issue calls a bug (the transcript at rest, herdr's only `working` before the child ended). It now expects rest. - `bun run check fast` passes on current main (e450d27). ## Review A Codex (gpt-6.1-terra, xhigh) adversarial review ran before this PR opened. - **Fixed in the second commit** (three MEDIUMs): - the rest flag was lost on a same-session re-lookup - a linked `<session>` folder was still followed - `subagentDetails()` skipped the folder check - **Left as is:** - LOW: the 5,000-entry window is taken before names are filtered, so in a huge folder an active agent past it can go uncounted. This predates the change. - After the listing, each agent's files are read by path, so a swap between the listing and those reads is not anchored. --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
fix(usage): stop reading the sign-ins of accounts hidden from Plan li… …mits (#3355) Fixes #3333. ## What With Plan limits on, hiding or removing accounts in Settings → Agents was display-only. Ovis still asked every provider for its sign-ins on each refresh, which ran `gh auth token --user <login>` for every github.com account, including accounts with no Copilot plan. Endpoint security can flag that as credential access. Now: - **Fully hidden providers aren't asked.** A provider whose every known account is hidden or removed is no longer asked for its sign-ins. Its last rows stay as they were, and no `gh auth token` runs for it. - **Hidden Hub accounts aren't used.** The Hub (CLIProxy) is no longer asked to use the credential of a hidden or removed account that already has a row. An account it has never read is still read once, so it can be listed. - **A sign-in clears only its own provider.** A sign-in that ends invalidates only that provider's cache. Before, any sign-in (for example Claude's) cleared every provider, and the next report ran `gh auth token` for a fully hidden Copilot again. - **Showing an account again reads it at once.** Its provider is looked up and read immediately, and rate-limit backoffs are kept. - **A provider with any visible account** is still searched as before, so new accounts are still discovered there. No OAuth refresh is added: expired tokens are still reported and left to the CLI. ## Not covered Accounts that have no plan for a provider (for example github.com accounts with no Copilot plan) are never listed, so they cannot be hidden. A provider whose only accounts are such unlisted ones is still searched on each refresh. Stopping that needs a per-provider on/off switch (the issue's option 2), left for a follow-up. ## Validation Failing-first tests, each of which failed before its fix: - **`server/usage.test.ts`:** - A fully hidden provider is not asked for sign-ins. - A mixed hidden/visible provider still is. - A hidden Copilot account stays unread when another provider's sign-in ends: `gh auth token` ran twice before, once after. - Showing an account again looks its provider up at once. - **`server/cliproxy.test.ts`:** the Hub is asked nothing for hidden accounts and their last rows stay, until one is shown again. It counts `/requests/api-call`. - **`server/usage-account-list.test.ts`:** showing an account in Settings starts the search. `bun run check fast` passes on current main (e450d27). ## Review A Codex (gpt-6.1-terra, xhigh) adversarial review ran before this PR opened. Its three MEDIUMs are fixed in the second commit: - the Hub path still used hidden accounts' credentials - an unrelated sign-in cleared every provider - showing an account didn't refresh it **Left as LOW:** - While a provider is skipped, a removed account's row can go stale; for example, it is still listed as removed after being signed out of its CLI. - Each provider is still looked up once after a server restart, before the hidden list applies to a cached inventory. --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
fix(letta): claim on a peer record gone only for a moment or rewritte… …n while read (#3357) Fixes #3356. ## What This closes two fail-opens left in `server/letta-store.ts` `folderClaims` (the peer-record check from #3351): - **A record gone only for a moment.** An ENOENT/ENOTDIR at the open counted as "no record". Now the record's path is checked again with `lstat`. Only a record still gone claims nothing; anything back at the path is a claim. - **A record rewritten in place during the read.** The opened handle is stat'ed again after the read. A short read (`bytesRead !== size`), or a size or `mtimeNs` different from the first stat, is a claim. ## Validation - Failing-first tests in `server/letta.test.ts`, each run with and without `/proc`: - **".letta gone just as it is opened, or a file in its place, and back at once, is still a claim":** an ENOENT and an ENOTDIR variant. A `.letta` that stays gone still claims nothing. - **"a peer's record rewritten in place while it is read is still a claim":** short-read, size and mtime variants. - All five variants failed on main, in both modes, and the file passes 65/0 with the fix. - `bun run check fast`: green on the final tree, unit tests 13146 pass, 0 fail. - The changelog line is added to #3351's unreleased fragment `changelog.d/letta-record-scan.md`, which describes the same behaviour. - **Not covered here (the issue's LOWs):** the `fsp.stat` spy that never fires in Bun, and the one fs-pool slot held per stuck peer folder. --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
feat(machines): update any PC from here, one or all, over relay, dire… …ct or SSH (#3309) ## What Every PC added here is now updated from the PC in use. This covers PCs reached through the relay (direct or relayed) and over SSH. Nobody has to go to that PC. - **Badge on the roster.** `Machine.ovis_update` is the update a PC can take from here, told unasked: - `own`: the PC's own Ovis offers a newer release. A connected relay PC or SSH-own-Ovis PC is asked through its connection (`GET /api/updates`) when it connects and then every 5 minutes. - `bridge`: this app's bridge on that PC is of another version. - When an update is offered but cannot run from here, `updatable: false` comes with a plain-words `reason`. - **One per-PC route.** `POST /api/machines/:id/update` (202 `{ job_id }`) works whichever way the PC is reached. - A connected PC's own Ovis is asked to install the release it offers, by revision (no redirect followed). It is followed through its restart and reconnect until it answers on that release (`server/pc-update.ts`). - Anything else runs the existing bridge update. - When nothing can run, the answer is 409 `update_unavailable` with the same words. - `DELETE` ends the wait. - **One click for all.** `POST /api/machines/update-all` returns `{ started, queued, skipped }`. Own updates run side by side. Bridge updates run one after another, sharing one bundle download, and show `updating.queued` until their turn. - **Failures in plain words.** `Machine.update_error` keeps why a PC's last update failed. It is cleared when the next one starts. - A relay PC no longer has to be updated on that PC. The relay device this server paired as already drives, and the test shows its install goes through. Contract: `shared/machines.ts` (`PcUpdateOffer`, `PcUpdateStarted`, `PcUpdateAll`, `MachineUpdate.queued`). Docs: docs/remote-pcs.md, "Updating PCs from here". This is the server/contract half; the devices-list buttons come in a separate UI change. ## Who may do this (contract change) One guard covers every change to this PC's PCs, and this PC's own updates: every `POST`, `PATCH` and `DELETE` under `/api/machines/` except a PC's own proxied routes (its panes, workspaces and files), plus `POST /api/updates/install` and `POST /api/herdr/update`. The rule is `ownerAccess` (`level === "full" && role === "drive" && via` not `open` or `portal`) and `updateRequestAllowed` (`X-Herdr-Update: 1`, same origin, no query). Anything else gets 403 `forbidden`. The connection server's bridge token still asks for its own install by revision. | May | Who | |---|---| | Yes | a driving sign-in on this PC, its token, its Tailscale login | | Yes | a driving device paired with this install, directly or over the relay (the owner's phone, his other PCs) | | No | a visitor of an open install, with nothing paired | | No | a Portal visitor, whatever role Portal gives | | No | any device that only watches | Before this PR, the existing `/api/machines` changes (setup and approve, settings, enable, remove, update-bridge, relay and Tailscale adds) took any driving caller, the open LAN and Portal included, and `/api/updates/install` and `/api/herdr/update` took any authenticated caller with the header. `server/machine-owner-guard.test.ts` runs every route against every kind of access, with and without the header. The app sends the header on every PC request (`machineRequest`), and the sidebar's update buttons send the release the card shows. ## Validation - `bun run check fast`: passed (typecheck, build, unit 13009 pass / 0 fail). - New: `server/pc-update.test.ts` (offer reading, following an install through a restart, failures in its own words), and 3 relay contract tests in `server/relay-pc.contract.test.ts` (two real servers through a local relay, with a stand-in updater on the far PC). Extended: `server/machine-own-update.test.ts` (the SSH badge, the one route, refusals and `update_error`). - Skipped: browser regressions (no UI change); integration and browser CI run on GitHub.
fix(letta): check another pane's Letta record off the event loop and … …fail closed (#3351) Refs #3326 (item 2). Item 1, the long-line tool-output scan, went back to #3326 after the merge pass measured 57-108 ms of blocking at its 8 MiB line cap; see #3326 (comment). Item 3 needs herdr or hc to report a process start time. ## What In `server/letta-store.ts`, the check that another Letta pane's record doesn't claim the same conversation: - **Runs off the event loop.** It uses `fs/promises`, including `lettaRecordOwned`'s peer-folder resolution. Every peer wait has a deadline, and a peer that misses it counts as a claim. - **Can't be fooled by links or swaps.** - The peer folder, its `.letta` and the record are opened through descriptors held along a chain of plain folders, checked by device and inode before and after the read. Where there's no `/proc` (macOS, Windows), the record is opened with `O_NOFOLLOW` and the folder chain is checked the same way. - The opened record's `fstat` must match an `lstat` of its path taken after the read. Any mismatch counts as a claim, and so does an error or the file vanishing, so the check fails closed. - A `realpath` error other than ENOENT/ENOTDIR counts as a claim; only a folder known to be gone claims nothing. - **Re-checks before binding.** After awaiting peer folders, it reads the pane's own record again and asks herdr about the pane's Letta again: the same pid, start identity and cwd. It binds nothing if either changed. ## Validation - Failing-first tests in `server/letta.test.ts`, run in both `/proc` and no-`/proc` modes where it applies: - link and decoy swaps, put back or not, of the peer folder, its `.letta` and the record - a decoy opened while the original record is removed (the fail-closed check) - an unresolvable peer folder (EACCES/EIO) - a peer that never answers, and a hanging `realpath` through `resolveLettaTranscript` - the pane's own record rewritten during the wait - the pane's Letta exiting or moving during the wait - `bun run check fast` passes on current main. The one failure, `scripts/check.test.ts`, was load-flaky and passes 78/0 when run alone. ## Review - A Codex (gpt-6.1-terra, xhigh) review ran before this PR opened, and later rounds addressed: - CodeRabbit's five threads - dori's findings: the non-`/proc` path and the deadline on `lettaRecordOwned` - the merge pass's HIGHs: the `fstat` identity check, and failing closed when the record vanishes - **Behaviour change:** a peer record that exists but can't be read or proven now blocks binding a pane that has no report, where before it was ignored. --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
fix(relay): end a pairing host on revocation or key removal, and stop… … per-second alarms in grace (#3346) Fixes #3307. ## What In `relay/pair-room.ts`, a typed-code pairing host: - **Is re-checked against the relay's current settings.** Its attachment now keeps the pass's key id and server id next to the expiry. `lapsed()` closes the host with 4402 `relay pass invalid` once the PC is revoked or its signing key is removed. Before, an admitted host outlived both until its pass expired or its 11-minute deadline passed. - **No longer fires an alarm every second during grace.** Before enforcement, `passDue()` returns only the enforcement time, so an expiry already in the past doesn't re-arm the alarm. - **Hosts from before this deploy are kept.** A host admitted by the previous version, whose attachment has only `pass_exp`, keeps its expiry check but is not closed for missing the new fields. Hibernating sockets survive a deploy, and those hosts end at their 11-minute deadline anyway. - **README:** `relay/README.md` now says that revocation applies at the next frame, arrival or alarm that the new settings reach, and that a Durable Object may keep running the previous version for a few minutes after a deploy. These cases matter only once the relay enforces passes; it does not yet. Merging deploys the relay (`relay-deploy.yml`), which drops every socket once. ## Validation - Failing-first tests in `relay/worker.test.ts`. Each failed on main and passes now: - revocation or key removal ends the host at the next guest's arrival - the grace alarm is no longer set one second ahead - a host restored from a main-shaped attachment stays open - More tests: - revocation with a guest already connected, ended by its next frame and, in a second case, by an alarm - a valid host crossing `enforceAt`, with the next alarm asserted before and after - Relay test files run one at a time: 160 pass, 0 fail. - `bun run check fast` passes on current main (eb584a0), with the unit suite fully green. ## Review A Codex (gpt-6.1-terra, xhigh) adversarial review ran before this PR opened. It found: - MEDIUM, fixed: hosts from the previous version would be closed after a deploy. - MEDIUM, documented in the README: after a deploy, an object can keep the old policy for a few minutes. - LOW, fixed: the existing-guest and enforcement-crossing tests above. --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
fix(letta): Letta status follow-ups from review (#3334) ## Summary Part of #3332: follow-ups to #3331 (Letta status where no report gives one) from its reviews. - **Refresh key only for Letta panes:** `lettaRefreshKey` returned a key for every pane, so on herdr-core a title change in a shell, an editor or another agent forced a new Letta lookup and bypassed the 5 s interval. Panes that aren't Letta's now give an empty key. - **Blocked kept in a late read:** in a snapshot read that finished after a newer one, a file-tracked pane waiting for approval kept the file's `working`. It now keeps the `blocked` the newest read gave it. - **Tracking kept through a replay:** after a subscription gap, the collector replays the snapshot's status, which is Ovis's own Letta status. `onStatus` took it for a report and ended tracking. Now only a live event, or another agent, hands the pane back. - **A denied approval told as a failed rest:** while the title asked for approval, the file's changes were dropped, including the reason for a rest. An aborted or errored answer written meanwhile is now kept and passed on when the wait ends, so the end is told as a failed rest, not a finish. - **The checked file is the one opened:** the messages file's path is checked, then opened with `O_NOFOLLOW`. The opened file's device and inode must now match the checked one, so a folder swapped for a link in between opens nothing. - **A status event that says nothing:** for an unreported Letta pane on herdr-core, an `unknown` event now takes the status its own title last showed, instead of overwriting the work it showed. Still open in #3332, each needing more than a small change: a false `done` when an approval starts during a snapshot; turn continuity when a brief report takes the pane; title-only turns shorter than the collector's 500 ms snapshot delay; checking that a spinner title is Letta's own. ## Validation - `bun run check fast`: ok (typecheck, build, unit 13084 pass). - Failing-first tests: `server/letta-status.test.ts` (a title change in another pane, a late read) and `server/core/letta-status-hub.test.ts` (a replay, a denied approval, an `unknown` event). The check-then-open race can't be hit deterministically. The link and FIFO refusals stay tested.
feat(letta): show Letta's status where no report gives one (#3331) ## Summary On herdr-core, a Letta Code pane is named (#3308) and its chat opens (#3324), but its status stayed `unknown`: Letta sends no OSC 7501 report. This gives Letta panes a status where no report gives one, from facts Letta writes itself, never read off the screen: - **Local conversations:** a new tracker (`server/letta-status.ts`) reads the conversation's `messages.jsonl` in Letta's local backend, which Letta appends to during the turn. Its transcript is written only when the turn ends, so it can't show work in progress. The records have the same shape as gjc's transcript, so gjc's reducer and the existing session-file tracker are reused: a prompt, a tool call or a tool result means working; `stop` or `length` means at rest; `error` or `aborted` means a failed rest. It is wired into `server/core/status.ts` next to gjc's, and applies only to Letta panes whose status is `unknown`. - **Cloud conversations, or panes without a bound conversation:** Letta's own terminal title (`terminal_title` only) carries a braille spinner frame while a turn runs (`⠋ Tutor`) and the plain name at rest (`titleStatus()` in `server/agent-identity.ts`). - **Waiting for approval:** in Letta's `standard` permission mode, a tool call waiting for the user's approval shows only as the pending call in `messages.jsonl`. The titl 7C1B e reads `[ ! ] Action Required | <name>`, so the pane shows `blocked`, even when its messages file is tracked. It applies only where no report and no tracked messages file gave a status. The herdr-core adapter sends `pane.updated` when that spinner starts or stops, and a title-derived turn goes through the normal status path, so a finished turn can alert. - **Handing a pane back:** a status the program reports, or another agent starting in the pane, takes it back from Letta's files at once. Rediscovery runs at gjc's 5 s, and earlier when the title's name or mark changes. ## Verification On a Linux PC with herdr-core (hc 0.1.17, an isolated HOME and herdr-core session) and letta-code 0.34.44, while Letta ran a 6-second tool call (last checked at this branch's final code): - **Local conversation:** `idle` → `working` (t1 to t8, matching Letta's spinner) → `done` (t9 onward). - **Pane with no bound conversation:** a Letta pane started in its home folder, where no conversation is bound, so only the title applies. It showed `idle` → `working` (8 s) → `done`. ## Validation - `bun run check fast`: ok (typecheck, build, unit 13075 pass). - Tests: `server/letta-status.test.ts` covers the reducer on one real turn of letta-code 0.34.44's `messages.jsonl` (fixture), discovery (only unknown Letta panes; cloud conversations get no file), and the title fallback (first look tells nobody; changes are told; a reported or gone pane is forgotten). `server/agent-identity.test.ts` covers `titleStatus`, and `server/herdr-core/adapter.test.ts` covers the spinner title event. ## Known limits - A title some other program left behind could be read as Letta's before Letta writes its own. This is limited by the Letta-only and still-`unknown` gates, but not ruled out. - A title-only turn shorter than the collector's 500 ms snapshot delay can pass unseen. Spinner changes reach the rows as `pane.updated`, not as status events. - A report or another agent that arrives while the first discovery of a pane is still running can be overtaken by it once, until the next refresh (every second) hands the pane back. Review notes: Codex reviewed this branch twice before it opened. All findings from the first pass are fixed. From the second pass, three MEDIUMs are fixed: a title turn found running now counts as work; the title goes on from the file's last status when the file stops being tracked; a report with an unrecognised word no longer stops title updates. The other two are the limits above.
fix(letta): open Letta's chat from its own record when no report name… …s the conversation (#3324) ## Summary On herdr-core, Letta Code's pane was named after #3308, but its chat still showed only the terminal. Letta's chat binding needs a session report (`herdr:letta`), which only herdr's Letta integration sends. herdr-core sends none, and neither does herdr without the integration. With no report, `lettaTranscriptFor` now binds the one conversation Letta's own project record (`.letta/settings.local.json`) names. Without a report, the record's age is the only thing that backs it, so more must hold: - the letta was started plainly, or with only model, backend, toolset or system-prompt options. Plain `letta` resumes the conversation the record names; any other option (`--new`, `--resume`, `--agent`, `--name`, an unknown flag) leaves the chat on the terminal; - Letta saves its sessions: `LETTA_DISABLE_SESSION_PERSIST` is not set to anything, and the process isn't a subagent (`LETTA_CODE_AGENT_ROLE=subagent`); - no other Letta pane goes without a report: it might have moved here from a folder with the same record; - the transcript was written after the process started, which shows this process's own use of the conversation; before that, the chat stays on the terminal; - the record names exactly one conversation, and no other Letta pane's folder record names the same one. This holds even when that pane's own report names a different one, because a report can predate a `/resume`. The existing checks still apply on top of these: the record is older than the process, the process is still in the folder it started in, the folder isn't its home, no other Letta pane runs in the same folder, the conversation belongs to that agent, and the transcript is held. This applies to herdr's TUI too: a Letta pane without the herdr integration now gets its chat. The behaviour is the same on both runtimes. ## Verification On a Linux PC with herdr-core (hc 0.1.17, an isolated HOME and herdr-core session) and letta-code 0.34.44 on its local backend, after one real turn: `GET /api/pane/conversation` returned `source: letta-transcript` (16 turns at the latest check) and metadata `model: openai-compatible/claude-haiku-4-5-20251001`, which the model card reads. Before this change it returned `source: scrollback`. With a second, unreported Letta pane open, it stays `scrollback`, as intended. ## Validation - `bun run check fast`: ok (typecheck, build, unit 13023 pass). - Failing-first tests in `server/letta.test.ts`: no report binds the record's one conversation (default and local), still refused when the record is newer than the process, when it names two conversations, or when the report has another form; another Letta pane's folder record naming the same conversation refuses, reported or not. One older assertion pinned the old behaviour, "no report → Terminal"; it now expects the record's conversation.
PreviousNext