Changelog#
All notable changes to OpenDray v2 are documented in this file.
The format is based on Keep a Changelog. Version numbers follow this project's own major-as-generation strategy: major version = product generation, minor = feature iteration, patch = fix / polish. See VERSIONING.md for the full rationale and what triggers a major bump.
[Unreleased]#
[v2.15.1], 2026-09-08#
Fixed#
- Grok sessions now scroll cleanly on desktop and mobile. Grok used to open in its fullscreen mode, which has no scrollback and ignores the mouse wheel, so scrolling was erratic on desktop and impossible on phones and tablets. Grok now opens in its scrollback native mode, so the wheel and finger swipe scroll the conversation the same way they do for Claude. (#546)
[v2.15.0], 2026-09-05#
Added#
- Grok account switching can now carry the conversation across the switch, matching Claude. Switching a running grok session to another account with "carry over conversation context" turned on seeds the new account's fresh session with a recap of your recent conversation. It is opt in and consent gated: the recap is sent to the provider under the new account, which the confirm dialog states before you switch. (#542, #543, #544)
[v2.14.1], 2026-09-04#
Added#
- Grok accounts get a full UI: a Grok accounts panel in the Providers page (list, import local, enable or disable, remove) and live account switching from the session header, matching Claude and Antigravity. This surfaces the grok multi-account backend that shipped in v2.14.0. (#539)
- Opt-in git worktree isolation, so concurrent sessions can each work in their own worktree without stepping on one another. (#531)
- Mobile can reach a gateway behind Cloudflare Access without a VPN. (#538)
Changed#
- Documentation rewritten in plain, human prose with no em dashes, the README simplified so it reads top to bottom in one pass, and the changelog brought up to date. (#537, #528, #536)
[v2.14.0], 2026-08-31#
Grok catches up. You can pool several xAI accounts, bind one to a session,
and switch a running grok session from one account to another without
losing the session, the multi-account parity Claude and Antigravity
already had. Each account is an isolated GROK_HOME; the heavy install and
cache directories are shared across accounts by symlink, so a second
account costs kilobytes, not gigabytes.
Added#
- Grok multi-account: pool accounts, pick one per session, and live-switch
a running session between them (
PATCH /sessions/{id}/grok-account) without dropping it. Each account is an isolatedGROK_HOME; MCP trust is per-account and the heavy install/cache dirs are shared by symlink. (#533, #534, #535) - Cortex spawn injections (memory guidance, ambient memory, skills) now
reach grok too, coalesced into grok's single
--rules. (#530)
[v2.13.3], 2026-08-22#
The one-line installer runs to the end again, as root, on a fresh Proxmox LXC or VPS, which is exactly how it is usually run. And Cortex, the knowledge layer, grows a lifecycle: operator edits stick, a page can be handed to an in-session agent, proposals are reviewed as a diff, and the sweep respects a page's approval gate.
Added#
- Cortex knowledge lifecycle: durable operator edits, polarity, deletion-as-signal, and conflict execution. (#521)
- Hand a knowledge page to in-session agents (#513); review proposals as a diff instead of two full documents (#515); let the sweep honour a page's approval gate (#516).
- Tasks grouped by project in the management views. (#520)
- Git: an "Update branch" action so a stale PR can be merged from opendray. (#518)
Fixed#
- Installer: root installs never completed: the Postgres readiness gate
always failed as root (
run_priv -u postgresran a literal-u), andmigrateran withHOME=/rootso its backup keyfile was unreadable. The one-liner now runs to the end. (#529) - Mobile: a knowledge proposal is reviewable before you decide on it. (#519)
- Cortex:
doc_read's framing is no longer written into pages. (#517) - Catalog: compare resolved paths when detecting non-npm bin links. (#514)
[v2.13.2], 2026-08-11#
The Vault stops being a flat pile of markdown. It holds a folder
structure you maintain, files each project under its own name, and
renders HTML documents as carefully as it renders markdown, from an
in-memory string in a sandboxed frame, with scripts off, because a
document that arrived by git pull is not your own writing.
The phone catches up with the browser. It can see whether your edits
reached the remote, browse by tag, follow [[wiki links]], show what
links back, and start today's note, instead of being somewhere you
could only read what you had written elsewhere. The admin UI itself
now fits a phone screen, which it never did.
Added#
The admin UI works on a phone and a tablet. It was built for a desktop three-pane layout, which on a phone meant a horizontal scrollbar and two columns you could not reach. Every page now has a narrow form: below 1024px the third pane becomes a slide-over, below 768px the list becomes the page until you pick something and the navigation tree follows it into a drawer. Tables, spacing and the topbar were sized to match.
The drawer is one component rather than a pattern re-implemented per page, and it is reachable from a labelled control in the header, never from an edge handle alone, which is unreachable in practice.
The Vault syncs from the phone. Repository status, a manual commit / push / pull, and the auto-sync settings are all on the phone now; previously the mobile app could edit documents but not see whether they had reached the remote, so the answer to "did that save get anywhere" was only available on a desktop.
The mobile Vault has the knowledge layer the web has had. It could create, read, edit, rename and delete a document, but nothing that makes a vault more than a folder. It now browses by
#tag, shows a document's backlinks and its tags, follows[[wiki links]], completes them while you type, jumps by heading, and opens today's daily note from the same template the web writes.Two constraints worth knowing. The preview runs with JavaScript off, a document can arrive by
git pull, so it is not the operator's own writing by default, which means wiki-links are anchors on a private scheme intercepted at navigation, and a heading jump lands in the source view rather than scrolling the rendered page. And the path rules are Unicode-aware on mobile: the web's ASCII-only sanitiser turns笔记.mdinto--.md.The Vault stores and renders HTML documents, not just markdown. Project documentation increasingly ships as HTML, exported from Notion or Word, generated by typedoc, asciidoc or Sphinx, and a doc library that cannot open those is a markdown library.
.html/.htmare now first-class alongside.md: listed in the tree, creatable, movable, and rendered.HTML is never served from opendray's own origin. There is no endpoint returning a document as
text/html; that would execute it same-origin with the admin session, so a file pulled from a git remote could take over the account. The body travels as a string through the existing JSON read and is rendered from memory in a sandboxed frame with an opaque origin. Scripts are off by default, exported documentation is static markup and renders identically without them, with a per-document opt-in for pages that genuinely run code, remembered locally per file.Mobile gains a rendered view for the first time. It previously showed only a document's source, which HTML would have made unreadable, so preview covers both kinds: markdown is converted and rendered through the same webview rather than drawn as native widgets, so the two formats look like one product.
Deliberately not symmetric:
[[wiki links]]are still scanned and rewritten in markdown only. An HTML document can be the target of one, but its own<a href>links are left alone. Rewriting those on a move is a different job, with relative paths, anchors and assets to get wrong. Auto-derived paths (daily, project, personal notes) stay markdown. And when a filename and a template disagree,guide.htmlfrom the markdown Blank template, the filename wins, since the operator chose the extension and the template was probably a default.In-document links work: clicking a table-of-contents entry scrolls to that heading, and only links that actually leave the document open a new tab. This needed more than it sounds like. See Fixed below.
Fixed#
Vault auto-sync settings could not be saved. Whatever interval you set, the dialog snapped back to "every 10 minutes": the form refetched every 8 seconds and overwrote the draft mid-edit, so Save was never enabled and no setting had ever reached the database. The interval is now a free-text Go duration with presets, and the gateway rejects an unparseable one with a message instead of quietly substituting a default.
The mobile Vault's rename and delete were unfindable. They existed, behind a long press on a row, with nothing on screen saying so, reported in testing as missing outright, which for a hidden gesture amounts to the same thing. Each row now carries a visible actions button in place of a chevron that did nothing the row did not already do, and the open editor offers rename and delete where the web has always had them.
Creating
guide.htmlon the phone producedguide.html.md. The new-document path appended.mdunconditionally, so an HTML document could not be created from mobile at all.Six hints rendered
<prefix>as literal text instead of<prefix>.A table-of-contents link no longer loses the document. In the web viewer, clicking an in-document
#anchornavigated the frame away and rendered the opendray app inside the document view. Asrcdocframe's document URL isabout:srcdocwhile its base URL is inherited from the parent page, sohref="#install"resolved against the gateway and became a real navigation. The base is now pinned to the frame's own document, which also stops a pulled document's relative URLs from resolving against the gateway's origin.The first attempt at "external links should open a new tab" used
<base target="_blank">, which retargets every link, so a contents entry opened a blank tab instead of scrolling. Anchors are now rewritten individually: fragment links are left in place, an author's explicittargetis respected, and only links leaving the document gettarget="_blank". Generated documentation is precisely where this mattered: Sphinx, typedoc, asciidoc and Notion exports all ship a fragment contents list, and markdown footnotes render as fragment links too.Mobile deliberately does not pin a base:
loadDatamakes the document's own URL and its base bothabout:blank, so fragments already resolve in place, and pinning would have introduced the bug rather than fixed it.
Changed#
CI no longer runs the whole suite twice on every branch commit. A
feat/**branch with an open PR matched both thepushandpull_requesttriggers, and the concurrency group keys on the ref, which differs between the two events, so neither run cancelled the other.pushis now main-only;pull_requestalready covered branch work.A project's documents are filed under the project's name. The Vault is a project documentation library, and it filed every project under
projects/, a folder naming what the whole library already is. One level of nesting that told the reader nothing, on every path, in every listing, and at the top of the git repository the Vault syncs to. The operator's own notes were split off further still: agent docs atprojects/<name>/…, the human scratchpad atpersonal/<name>.md, the same project's material in two distant places, sorted by who wrote it rather than by what it is about.New vaults now use
<name>/…with that project'spersonal.mdinside it. Existing vaults are not rearranged. The layout is decided once, at first start, and written intoconfig.tomlas[vault] layout. Recording it is the point: the alternative, work the shape out from what is on disk each time, is the bug that put one install's entire document library behind anotes/directory, because a probe asking "does this folder have content?" changes its answer the moment someone puts content there.The doc library offers the conversion, on web and on mobile, to any vault that still nests projects. A migration that ships only as a CLI command is one that only the people who wrote it ever run. Every existing vault would have stayed nested with its owner never learning there was a choice. The offer is dismissible and never appears for a vault that is already flat or has nothing to move, and the gateway says the same thing once in the startup log for anyone who never opens the UI.
Nothing moves without a preview. Web and mobile both run the migration as a dry run and show the real list, including what it refuses to touch and why, before anything is renamed.
opendray notes flattendoes the same from a terminal. It defaults to a dry run, drives the same rename the UI uses so[[wiki links]]are repointed as it goes, repoints per-cwd project overrides, and never overwrites: a destination that already exists is reported and skipped, leaving both copies for you to reconcile.Where a project's notes live is now answered by the gateway,
/notes/inforeports the layout and the project mapping carriespersonal_path, instead of being re-derived by web, mobile and the CLI. Three implementations guessing is three chances to disagree, and the CLI'snotes projectwas already guessing wrong.Reserved names step aside rather than collide: a project called
dailyfiles underdaily-docs, sincedaily/YYYY-MM-DD.mdbelongs to the whole vault._- and.-prefixed names are reserved too.The conversion records the resulting layout itself and applies it to the running gateway, rather than leaving both to the next restart. Otherwise a converted vault keeps deriving
projects/<name>andpersonal/<name>.mdagainst the directories it just emptied. The directories the migration empties are removed; one still holding anything is left exactly where it is.Documents save when you say so, not on a timer. The editor wrote after every pause in typing, so a long document rewrote itself to disk over and over while it was being worked on. Saving is now a button, or ⌘S / Ctrl+S, on web and on mobile. Leaving a document still flushes unsaved text, that safety net costs nothing while typing, and closing the browser tab with unsaved work asks first.
Autosave was not the whole story behind the typing lag, so two things that were: the editor streamed every keystroke to the page, which re-rendered the whole vault tree and recomputed the outline per character; and the tree re-rendered along with it. The stream is now paced to what a sidebar can use, and the tree only re-renders when the note list or the selection changes.
Line numbers in the source view. On by default for HTML, off for markdown, and toggleable either way. Numbers turn wrapping off, the way every code editor does it: a wrapped line covers several rows, so a gutter counting 1..N drifts on exactly the long lines an HTML document is full of.
The doc library can rename and delete a document. It could create and edit one, and that was all. A daily note, or anything not bound to a project, could be made but never moved or removed from the one surface that lists everything. Rename goes through the move endpoint, so the
[[wiki links]]pointing at the old path are repointed rather than left dangling, and a partial rewrite is reported instead of being folded into a plain "renamed". Mobile gains rename too; it already had delete."New" and "Today" are reachable once a document is open. They were in the header the whole time, pushed off the right edge by the vault path: a
truncateelement in a flex row still needsmin-width: 0, or it refuses to shrink below its content. Anyone with a long vault path could only reach the two actions from the empty state, which is exactly when they had no documents to leave.A folder holding the selected document can be collapsed again. The tree re-applied "open the ancestors of the selection" on every render, so the collapse landed and the next render undid it, and "Collapse all" left that one branch open. Revealing the selection is a response to the selection changing, not a rule about what must stay open, so it now runs once per selection.
opendray notesaccepts flags after the subcommand. Go'sflagpackage stops parsing at the first non-flag argument, sonotes flatten --applyleftapplyfalse, performed a dry run, and advised re-running with--apply, which is what had just been typed.notes list --prefix=xsilently listed everything for the same reason. Both orders now mean the same thing.The Vault is your documents. Agent skills and the MCP registry moved out. One root held three tenants with nothing in common: the operator's markdown, the skills opendray injects at spawn, and the MCP registry, which is why the settings page could only describe it as "notes, skills and git-versioned root", a sentence that parses only if you already know the implementation. Opening the Vault showed
skills/andmcp/sitting among your folders, and because Vault Sync commits that same directory, they went to your remote: on one install a private docs repo had picked up opendray's ownskills/secretary/SKILL.md, and agit clean -fdthere would have deleted the gateway's skills.New installs get
~/.opendray/vaultfor documents,~/.opendray/ skillsand~/.opendray/mcpbeside it, and a Vault repo holding writing and nothing else. Existing installs are not moved: any root with content still in the old place keeps being used, the settings page prints where everything actually resolved, and says plainly when it is still the shared layout.vault.notesandvault.skillskeep working;vault.rootnow means the documents directory, and[skills].rootis the new spelling. Machinery directories that do sit inside the Vault are hidden from the doc library and added to an opendray-managed.gitignoreblock, so nothing new gets carried to your remote. Anything already committed needsgit rm --cached. opendray will not rewrite your repo.Path resolution used to be reimplemented in four places (the gateway plus each of
opendray notes|skill|mcp) with different precedence in each, so the CLI could read a different directory than the running gateway. There is now exactly one resolver.
Fixed#
Verifying a git credential now checks that it can push, not just read. A GitHub fine-grained token's Contents permission has three levels: No access, Read-only, Read and write, and every check the verification made passed identically for a Read-only one. So it went green, the Vault pulled happily, and the first push came back
remote: Write access to repository not granted ... 403, the same message a token with no Contents produces on a plain fetch, which sends you looking at everything except the token.Verification now also probes git's receive-pack advertisement: literally the first request
git pushmakes, asked with the same credential over the same protocol, and a plain GET that changes nothing. If it is refused, push will be refused. Because it is git's wire protocol rather than a forge API, one probe covers GitHub, Gitea and GitLab, and it cannot disagree with what git actually does. A read-only credential now reports "CANNOT push (read-only)" and names the setting to change. A forge answer that is neither a clear yes nor a clear no is reported as nothing at all. Telling someone their working token is read-only is the same mistake pointed the other way."Reset to remote" no longer silently destroys unpushed work. It ran
git reset --hardplusgit clean -fdbehind a confirmation that named no quantity, survivable when the remote is ahead of you, and not survivable when the remote is empty. A vault whose pushes have all been failing is exactly that, and the two faults compose: one operator lost 354 documents when a read-only token made every push 403, the local commits piled up unpushed, a pull hit a rebase conflict, and "reset to remote" looked like the way out of the conflict.The endpoint now counts what exists only locally (unpushed commits, modified files, untracked files) and refuses with a 409 and that breakdown unless the caller explicitly confirms. The dialog quotes the numbers and names example files instead of asking "are you sure?". And confirming is no longer final: opendray parks the unpushed commits on an
opendray-rescue/<timestamp>branch and stashes the working tree (--include-untracked, sinceclean -fdis what destroys untracked files and no ref can hold those) before resetting, then names the rescue branch in the success toast. A tree that is already level with its remote loses nothing and still resets in one click. A confirmation that fires on no-ops is one people learn to dismiss.
Added#
Git credentials are scoped per host and owner, so one forge can hold several identities. One row per hostname assumed one identity per forge, which breaks the moment you touch a personal repo and an org repo on the same host: a fine-grained GitHub token is granted per repository, so the token that reaches
github.com/<you>/…generally cannot reachgithub.com/<org>/…, and there was nowhere to put the second one. Git host entries now take an optional Owner; resolution prefers the owner-scoped credential and falls back to the host-wide one, so existing setups keep working untouched. Vault sync resolves the same way, which it previously could not: it only ever looked up by hostname, and its auth panel now names the credential it resolved to, saying plainly when the remote's owner has none of its own and the host-wide one is standing in.Git hosts is now the authority for HTTPS git auth. A session's push went out with whatever the machine offered. Xcode ships
credential.helper = osxkeychainenabled, so a stale keychain entry answered silently and failed with an error describing a token nobody remembered configuring. Pushes now authenticate with the configured credential, and inherited helpers are blanked for HTTPS remotes even when opendray has nothing registered: failing as "no credentials" beats quietly succeeding as an identity you never chose. SSH remotes are untouched: the agent is a deliberate, visible configuration.Disabling a git host entry now actually disables it. The toggle changed nothing: credential resolution returned disabled rows and every caller (vault sync, PR and issue listing, remote detection) used the token regardless, so an entry switched off kept authenticating. The check now lives in the resolver, which also makes disabling compose properly: turn off an owner-scoped entry and its host falls back to the host-wide one, exactly as if the row were absent.
Git host entries can be verified against the forge. A stored token was a claim nobody checked, and the forges hide the mistake: a GitHub fine-grained token keeps "which repositories" and "which permissions" in separate sections of one form, with permissions defaulting to none, so granting all repositories and stopping there produces a token that authenticates perfectly and cannot read a single repo. Git then reports
Write access to repository not grantedon a plain fetch, naming the wrong permission on the wrong operation. Verify now asks the forge who the token belongs to, warns when that differs from the entry's owner, and optionally checks a specific repo, with a hint that says where to look.
Added#
- Markdown in the Vault is syntax-highlighted while you edit it. The
file viewer has always coloured what it shows, so raw markdown in the
Vault (the one place people actually read and write it) was the last
flat grey surface. Web layers a highlighted backdrop under the
textarea; mobile colours the field directly through its editing
controller, so the caret can't drift from the glyphs. Headings, bold,
italic, code, links, quotes, lists, tags and
[[wiki-links]].
Fixed#
- The Vault no longer describes itself as an Obsidian feature. It syncs through a plain git remote. Obsidian is merely one editor that can be pointed at the same repo, not something opendray integrates with. The user-facing wording was corrected in the previous release; this clears the same claim from the code that outlives it.
Added#
- New docs start from a template, and folders can explain themselves.
Every doc previously started as an empty file with a heading, which is
how a vault ends up with five different ideas of what a feature note
is. Creating a doc now offers Blank / Feature / Decision (ADR) /
Runbook, and a folder holding a
README.mdgets a control that opens it, so a directory can say what lives in it. Templates render server-side (the title comes from the filename, the date from the clock) so a doc started on the phone and one started on the web come out identical rather than drifting. Dropping_templates/<id>.mdin the vault overrides a built-in or adds a new one, so a project can change the shape of its docs without a gateway release.
Added#
- The Vault can hold a folder structure you actually maintain. Project
docs were a flat list: the "New doc" box replaced
/with-, sofeatures/canvas.mdbecamefeatures-canvas.mdand a folder could not be created from the UI at all, while the backend had stored nested paths the whole time and the Notes page already rendered them as a tree. The project-docs lane now renders that tree (rooted at the project, with a Recent toggle for "the one I just edited"), typing a path with slashes files a doc in a folder, and a new move/rename repoints the[[wiki-links]]that pointed at the old path. Without that, filing a doc away silently stranded every reference to it, which is why nobody reorganised. Web and mobile both; the rewrite skips code blocks, so a fenced example of the syntax is never edited.
[v2.13.1], 2026-08-07#
Your phone and your browser can reach the gateway again while its Mac
sits idle. A machine that falls asleep takes its network with it, so the
gateway simply stops answering, a failure that looks exactly like a
flaky gateway from every surface that talks to it, and sends people
reading gateway code instead of pmset -g log. opendray now holds the
host awake while it serves, and lets you choose how far to take that.
Added#
Host power is now a setting you can see, on web and mobile. The choice between "stay reachable" and "let the machine sleep" was only reachable by hand-editing
config.toml, which meant nobody found it. Server settings gain a Host power section listing all four modes with what each one costs (web renders them as annotated cards, mobile as a labelled picker) plus a note that a deliberate sleep is never blocked and that non-macOS hosts ignore the setting. Translated across English, 中文 and Español.Wake-on-demand: the host may sleep, and phone/web traffic wakes it.
[host] prevent_idle_sleep = "on_demand"lets the gateway's Mac sleep whenever things are quiet instead of being held awake around the clock. Incoming traffic dark-wakes the machine (macOS "Wake for network access"); the gateway then seizes a power assertion the moment a request, live stream or mid-turn session shows up, extends the wake for as long as serving actually takes, and lets go after a two-minute linger so the host can sleep again. Running sessions count as activity, so a turn started from a phone survives the phone locking. On-demand warns at startup ifwompis disabled, since without it a sleeping host can't be woken remotely at all. The existing"ac"(default),"always"and"off"behaviours are unchanged.
Fixed#
Saving invalid server settings no longer bricks the next restart.
PUT /admin/settingswrote whatever it was given. Since the loader validates on every startup, a bad value (an unparseable duration, an unknown mode) saved cleanly and then stopped the gateway from coming back up, with nothing naming the offending field. The write path now validates exactly what is about to hit disk and answers 400 instead, leaving the stored config untouched.The gateway keeps its host awake, so phone and web stay connected. A Mac left to itself idle-sleeps, and a sleeping host takes its network with it: the gateway stops answering, a remote Postgres becomes unreachable, and every phone or web request times out until someone physically wakes the machine. Incoming traffic does dark-wake the host, but the window is short and a user-session LaunchAgent isn't reliably scheduled inside it, so the request has already timed out, which reads as "opendray is flaky" and sends people reading gateway code instead of
pmset -g log. opendray now holds a power assertion for as long as it serves. Default is wall-power only, so a laptop on battery still sleeps normally;[host] prevent_idle_sleeptakes"always"or"off". A deliberate sleep (lid close, Apple menu → Sleep) is never blocked, and the assertion is tied to the gateway's pid so even a SIGKILL can't strand the host awake. macOS only; accepted and ignored elsewhere.
[v2.13.0], 2026-08-07#
The Canvas: the agent renders a page you can actually see, mark up and iterate on, instead of describing a screen in prose and hoping it matches.
Added#
- Canvas: a visual surface for designing with the agent. The agent
renders a self-contained HTML page via
canvas_renderand it appears in the operator's panel, where they pin a point or drag a region and send those marks back as feedback. Marking reports the real element selector and markup (and for a region, the components inside the frame) so the agent iterates on the DOM instead of guessing from coordinates. Not only UI mocks:ui,flow(flowchart),mindmap,graph(relationship diagram) anddoc(a formatted spec page), with diagrams authored as inline SVG so they need no external assets and stay markable node by node. - A focused canvas per project. A project accumulates many canvases, so
one is FOCUSED per cwd, scoped to the project rather than the session
because the MCP server only ever receives the cwd. Browsing the list is
free and costs nothing; only an explicit "Work on this" seeds the note
that makes ordinary conversation ("make the title bigger") resolve to that
canvas. Agents pull the same fact with
canvas_context, and a slug-lesscanvas_rendertargets the focused canvas rather than creating a duplicate. - A design system per project. Colour / type / radius / spacing tokens plus free-text style rules, carried in every canvas request AND injected into each rendered document as CSS variables. The pairing is what stops successive renders from drifting apart. Ships starting palettes for people who don't think in hex, a real colour picker, and two one-click jobs: read the project's actual theme, or draw the system as a canvas.
- Full mobile parity for all of the above: viewer, viewport switching, focus and precise marking from the phone.
opendray statusis now a systemctl-style health dashboard. On macOS the command used to dump ~80 lines of rawlaunchctl printinternals. A live process says nothing about whether the gateway is serving HTTP or can reach its database. The default output is now a compact checklist: process, HTTP health + uptime, database reachability, listen address, restart count (with the last non-zero exit surfaced as a hint) and the config path. The raw launchd/systemd dump moved behind--raw.
Changed#
primaryin a design system means the BRAND colour, and the gateway now says so. shadcn/ui and the Tailwind templates built on it use--primaryfor a near-black or near-white ink and keep the brand hue in--accent, so an agent mapping token names one-to-one produced a palette of greys with no brand colour in it. The contract is now stated wherever an agent reads it (the extract prompt, thecanvas_designtool schema, the token docs and the catalog guidance) and saving a palette whose every colour resolves to a grey returns an advisory warning saying what probably went wrong.
Fixed#
- Design-system swatches show the real colour, in any notation. Reading
a colour back from
getComputedStyle().coloronly yieldsrgb(…)for legacy sRGB notations (a modern colour function round-trips unchanged in both WebKit and Chromium) so an all-oklch project got a grid of identical grey fallbacks on the web, and mobile, which parsed nothing but#rrggbb, got no swatches at all. The gateway now resolves hex / rgb / hsl / oklch / oklab centrally and serves the result, which also lets the hex-only OS colour picker edit an oklch theme without converting it one field at a time. - Pin and region marks stay on the content they were placed on. The web
panel recorded a mark as a percentage of the visible frame and drew it in
an overlay on top of the preview, so it was anchored to the window: scroll
the canvas and the mark stayed put while the content moved out from under
it, and a mark made after scrolling was stored pointing somewhere else
entirely. Marks are now recorded in document percentages and drawn into the
canvas document itself. This also settles a mismatch where the same
x/yfield meant frame percentages from the web and document percentages from mobile. - The mouse wheel (and touch swipe) now scrolls grok and opencode
sessions. These TUIs enable mouse tracking (so xterm hands them the
wheel instead of scrolling its own viewport) but then ignore wheel events,
scrolling only on arrow keys. opendray's wheel→arrow fallback was gated
behind "app hasn't grabbed the mouse", so it never ran for them and the
wheel did nothing (verified live: grok sets
?1000/1002/1003/1006at startup yet does nothing on button-64/65). The web terminal now recognises these wheel-ignoring providers and sends arrow keys for both wheel and one-finger touch scroll, scoped byproviderIdso Claude/Codex/Antigravity (which genuinely consume the wheel) are untouched. - Coming back to the mobile app after it slept no longer fails session loads. While the app is suspended the OS silently tears down its TCP connections, but the HTTP client's pool didn't know. The next request went out on a dead socket and stalled into a 30-second timeout and a full-screen error, even on a LAN. Connection-level failures on GET/HEAD are now replayed up to twice with backoff (a 4xx/5xx still passes through untouched), the pool's idle timeout drops to 5 seconds so stale sockets are rarely handed out at all, and returning from a suspension of 5+ seconds refetches the session list immediately instead of waiting for the next tap.
Security#
- Hardened the path-containment barrier behind
/fs/download,/fs/zipand/fs/upload. The root-scoped filesystem endpoints now validate the resolved path withfilepath.IsLocaland refuse any residual..sequence in the canonical path (which also means oddly-named entries likenotes..mdare rejected inside these endpoints). Resolves all seven open CodeQLgo/path-injectionalerts plus one allocation-size-overflow finding. - Bumped vulnerable transitive npm dependencies in the web workspace
lockfile: seroval 1.6.2 (critical GHSA-mv8w-475r-vwqw), brace-expansion
5.0.9 (three DoS advisories), postcss 8.5.26 (path-traversal advisories)
and
@babel/core7.29.7. Lockfile-only; no manifest changes.
[v2.12.2], 2026-07-23#
Round Table members gain live, grounded memory, the mobile session screen is rebuilt around a tool dock, and the operator takes ownership of the four global knowledge pages so their curation finally sticks.
Added#
- Round Table members read the shared memory. Every seated provider now
gets live, read-only access to the shared
opendray-memoryMCP, so members ground their claims in the real store instead of guessing. A new read-only mode (OPENDRAY_MEMORY_READONLY=1) exposes only the search / read tools and refuses every write server-side (safe even with tool permissions open) and the table prompt flips to "ground your claims via these tools". A single per-provider attach point (catalog.AttachMemoryMCP) handles the antigravity / codex quirks and is reusable for any spawn. - Operator owns the form of the four global KB pages. The shape of
kb_infrastructure/kb_conventions/kb_lessons/kb_reusablewas hardcoded in the drafter's prompts, so every consolidation sweep re-manufactured the fixed fat skeleton and operator curation never stuck. The drafter now honours each page's blueprintmaintainer_mode(human→ the operator owns it outright, never drafted) and, for AI-maintained pages, edits the operator's current structure in place (folding in only new, on-topic evidence) instead of regenerating from a template. A per-pageprompt_hintlets the operator steer form and scope without a code change. Set both viaPUT /blueprint/{slug}(cwd__global__); a web/mobile toggle is a follow-up. No migration. The four pages default to the previous behaviour. - Mobile: session tool dock. The session detail screen is rebuilt so the terminal owns the full height under a two-line AppBar title, and a new SessionToolDock (Files · Git · Database · Vault · More) opens each tool as a bottom sheet over the live terminal. The overflow ⋮ menu is slimmed to session actions.
Changed#
- Mobile: More / settings redesigned. The flat menu is rebuilt as grouped
inset cards per section, with an account-block identity header (monogram
avatar), accent-tinted icon chips, refined typography and a version footer.
New
sessions.dock.more/sessions.tools.*strings ship across en / zh / es at 100% parity.
Fixed#
- A human-locked global KB page could never converge from the UI. Two bugs
are fixed: approving a divergence proposal silently dropped the lock (the
merge was written as
updated_by='agent', flippingHumanLockedoff). An approval is an operator decision, so the result now stays operator-authored and locked; and rejecting a proposal didn't stick: the drafter re-generated and re-filed the identical refresh every consolidation cycle. The drafter now skips a locked page whose feedstock signature matches an already-rejected proposal, so a rejection holds instead of nagging forever.
Docs#
- The README surfaces the two marquee capabilities shipped since the last pass: Round Table (cross-vendor AI group chat + role-assigned execution plans) and the Database tool (Postgres / MySQL / MariaDB / SQLite, per-project crypto isolation, web + mobile), plus staged image attachments, TUI theme-following / wheel-scroll, and one-click provider updates.
[v2.12.1], 2026-07-18#
Grok reaches parity with the other cloud agents, the Cortex knowledge base grows a real management surface, and the mobile knowledge experience is rebuilt for phones.
Added#
- Grok is now a first-class cloud agent. It can drive the shared memory
MCP (memory search,
doc_read, cross-layer recall) just like Claude / Codex / Antigravity. Its spawn folder is marked trusted so grok actually starts the injected memory server instead of silently skipping it. Grok and OpenCode are also selectable in Discuss with AI and as Memory Worker agent providers, and creating a grok session now offers the Bypass permissions / YOLO toggle (--always-approve) the other agents already had. - A cross-page KB Librarian (experimental). Launch a dedicated agent session (pick its cloud agent, model and account) that can organize, create, edit and delete any global knowledge page across the whole base, driven conversationally, unlike the per-page Discuss chat. It gets read + write KB tools (list / upsert config / write body / delete) on its memory MCP; those tools are scoped to the Librarian session alone and never reach ordinary or third-party sessions.
- Edit a knowledge page's settings after creation. A
kb_*page's title, one-line description, nature (foundational / emergent) and inject flag were locked in at creation; they are now editable in place (web + mobile) on every page except the classic four, including seeded pages like Integrations, so you can flip a page between full-inject and on-demand retrieval. - Discuss with AI model lists are live and accurate. Antigravity and OpenCode models are enumerated straight from their CLIs, and Codex offers its full model family (higher plans unlock the fuller models) instead of one pinned choice, no more picking a stale model that fails at spawn.
- Round Table members can change mid-conversation. Add or remove seated
providers on an active chat (web + mobile): an added member is
@mentionableon the next turn with the full thread as context; a removed one stops replying while its past messages stay. - Mobile: staged image uploads. Images queue in a dismissable tray before send instead of uploading immediately.
Changed#
- The mobile Knowledge (KB) page is rebuilt as a searchable list → detail
flow. The old horizontal page-chip strip didn't scale once you had many
kb_*docs; the KB tab is now a grouped, searchable list (Foundational / Emergent) that grows gracefully, and tapping a page opens a full-screen reader/editor with its actions in an AppBar overflow menu. New page and the Librarian move onto a FAB.
Fixed#
- Grok sessions had no MCP / memory tools. opendray wrote the memory server
into the project-scoped
<cwd>/.grok/config.toml, but grok refuses to start repo-local MCP servers in an untrusted folder as a supply-chain guard, so the server was configured but never started. opendray now trusts the operator's own spawn folder (--trust), matching the other CLIs. - The web terminal input cursor no longer drifts on iPad.
[v2.12.0], 2026-07-16#
Added#
- Round Table: a cross-vendor AI group chat (experimental). Seat several
providers (Claude / Codex / Antigravity / Grok / OpenCode) plus the operator
in one shared thread; @mention who should reply (or
@all) and each member answers in character after reading the whole conversation, so heterogeneous foundation-model families react to each other in seat order. Summarize the discussion on demand, or turn it into a role-assigned execution plan: each step runs as a real session in a shared project (bind the project after the fact if you started without one). Hand the whole thread off to a working session to do the actual code changes. A chat can be closed and reopened (close keeps the thread, just stops new messages). Available on both the web admin and the mobile app, where Round Table gets its own bottom-nav tab, per-agent bubble colours, and labelled action menus. Fully self-contained and rollback-able (internal/roundtable/ROLLBACK.md).
Fixed#
- The Files-tree download icon is now reachable on touch devices (iPad,
phones). The per-row download button was revealed only on hover
(
group-hover) or keyboard focus. Tailwind v4 gatesgroup-hoverbehind@media (hover: hover), so on a touch device (which can neither hover nor focus a row) the icon stayed atopacity-0and was impossible to tap. It now pins visible under@media (hover: none), so touch users get a permanently-shown download control while pointer users keep the clean hover-reveal. (Follow-up to the v2.11.6 positioning fix, which addressed where the icon sits but not whether it ever appears without a mouse.) - Two MCP servers sharing a display name no longer brick Codex sessions.
Every provider renderer keys its generated config on a server's display
name, not its unique id. Two enabled servers with the same name therefore
collided on that key: Codex emitted a duplicate
[mcp_servers."…"]TOML table and died withduplicate keyat startup (before printing a byte, so the session flipped straight to the read-only "[buffer unavailable]" view) while Claude's map-based renderer silently dropped one of them.renderMCPnow rejects a duplicate name up front (for every provider, before any config file is written), and the Plugins create/update endpoints return409when a new or edited server would reuse a name already taken by a different id. Grok's manifest gap and this collision are unrelated; a stray second Notion entry sharing the nameNotion APIis what exposed it. - Grok now reports and applies CLI updates from the Providers page. The
grokmanifest carried an emptynpmPackage, and the whole update path is npm-gated:CheckUpdatereturned early (no latest version, no "update available" flag) andUpdatehard-errored with "not updatable via npm". Grok is published as@xai-official/grok(maintainer[email protected]), so the manifest now names it. - A provider CLI installed outside npm can now be updated in place. Grok's
documented installer (
curl -fsSL https://x.ai/cli/install.sh | bash) drops a symlink into the npm bin dir that npm does not own, and npm refuses to clobber it:EEXIST: file already exists. Simply naming the package would therefore have shipped a dashboard that advertises an update behind a button that always fails.Updatenow preflights the bin path: an unmanaged symlink is cleared so npm can take ownership (and the update output tells the operator exactly which link was replaced), while a regular file is never deleted: it is reported instead, mirroring the existingErrUpdatePrefixReadonlypreflight. Grok's install note now recommendsnpm install -g @xai-official/grok.
[v2.11.6], 2026-07-13#
Fixed#
- The download icon is reachable again in a deep or long file tree. The session inspector's Files tree renders inside a scroll area whose inner wrapper sizes to its content, so long filenames and deep nesting pushed rows wider than the panel: names were hard-cut with no ellipsis, and the hover-download icon (anchored to each row's right edge) sat beyond the visible edge, so hovering a file appeared to do nothing. The tree is now constrained to the panel width, so names truncate with an ellipsis and the download icon sits at the visible right edge. The Database tab is unaffected (its grid scrolls in its own containers). (#443)
[v2.11.5], 2026-07-13#
Added#
- TUIs follow the opendray theme. A terminal UI picks a light/dark
palette by asking the terminal, via the OSC 11 background query (which
xterm.js already answered) or the
COLORFGBGenvironment variable, which opendray never set. So a CLI that reads the environment (Grok'stheme = "auto", vim, tmux, …) had no way to know the operator was in light mode and always defaulted to dark. opendray now stamps the client's applied theme on session create and advertises it at spawn viaCOLORFGBG. Optional and backward-compatible: no theme advertises nothing and the CLI keeps its own default, and an explicitCOLORFGBGalready in the environment still wins. (#446) - The mouse wheel scrolls full-screen TUIs. In the alternate screen there is no xterm scrollback, and a CLI that hasn't grabbed the mouse never receives wheel events either, so the wheel silently did nothing and a Grok conversation couldn't be scrolled at all. opendray now does what a real terminal does (alternate-scroll): wheel notches become cursor Up/Down keys when the app is in the alternate screen and hasn't grabbed the mouse. CLIs that do grab the mouse (Claude Code, Codex, Antigravity) are unaffected. They already receive the wheel as SGR events. (#446)
- Custom tasks are pre-scoped to the current project. (#442)
Fixed#
- A disconnected browser no longer wedges CLI updates. Provider updates
ran
npm install -gon the HTTP request context, so a client disconnect (browser closed, proxy timeout) cancelled it and SIGKILLed npm mid-install. A half-killed npm leaves a partial global tree behind (a stale.<pkg>-XXXXXXtemp dir) after which every later install fails withENOTEMPTY, permanently wedging updates for that CLI (a codex update stayed broken for a week this way, and left a CLI whose platform binary never landed, so its sessions failed too). The install is now detached from the caller's cancellation. (#445) - A broken CLI is now visible instead of looking healthy. When a
provider's binary is on
PATHbut won't run, opendray used to fall back to showing the manifest version, rendering a CLI that can't even launch as perfectly fine. It now reports "Installed but not runnable" with the CLI's own error, and a failed update surfaces npm's actual message (ENOTEMPTY: …) rather than a bareexit status 217. (#445)
[v2.11.4], 2026-07-12#
Added#
- Mobile: Resources section + Updates "what's new" sheet. The mobile app gains the sidebar Resources block and the Updates/"what's new" sheet, reaching parity with the web admin (#433). (#439)
Fixed#
- Antigravity spawns no longer fail on an empty MCP config. antigravity's
first-run migration writes an empty
~/.gemini/config/mcp_config.json; opendray's MCP-injection prep parsed it unconditionally and errored with "provider prepare: parse …/mcp_config.json … unexpected end of JSON input", blocking every Antigravity session spawn. An empty (or whitespace-only) file is now treated as "no config yet" rather than a parse error, on both themcp_config.jsonand geminisettings.jsonsurfaces. (#440) - Grok provider icon. Grok is now registered in the web provider icon/visual lookup tables so its brand mark renders. (#434)
[v2.11.3], 2026-07-09#
Added#
- Session terminal: staged image attachments. Uploading an image to a session (attach button, clipboard paste, or drag-and-drop) now stages it as a dismissable chip in a tray at the bottom of the terminal instead of typing the server path straight into the running CLI. Esc (or the chip's ✕) cancels it (an empty tray still passes Esc through to the CLI) and an Insert button commits the path(s) when you're ready. Fixes the long-standing "the uploaded path can't be dismissed" surprise. Web for now; mobile parity to follow. (#436)
- Sidebar Resources block + Updates drawer. The web admin's left nav gains a Resources section under Settings: an Updates drawer that shows "what's new" from GitHub Releases (falling back to CHANGELOG.md) with an unread badge and "mark read", plus Docs, Community, and Sponsor links. (#433)
[v2.11.2], 2026-07-09#
Added#
- Database tool: MySQL, MariaDB and SQLite. The Database tool now
connects to MySQL and MariaDB (host/port/username like PostgreSQL; a
MySQL "schema" is a database) and SQLite in addition to PostgreSQL. The
connection form (web and mobile) gains an engine picker with per-engine
default ports. SQLite is a file-path connection: the path is fenced
to the connection's project
cwd(a path escaping it via../or a symlink is rejected) and extension loading is disabled. Reads run behind the same read-only fence on every engine (SQLite via a dedicated read-only connection pool). All engines are pure-Go drivers (go-sql-driver/mysql,modernc.org/sqlite), so the binary still cross-compiles without cgo. Migration0075widens the driver constraint;0076reseeds the kb_integrations page.
Fixed#
- Backup download link authorises correctly. The backup download URL now carries the admin token, so downloading a backup from the web UI no longer fails auth. (#428)
[v2.11.1], 2026-07-09#
Added#
- Mobile parity: Database tool in the session inspector. The mobile
app's session inspector gains a Database tab mirroring the web tool:
browse schemas and tables, page through rows, insert / update / delete
by primary key, and run read or write SQL against the project's
registered connections, honouring
db:read/db:writescopes and read-only connections, and reusing the session'scwdfor isolation. - Mobile parity: upload files into a session. The mobile session
inspector's Files tab gains an upload button: pick one or more files and
stream them into the current directory via
POST /api/v1/fs/upload, matching the web files-sidebar upload shipped in v2.11.0 (sameresolveWithinRootsandbox, auto-rename on name collision).
Security#
- Database tool: cryptographic per-project isolation for the
auto-attached MCP. The
opendray-dbtoolMCP now holds adb:signedkey and sends a per-sessionX-OpenDray-Dbtool-Sig = HMAC(secret, cwd)header; the gateway rejects a signed-key call whose signature doesn't match thecwd. An agent that extracts the injected key can no longer forge another project'scwd, closing the residual the honest-path check left open. Antigravity (whose MCP config is HOME-global and can't carry a per-session signature, a Google limitation) and third-party integration keys keep the plain?cwd=check via a separate honest-path key. Migration0074reseeds the kb_integrations page.
Fixed#
- Database tool: bigint primary keys stay exact. Row update/delete
and filters decode JSON with
UseNumber, so a 64-bit primary key above 2^53 is no longer rounded throughfloat64(which could address the wrong row or match none). Numbers beyond int64 keep their exact string. - Database tool: consistent table metadata.
TableMetaruns its four catalog queries (columns / PK / indexes / FKs) inside one read-only transaction, so concurrent DDL can't produce a half-updated view.
Changed#
- Dependencies. Bump
golang.org/x/crypto0.50.0 → 0.52.0 (#421) andgolang.org/x/net0.52.0 → 0.55.0 (#418), pulling transitivex/sysandx/textupdates. Build and vet clean.
[v2.11.0], 2026-07-08#
Added#
- Upload files & folders into a session from the files sidebar. The
session inspector's files panel can now create folders and upload files
or whole folders (recursively, preserving the subtree) via an upload
button or drag-and-drop, including dropping onto a specific folder row
to target it, or the panel background to target the session cwd.
Uploads land in the session's working directory where the AI model reads
them, streamed to disk (250 MiB per file) and confined to the session
cwd by the same
resolveWithinRootsandbox the download/zip endpoints use. Path traversal and symlinked-intermediate escapes are rejected. Conflicting names auto-rename (name-1.ext) instead of overwriting what the session produced. New admin-only endpointPOST /api/v1/fs/uploadon the existing/fsgroup; the tree refreshes to show new files (including renames). (#420) - Database tool: direct project database access. opendray can now
hold per-project (cwd-keyed) database connections and expose them like a
JetBrains-style database tool: browse schemas/tables, read table data,
edit rows, and run a SQL console. It surfaces two ways: a Database
tab on each project screen (web: connection manager, lazy schema tree,
paginated data grid with row insert/edit/delete, and a CodeMirror SQL
console with schema-aware autocompletion; mobile: connection management,
schema browse, read-only query) and an auto-attached
opendray-dbtoolMCP server (db_connections_list/db_schema/db_table_data/db_query/db_execute) so agent sessions can query and mutate a project's database directly. PostgreSQL only for now (a driver interface reserves MySQL/SQLite). Connection passwords are encrypted at rest with the same field cipher as channel/git-host secrets and are never returned by any read endpoint. Two new scopes,db:read(browse + read-only SQL) anddb:write(row CRUD + write/DDL), gate integration access; registering a connection stays admin-only (an integration can never point opendray at a new host). Reads run inside a server-sideREAD ONLYtransaction with a statement timeout, and per-connectionread_onlyrefuses every write regardless of scope. The dbtool MCP is withheld fromorigin=integrationsessions, matching memory isolation. Configurable via[dbtool](enabled by default; the feature is inert until a connection is registered). Migrations0072(schema) and0073(kb_integrations reseed). - Mobile: antigravity multi-account parity with web. The mobile app gains the antigravity multi-account management already shipped on web (#396). (#409)
- Mobile: Grok provider brand mark, syncing the real Grok icon added to web in #405. (#408)
- Memory search surfaces folded (deduped) variants in
memory_searchandmemory_load_context, so callers see the merged form rather than near-duplicate rows. (#414)
Fixed#
- antigravity MCP injection now targets the real config surface, so injected servers actually reach antigravity sessions. (#416)
- Transcript overlay for CLIs with unscrollable TUIs: the web terminal can surface scrollback for tools whose full-screen TUI can't be scrolled natively. (#415)
project_searchmoved from admin-only to dual-auth +memory:readscope, so integrations can search project memory. (#413)- Per-provider spawn parity: codex bypass, antigravity memory CLI, and a default-model guard for integration-originated sessions. (#412)
- Integration default-agent model is an explicit dropdown rather than a free-text datalist, on both web (#411) and mobile (#410).
Docs#
- README refresh: reworked hero, added comparison + FAQ, and synced the 5-CLI provider list across all 10 translations. (#417)
[v2.10.1], 2026-06-22#
Added#
- MCP servers reach Grok. Grok Build sessions now receive opendray's
enabled MCP registry (HashiCorp Vault, etc.), per-provider
mcp_servers, integration-scoped servers, and the opendray-memory server, the same injection every other MCP-capable provider gets. opendray writes them into the project-scoped<cwd>/.grok/config.toml[mcp_servers]table, which Grok union-merges with your global~/.grok/config.toml(your personal servers are untouched). Previously Grok shipped with MCP injection disabled, so it could not see Vault or any other shared server the operator had configured. (#404) - Cortex-first knowledge framing. The spawn banner now opens with a
preamble telling the agent to consult opendray's injected cortex
(
kb_*pages) as the authoritative source first, treating any external mirror (Obsidian vault, wiki) as a secondary fallback used only when the cortex doesn't cover the topic. Stops agents from grounding infra/DB process answers in stale external notes when the curated copy is already in-context. (#406) - Current objective always injected. Lean-mode spawns now inject the
live
current_objectivebody as a dedicated "work to THIS" block (not just an index entry the agent had to remember to fetch), plus a stronger proactive-maintenance directive so agents keepcurrent_objective, the journal, and durable memory current on their own. (#403) - On-demand, section-level KB access.
doc_readandproject_searchcan now pull a single heading-section of a large global knowledge page instead of the whole thing: akb_integrationslookup drops from ~15K tokens to ~300–1.3K, and search hits carry adoc_read(slug, section=…)pointer instead of dead-ending on a teaser. (#400) - Cross-project distilled knowledge now applies. Fixed the experience
compiler reading session outcomes through an ephemeral table, which
starved it of feedstock (112 journal summaries → only 2 survived) so it
produced zero global playbooks. Outcomes are now denormalized onto the
durable journal row (migration
0070, with a one-shot backfill of historical rows), so the compiler sees the full corpus and its global playbooks auto-inject at spawn. (#402)
Fixed#
- Grok provider icon. Grok now shows its real brand mark in the spawn dialog and provider rail instead of the neutral letter-disc fallback. (#405)
[v2.10.0], 2026-06-21#
Added#
- Antigravity multi-account. Bind a session to a specific Antigravity
(
agy) login, switch accounts from the session header, and manage accounts in Providers → Antigravity (discovery + guidedHOME=… agylogin). Accounts are isolated by$HOME, the agy analogue of Claude'sCLAUDE_CONFIG_DIR. Switching accounts keeps the conversation: agy stores each conversation as a portable per-$HOMESQLite db, so the switch copies the current conversation into the new account's HOME and resumes it (--conversation <id>). You continue the same chat on the other identity, only the credential/quota changes. Restarting an antigravity session resumes its conversation too. (#396) - Grok Build CLI provider. xAI's
grokas a first-class provider (install withcurl -fsSL https://x.ai/cli/install.sh | bash, thengrok login; modelsgrok-build/grok-composer-2.5-fast, bypass via--always-approve). Resolves exe/model/bypass generically: no per-CLI adapter code. (#397) - OpenCode local-endpoint diagnostics. When a session's local endpoint
(LM Studio / Ollama / vLLM) is unreachable or serves no chat-capable
model, opendray now surfaces a one-time spawn notice explaining the cause
(check the URL ends in
/v1, the endpoint serves on the LAN, a chat model is loaded) instead of OpenCode's opaque[buffer unavailable]. (#398)
Changed#
- Carry-context is ON by default when switching Claude accounts, and rate-limit auto-failover now carries context too: a switch seeds the new account with a recap of the prior conversation instead of starting blank. Untick the toggle for a clean-slate switch. (#395)
Removed#
- Gemini CLI provider retired, superseded by Antigravity. Removed from
the install wizard, the provider catalog/UI, the
opendray providersnpm-update list, and the Cortex "discuss with AI" list. Existing Gemini sessions and on-disk credentials are left untouched, but the provider is no longer offered to new installs. (#397, #399)
[v2.9.1], 2026-06-19#
Fixed#
- Escalating a Cortex discussion now jumps straight into the spawned
session and continues on the same CLI + account. The escalated session
surfaces immediately (web deep-links it via
?open=, mobile pushes the route) instead of only appearing on the next manual list refresh. It also inherits the conversation's provider / model / Claude-account override rather than always falling back to Claude. (#390)
Changed#
- Refreshed the third-party integration guide and its searchable
kb_integrationsKB page to match the shipped v2.9.0 contract:permission_mode(default|bypass) replacing the oldbypass_permissionsboolean, the per-principalintegration:<id>memory zone, the enforcedproviders:write/providers:updatescopes, and the reservedagent_idfield. Removes the staleFORTHCOMINGframing so any AI or developer reading it gets the current contract. (#391)
[v2.9.0], 2026-06-19#
Added#
- Per-integration spawn profile. Each third-party integration now carries its own provider-agnostic spawn config (MCP servers, system prompt, and a permission-bypass toggle) decoupled from per-CLI args, so one integration behaves consistently across Claude / Codex / Gemini. (#381)
- Per-integration default agent + first-class session model. Choose the default agent and model an integration's sessions spawn with, from a dedicated web + mobile config UI. (#378, #379)
- Native Select & Copy in the terminal. Drag (or long-press on touch) to select any portion of the buffer (a command, a line-wrapped URL) and copy it, on both web and mobile. Replaces the old whole-buffer copy. (#374)
- Claude account selector for AI discussion. Cortex's Discuss With AI lets you pick which Claude account drives the discussion (web + mobile + i18n). (#385)
- Remove (delete) session control alongside Stop, so an ended session can be cleared from the list rather than only halted. (#387)
- Mobile parity for integrations + project blueprint, bringing the phone app level with the web admin's integration management. (#386)
- Third-party integration guide + searchable
kb_integrationsKB page: an authoritative, on-demand reference for wiring external callers. (#382)
Changed#
- Integration-origin sessions are isolated from the operator's session list and default to no memory capture, keeping third-party traffic out of the operator's working view. (#375, #376)
- Retired the detected-URLs badge on the terminal pane: native Select & Copy now covers the OAuth-login URL case the badge existed to rescue. (#388)
Fixed#
- Third-party integration memory capture is routed into per-integration zones instead of leaking facts into the operator's partition. (#380)
- Backup pre-migration snapshots auto-discover the newest
pg_dumpso a stale PATH default can't crash-loop migrations. (#383) - AI discussion on non-Claude providers uses the correct per-CLI
headless invocation (e.g. gemini
--prompt, codexexec). (#384)
[v2.8.0], 2026-06-16#
Added#
- OpenCode CLI provider. Drive OpenCode (the open-source,
provider-agnostic agentic coding CLI) through the same PTY gateway as
Claude Code / Codex / Gemini. Point it at a local OpenAI-compatible
endpoint (Ollama / LM Studio / vLLM) with just a base URL: opendray
generates a per-session config, auto-discovers the endpoint's models so
they all appear in OpenCode's
/modelpicker, and wires the sharedopendray-memoryMCP, skills, and a spawn-dialog bypass toggle. (#369) - AI discussion model picker on mobile, matching the web admin's cloud-agent + local-model selection in Cortex's Discuss With AI. (#368)
- Antigravity is now selectable as a cloud agent in Cortex's Discuss With AI. (#370)
- Backup-hardening arc + Cortex memory/notes UX integrated from beta: scheduled/fan-out backups across pluggable targets, pre-migration snapshots, and the conversational notes/knowledge maintenance surface. (#367)
Changed#
- Recovery Kit clarity. The dialog now states what the kit is for (disaster-recovery insurance, rarely needed), that each generation is an independent file sealed with its own password (nothing is stored, there is no master password), and that the password protects the file, not the gateway. Downloaded kits are named by key fingerprint + date so regenerating no longer silently overwrites a prior kit. (#371)
Fixed#
- Release announcements trigger via
workflow_runinstead of the release event. (#366)
[v2.7.6], 2026-06-14#
Added#
- Carry context on Claude account switch (opt-in). Switching a live
session to another account starts a fresh conversation (Claude Code
can't
--resumeacross accounts). A new "Carry over conversation context" toggle in the account switcher seeds the new account's session with a recap of the prior conversation, read from the old transcript, injected into the system prompt. Off by default; the toggle's helper text is the consent surface, since carrying context sends prior conversation content to the provider under the new account. Automatic rate-limit failover never carries context. Also fixes the stale switch-confirm copy that still claimed history was preserved via--resume(removed in v2.7.x). - Release announcements auto-drafted for X. Each published release
now appends an "Announce on X" block to its GitHub release notes with
a one-tap intent link composed from the CHANGELOG, and (when
TYPEFULLY_API_KEYis configured) queues a Typefully draft.
Fixed#
- Web self-update no longer dead-ends on live sessions. When an in-app upgrade would interrupt running sessions, the gateway gates the restart behind a confirmation (the sessions auto-resume). The web About panel previously surfaced that gate as a raw error with no way forward; it now shows an "Upgrade anyway" prompt with the live- session count and proceeds on click.
[v2.7.5], 2026-06-11#
Fixed#
- iOS Safari: restored the contextual copy pill on terminal text
selection (regression from v2.7.3). PR #353 added
touch-action: pan-yto.xterm-viewportso iOS scrollback worked with the clipped pane, but the same property claimed horizontal / diagonal gestures for the browser and broke xterm's selection-drag on iPad and iPhone: the pill (anchored atpointeruponce a selection finishes) never appeared becausepointeruparrived with no finished selection to anchor to. Droppedpan-y(the defaultautois sufficient for iOS scrollback) and added atouchendbelt-and-suspenders fallback so the pill anchors reliably even when iOS Safari's synthesisedpointerupdoesn't fire (or fires with stale coordinates) on canvas-internal events.
[v2.7.4], 2026-06-11#
Added#
- Inspector: one-click download for files + zip-on-the-fly for
folders. Every row in the Files tree now carries a hover-revealed
Download icon. Clicking it on a file streams the bytes as an
attachment with the original filename (including unicode via RFC
5987
filename*=); clicking it on a folder streams a built-on-the- fly zip archive of the visible subtree (hidden entries + symlinks skipped to match the tree's listing).http.ServeContenthandles Range requests so resume works on large files; the zip builder walks deterministically and skips per-file permission errors instead of aborting the whole archive. Downloads are confined to a caller-suppliedroot(the session's cwd in the inspector) and the server EvalSymlinks-checks the resolved target stays inside it, so a hand-crafted URL can't exfiltrate files from outside the inspector's view even with a leaked admin token. - Plugins: drag-and-drop install of
SKILL.md. Drag aSKILL.mdonto the Agent skills table and the daemon slugs the frontmattername:into an id and writes it to<vault>/<id>/ SKILL.md: no id prompt, no editor modal. Vault collisions return 409; built-in collisions become overrides with the existing badge. i18n parity preserved across en/es/zh.
Fixed#
- Web sessions: breathing room around the chat pane. Slimmed
the chrome (SessionTabs
h-9 → h-7, WorkbenchHeaderh-14 → h-11, avatar36 → 28px) and reserved a 12px bottom strip so Claude's input no longer sits flush against the browser bottom edge. Net result: chat-top moves up ~20px and typing feels less cramped on tall windows.
[v2.7.3], 2026-06-10#
Fixed#
- Mac Safari terminal regressions introduced in v2.7.2 by #323. The
chat no longer extends past the browser bottom; Claude's TUI input
wraps correctly past the visible right edge after 3-4 lines; and
[disconnected — reconnecting…]stops stacking: it announces once per disconnect cycle, then prints[reconnected]on recovery or[connection lost — refresh the page to reconnect]after retries are exhausted. WebKit'sResizeObserverfires late on absolute-positioned elements nested inside flex containers, which leftfit()reading a stale size after sidebar/banner shifts; the xterm host now stays in-flow withcontain: layout paintinstead. The WebSocket retry budget was also bumped (maxRetries6 → 30,maxBackoff8s → 15s) so an idle proxy bouncing the socket doesn't read as "broken forever." - iOS web terminal scrollback. Two-finger drag on the terminal
canvas now scrolls xterm's scrollback.
.xterm-viewportgot-webkit-overflow-scrolling: touch,overscroll-behavior: contain, andtouch-action: pan-y. The AppShell switched fromh-svhtoh-dvhso the layout tracks the live visual viewport (keyboard, address-bar) instead of locking to the address-bar-visible height.
Added#
- Web: "update available" badge on the Settings icon. A small
accent dot appears on the gear in the sidebar (expanded, icon-rail,
and mobile slide-over) when a newer release is detected, so the
upgrade prompt finds operators instead of waiting for them to open
About. Background poll every 6 hours, shares the
['version']query cache with the existing AboutSection so a manual "Check now" updates the badge immediately. Suppressed whilepending=trueto avoid nagging during an in-flight upgrade. i18n parity preserved across en / es / zh. - GitHub Sponsors landing material.
SPONSORS.md(pitch, five tiers, FAQ, thank-you wall),docs/sponsors/dashboard-copy.md(paste-ready text for both the Opendray org and the navidrast personal Sponsors dashboards), and.github/FUNDING.ymlupdated to list both accounts.
[v2.7.2], 2026-06-04#
Added#
opendray doctor+opendray setup-macos(macOS).setup-macosgives the binary a stable, per-machine self-signed code-signing identity (in a dedicated keychain, fully non-interactive) and re-signs it, so a one-time Full Disk Access grant survives rebuilds/updates instead of macOS re-prompting on every version change.doctoris a read-only health check that flags an ad-hoc signature or a config living in a TCC-protected folder.opendray servewith no-confignow falls back to~/.opendray/config.toml(outside the protected folders) so a fresh install's gateway starts without a privacy prompt.- macOS release binaries are now Developer ID-signed + notarized
(when the signing secrets are configured), so a user's Full Disk
Access grant persists across
opendray update. Signing runs via quill on the Linux release runner and is a no-op when unconfigured. - Telegram
/peekcommand + control-keyboard button to re-send the selected session's latest output on demand; the docked control keyboard now refreshes on/selectand/start. - Mobile: switch a running Claude session's account + agent-CLI update awareness. Rebind a live session to a different account from the session screen (web parity), and see when a provider CLI has an npm update available. The session Tasks tab also reached web parity.
- Spanish (es) translation across web + mobile with in-app language switching, plus a CI translation-parity guard that fails the build on missing/extra keys.
Changed#
- Telegram notifications consolidated on
enabled+muted+ the repeat policy. The redundantnotify_enabledswitch and the per-topicnotify_onpicker were removed: an enabled, unmuted channel notifies once per round, and the web channel card gained the mute toggle that mobile already had.
Fixed#
- Switching a Claude session's account no longer leaves it stopped and
unrestartable. The switch now starts a fresh conversation under the
new account (a session UUID that account's CLI actually knows) instead
of
--resume-ing a UUID minted under the previous account, which failed with "No conversation found" and exited the process. - Web terminal jitter caused by the page's scrollbar: the terminal pane
is now isolated from
<main>. - Mobile: numeric Telegram
chat_idis submitted as a number, not a string, so a Telegram channel configured from the phone starts. - Release pipeline: release notes are written outside the work tree so goreleaser's dirty-tree check passes.
Removed#
ghcr.io/Opendray/opendraycontainer image (all 370 versions / 84 tags). The image was orphaned: the most recent tag wasv2.1.0(five releases behind), no workflow in.github/was building it any more, no docs / installer scripts / discussions referenced it. More importantly, a pullable container contradicted the host- resident-only deploy policy (Discussion #300, README "Choose how to run it" table): opendray runs AI CLIs through PTYs and shares filesystem state (~/.claude, ssh-agent, project files) with them, which container isolation breaks. Operators who landed on the GHCR page following stale links from external blog posts would have pulled v2.1.0 and hit exactly that failure mode. Supported install paths remain the one-line installer, the from-source quickstart, andnpm install -g opendray.
[v2.7.1], 2026-06-01#
Security + bug-fix rollup on top of v2.7.0. No API, config, or schema changes. Drop in.
Security#
- Path-traversal sanitiser bypass in NotesPanel (#294).
replace(/\.\.\/+/g, '')was bypassable by overlap (....//collapses to../after one pass). Replaced with split-on-slash + filter-out../.+ rejoin, which cannot be bypassed that way. - Windows path-traversal gap in
backuplocal target (#296).filepath.IsAbs("/foo")returnsfalseon Windows, so the prior absolute-path reject left a gap.LocalTarget.resolve()now also rejects paths with a leading/or\, and rejects any colon on Windows to catch drive-relative forms likeC:foo/C:..\evil. - Demo-client API key in log lines (#294). The integration-key
fingerprint embedded in two log sites was emitting bytes from the
secret through
console.log. Dropped the fingerprint entirely;integration_idalready identifies which credential is in use.
Fixed#
- Windows build failure in
internal/session(#296).syscall.SIGTERM/syscall.SIGKILLare undefined on Windows. New build-taggedsignals_{unix,windows}.gohelpers abstract the difference: Unix preserves the priorSIGTERM → grace → SIGKILLladder; Windows falls through toproc.Kill()(TerminateProcess) since the platform has no SIGTERM equivalent. Documented in code. go test -race ./...failing on Windows (#296). Test compat acrossauth,backup,cliacct,catalog,mcp,session,apppackages:USERPROFILEsetenv alongsideHOMEforos.UserHomeDir, Unix-perm asserts skipped on Windows (os.Chmoddoesn't enforce them there), symlink tests skip whenos.Symlinklacks privilege, shell-script fake MCP server replaced withTestMain-as-fake-server pattern,app_test.gouses an existing file as fake parent dir for cross-platformos.MkdirAllfailure. Full suite now passes on Windows (44 packages, 0 failures).- Identity-replacement no-op in NotesPanel (#294).
prefix.replace(/\/$/, '/')stripped a trailing slash and replaced it with the same slash: the author meant to ensure a trailing slash. Rewrote asendsWith('/') ? prefix : prefix + '/'.
Docs#
README.fa.md10-way language switcher backfill (#293). The Persian README still listed only English / 简体中文 / فارسی; now matches the ten-way switcher the other nine READMEs got via #282.enable-cli-updates.shdiscoverable from the failure path (#297). The in-app guidance toast (returned when the npm global prefix is read-only by the service user) andscripts/README.mdnow name the helper script that resolves the situation, so operators don't have to grep for it. Closes #262.
[v2.7.0], 2026-06-01#
The Flutter mobile app catches up to web. Features that landed on the web dashboard but never reached mobile are now at parity: Telegram two-way channel config, Claude account metadata, a gateway version check, and most-recently-used session ordering.
Added#
- Telegram two-way channel config on mobile (#290). The mobile
channel form gained the five Telegram fields the web form already
had: owner allow-list (
owner_user_ids), two-way chat toggle (chat_enabled), typing indicator (chat_typing), activity notifications (notify_enabled), and reply length cap (reply_max_chars). Booleans render as switches and serialize to the same config shape the gateway expects. - Claude account metadata on mobile (#290). The provider page now surfaces the gateway-decorated account fields (subscription tier, rate-limit tier, active session count, last-used time, and OAuth email) as inline chips, plus an identity-drift banner with an Accept action when the on-disk OAuth identity changed.
- Gateway version check on mobile (#290). The About screen shows the running gateway's version, commit, and whether an update is available, with a release-notes link. Read-only: the in-app self-update stays on web / the host shell.
- Most-recently-used session ordering on mobile (#290). The sessions list now sorts most-recently-opened first (recorded per-device, persisted across restarts), mirroring the web list; live sessions still group ahead of ended ones.
[v2.6.0], 2026-06-01#
Web/mobile gain a real PR detail surface and a read-only Issues
surface, both backed by the existing git provider plumbing. The
gateway binary is now also distributable via npm: npx opendray
runs without a Go toolchain on the box. Plus a small dropdown-
positioning fix and a handful of new README translations.
Added#
- PR detail surface on web + mobile (#279). Click into a pull
request from the PR list to get description, status (open /
merged / closed), CI check summary, head/base branches, author,
and last-updated timestamp. Backed by the same
git.PullRequestsprovider interface the list view already uses: no new API surface for the host, just a deeper read against what the provider returns. - Read-only Issues surface on web + mobile (#281). List and detail views for repo issues, mirroring the PR layout: title, state, labels, assignee, body, comments thread. Read-only by design: issue creation/edit stays out of scope until the permission model around it is settled.
- Distribute
opendrayas an npm package (#280).npm i -g opendrayornpx opendraynow works: the package wraps the platform-appropriate binary from the GitHub release. Useful for operators on Node-heavy fleets who'd rather not script a curl install. The binary itself is unchanged; npm is just another delivery channel alongside the existing tarballs.
Fixed#
- Dropdown menus clamped to the viewport (#284). The account switcher and the session-action menu could overflow off the right edge of narrow viewports (sub-400px mobile, or a side-by-side desktop layout). Both now flip / clamp so the trailing edge stays inside the visible area.
Docs#
- Seven additional README translations (#282): Spanish, Brazilian Portuguese, Japanese, Korean, French, German, Russian. The README switcher row at the top of every translation now lists ten languages.
- Farsi (Persian) README translation (#283), originally
contributed by Majid Allahverdi
in #278; brought to
mainvia #283 after a cross-fork rebase-conflict workaround. See Credits.
Credits#
- Farsi (Persian) README translation by Majid Allahverdi, originally contributed in #278, brought to
mainvia #283 after a rebase-conflict workaround.
[v2.5.0], 2026-05-31#
Phase 2 Tier A of the multi-Claude-account work: rate-limit-aware auto-failover. A Claude session that hits its account quota can now automatically switch itself to the next non-throttled enabled account, with the conversation continuing seamlessly on the new identity. Plus documentation polish around the release ceremony so the next operator cutting a release doesn't have to re-discover today's gotchas.
Added#
- Rate-limit auto-failover for Claude sessions ([providers.claude]
auto_failover_enabled, default false).pumpStdoutscans each Claude session's PTY for theYou've hit your session limit · resets HH:MM (UTC)banner. On a match:- The current account is marked throttled-until-reset in an
in-memory
ThrottleStore(lazy GC of expired entries). PickFailoverClaudeAccountpicks the next enabled non-throttled account by the same least-loaded heuristic auto-assign already uses.SwitchClaudeAccountruns end-to-end: transcript JSONL hard-linked, PTY respawned with--resume, conversation continues on the new identity.- Bus events for observability:
session.auto_switchedon success,session.auto_failover_no_targetwhen the fleet is exhausted,session.auto_failover_failedwhen the switch itself errors. 5s cooldown per session + 4 KiB rolling window so a persistent banner can't drive the regex on every chunk. Opt-in by design: defaults off so existing operators aren't surprised by silent account switches.
- The current account is marked throttled-until-reset in an
in-memory
RELEASING.mdrunbook at the repo root documenting the release ceremony end-to-end: the chain diagram, the "tag-after-changelog- merges" gotcha, recovery procedures (empty release body, pulled- back release), pre/post-release checklists, roadmap to release-please automation and pre-release-rc.Nchannels.
Tests#
- Pinned contract: disabled accounts are excluded from auto-assign.
A regression-safety unit test for the
enabled<2guard inPickAutoAssignClaudeAccount. The SQL filter (WHERE ca.enabled = true) and the explicit-pin validation path were already covered by live integration + handler tests; this closes the last gap.
Internal#
- New
ClaudeAccountResolverinterface methods:MarkClaudeAccountThrottled,IsClaudeAccountThrottled,PickFailoverClaudeAccount.pickLeastLoadedSQL gains variadicexclude ...string(parameterized viaNOT (ca.id = ANY($1::text[]))).
Config#
- New:
[providers.claude] auto_failover_enabled(default false). Opt-in for the runtime rate-limit-driven account switching.
Honest limitations of Tier A#
- Banner-text fragile: if Claude rephrases the limit message, the
regex needs updating. Fallback separators (
-,|) for the middle bullet are already covered. - No predictive load spread: only reacts to hard limits. Tiers B (active probing) and C (local HTTPS proxy) from the design discussion remain available as upgrades.
- Sessions running on the empty-id default (
~/.claude) are skipped by the failover path for the MVP, mapping the default to a real account row needs a resolver round-trip we haven't exposed yet. After auto-assign kicks in for ≥2 enabled accounts, most new sessions are pinned to a named account anyway, so this gap shrinks to zero in practice.
[v2.4.0], 2026-05-31#
Multi-Claude-account UX, two-way Telegram channel, and a clutch of session-quality fixes. The big new capability: a single OpenDray gateway can now manage multiple Anthropic identities side-by-side and let an operator switch a live Claude session between them without losing the conversation.
Added#
- Claude accounts: filesystem watcher.
~/.claude-accounts/<name>/is now monitored with fsnotify; a new.credentials.json(the result ofCLAUDE_CONFIG_DIR=… claude login) registers an account row automatically. 500ms debounce, backoff-on-error reattach loop, symlink rejection at every level. - Claude accounts: synthetic
defaultrow.~/.claude/.credentials.json(the CLI's own home) now surfaces as a row nameddefaultso the primary identity is visible in the panel without forcing the named-account login flow. - Claude accounts: capacity chips. Each row now shows
subscription_type,rate_limit_tier,active_sessions,last_used_at, andoauth_email, all derived server-side from<configDir>/.credentials.json+<configDir>/.claude.json+ a single JOIN against the sessions table. No new chrome. - Claude accounts: least-loaded auto-assign at session create.
When
POST /sessionsarrives with provider=claude and emptyclaude_account_id(and ≥2 accounts are enabled), the gateway picks the enabled account with the fewest non-terminal sessions (alphabetical tiebreaker). Removes the "everything piles onto default" bias. Explicit operator pin still wins. - Claude accounts: identity drift detection. First-seen
oauthAccount.emailAddressper account is recorded under~/.opendray/cliacct-identity.json(chmod 0600). On every List/Get, the current on-disk email is compared; mismatch surfacesidentity_drift=trueandprevious_emailon the Account row, rendered as a red "identity changed: was X · accept" chip.POST /api/v1/claude-accounts/{id}/accept-identityupdates the baseline so the chip clears. - Session switch preserves conversation.
PATCH /api/v1/sessions/{id}/claude-accountnow hard-links the Claude transcript JSONL from<old_config_dir>/projects/<workspace>/ <session_id>.jsonlinto<new_config_dir>/projects/<workspace>/before respawning. Claude--resumethen finds and replays the conversation under the new account. Hard-link shares one inode so switching back-and-forth keeps both views synchronized. - Telegram: two-way conversational chat. Typing indicator, turn replies, persistent control keyboard acting on the current session, configurable from the dashboard.
- Catalog: warn + confirm before CLI upgrade. The in-app CLI
upgrade button now warns when sessions are using the CLI it's about
to replace, with a new
scripts/enable-cli-updates.shhelper for the non-root install path. - Web: MRU session ordering + Cmd/Ctrl+K palette search.
Changed#
claude_account_idvalidation is now enforced at session create AND at switch: bogus or disabled ids return HTTP 400 BEFORE the row is persisted (create) or BEFORE the live PTY is stopped (switch).- Default idle threshold raised 30s → 5m so long-running tool invocations don't get killed by the idle reaper.
- The "Switch Account" confirmation dialog now says "conversation history is preserved" instead of "in-progress conversation state will be lost", accurate description of what now happens.
Fixed#
token_filledpreviously only checked the legacy<accountsDir>/tokens/<name>.tokenfile, so every config-dir account (the documented flow!) showed "NO TOKEN YET" despite having working credentials. Now reports true when either source has usable credentials.- Gemini reply parsing now reads
chats/*.jsonlinstead of scraping the screen, eliminating screen-dump noise in Telegram forwards. - Session 'shell' provider's chrome stripper is now shell-aware so raw prompt characters don't leak into the channel forwarders.
- Web: copy now works over plain-HTTP LAN (Clipboard API requires HTTPS otherwise), terminal selection-driven copy works, copy pill is anchored at the selection with neutral styling.
Security#
- All disk reads in the cliacct path use
os.Lstatand reject symlinks (<accountsDir>/<name>/,<configDir>/.credentials.json,<configDir>/.claude.json, the legacy token file). Defense in depth against an attacker who can write under the accounts tree. migrateClaudeTranscriptLstat-rejects symlinked sources beforeos.Linkso a planted symlink can't be hardlinked into the new account's tree and read as conversation history byclaude --resume.- Telegram inbound is gated to the configured owner across all message types, not just control commands.
API#
- New:
POST /api/v1/claude-accounts/{id}/accept-identity: clears the identity-drift baseline by recording the current on-disk email as the new accepted identity.
Config#
- New:
[providers.claude] watcher_enabled(default true). Set to false to disable the fsnotify watcher; the Import-local button still works on demand.
[v2.3.4], 2026-05-29#
Fixed#
Language toggle in the web Topbar moved its checkmark but UI strings didn't switch. The zustand → i18next bridge ran as a module-level
useLocale.subscribe(...)ini18n.tsthat mounted before React. Under React 19 StrictMode + Vite HMR + zustand persist hydration the subscription could end up registered against a store snapshot React never re-reconciled with, so picking a language moved the dropdown's checkmark (which reads from the store) without triggeringi18n.changeLanguage(). Moved the bridge into a<LocaleSync />React effect underQueryClientProviderso it shares the same lifecycle as every otheruseTranslation()consumer and they update in lockstep (#267).Nine UI strings rendered their placeholders literally: "update available → {{version}}", "Suggested ({{count}})", "Updated {{from}} → {{to}}", "connected · {{count}} tools", and the three About-panel version-toaster lines all showed the
{{var}}template instead of the substituted value. The web i18next interpolation is configured for single-brace{name}but those particular keys were authored with the i18next default{{name}}. Normalized them across both locales (#261).Mobile
flutter build apkfailed with hundreds of parser errors after slang codegen. Mobile's slang config usesstring_interpolation: braces(matching the web) but the same{{var}}typos that produced literal placeholders on web produced invalid Dart on mobile (({required Object {version})and${{version}}) that wouldn't compile. Same normalization as #261, plus a refresh of the generatedstrings*.g.dartoutputs and alignment ofapp/mobile/pubspec.yamlto the product version (#264).
Changed#
App icons now show the new wooden-cart wordmark glyph instead of the old pink-gradient "D". README was already updated to the opendray.dev wordmark, but the running surfaces (web favicon, Android launcher mipmaps, the full iOS
AppIcon.appiconset, and the repo-rootassets/icons/logo/set) hadn't caught up, so a fresh install showed the new brand on GitHub and the old brand on the device. Regenerated every square icon surface from a single 1024×1024 source so proportions stay consistent across sizes (#266).The Providers page now asks for confirmation before upgrading a CLI that has live sessions on it. Linux file-replacement semantics mean an already-loaded session keeps the old binary in memory, but a long session with lazy / dynamic imports or in-flight subprocess work can pick up new code mid-run. When
n > 0non-terminal sessions are using the provider, clicking Update opens a dialog with the count and an honest explanation of the trade-off; with no live sessions Update still fires immediately, as before. Update-check responses also stay fresh for an hour now (matching the server-side npm cache) instead of being re-fetched on every tab switch (#263).
[v2.3.3], 2026-05-24#
Fixed#
- About panel showed no version and the self-update button did
nothing. The dashboard called the version / self-update API at
/versionand/version/updateinstead of/api/v1/..., so the requests 404'd. Added the/api/v1prefix (#251).
[v2.3.2], 2026-05-24#
Fixed#
- Cross-session memory injection rendered every fact as
- ---. The "Recent project memory" banner took the first line of each memory, which for frontmatter-authored facts is the---YAML delimiter. It now skips the frontmatter and surfaces thedescription(falling back to the first body line) (#250).
[v2.3.1], 2026-05-24#
Fixed#
- Copy buttons silently failed over plain HTTP (LAN IP / mobile).
navigator.clipboardis only exposed in a secure context. Added a sharedcopyText()helper that falls back toexecCommand('copy')and routed the existing copy callsites through it (#249).
[v2.3.0], 2026-05-23#
Fixed#
- Live sessions were destroyed by a daemon restart (e.g. a
self-update). Sessions are now marked
interruptedon a gateway shutdown and auto-resumed on the next startup via their stored agent session id (--resume), with bounded-concurrency spawning and an optionalOPENDRAY_AUTO_RESUME_MAXcap. A drain gate warns before a self-update interrupts running work (#247). - 404 page instead of the login screen after a restart. The 401
redirect now respects the dashboard base path (→
/admin/login) and keepsnextrouter-relative (#248). - Brand icons broke under a non-
/adminbase path (#246).
[v2.2.2], 2026-05-23#
Added#
- Memory: global-scope injection fallback + recency default, a fact told to one session surfaces in another regardless of cwd (#244).
- Transport-aware MCP editor template + "unsupported" badge for Codex (#242).
Fixed#
- Memory endpoints are now scope-gated (admin or
memory:read/memory:write) (#245).
[v2.2.1], 2026-05-22#
Added#
- Always-visible "Check for updates" + re-install action in the About panel (#243).
Fixed#
- Remote MCP URL normalization (#230).
[v2.2.0], 2026-05-22#
Added#
- In-dashboard update notification + one-click background self-update (#241).
- Startup warning when W^X (MemoryDenyWriteExecute) blocks executable memory (#240).
Changed#
- Repository renamed
opendray_v2→opendrayacross code/config/docs; install / uninstall URLs updated (#238, #237).
Fixed#
- Dropped
MemoryDenyWriteExecutefrom the systemd unit: it broke Codex / Gemini sessions (#218).
[v2.1.1], 2026-05-22#
Added#
- Responsive mobile web layout: slide-over nav + inspector with edge handles (#236).
Fixed#
- Telegram channel: handle
/start, and a clearer/listheader for terminated sessions (#235).
[v2.1.0], 2026-05-22#
Added#
- Per-provider model management from the dashboard (#229).
- Real CLI version + "update available" surfaced in the providers API/UI (#227).
- Interactive session switching via
/select+ Talk-to buttons in channels (#226). - Validate MCP servers from the Plugins page (#233).
- Windows installer: a true one-liner: auto-installs WSL2 + Ubuntu, runs the installer, and persists across reboot (#213).
Changed#
- Hardened the merged Update action: provider mutations are gated and the update path degrades gracefully (#234).
Fixed#
- Session list shows session names in
/listinstead of bare ids (#224). - Spawned CLIs get a color-capable
TERMso Claude/Codex/Gemini render in color (#225). - macOS installer hardening: robust local Postgres provisioning, configured-port binding, idempotent launchd reload, bash 3.2 compatibility, and a launchd PATH that finds brew-installed CLIs (#208, #209, #211, #212, #231, #232).
- Windows installer: OS-build guard, auto-resume after a WSL reboot, PowerShell 5.1 compatibility (#214).
- Installer: validate DB identifiers and don't abort on a free / commented-out Postgres port (#210).
Security#
- Scrubbed dev-internal docs + personal-network references from the public repository (#204).
[v2.0.5], 2026-05-18#
Added#
- Flutter mobile session terminal now has the URL detector
badge. Same model as the web admin: the PTY byte stream is
scanned for http(s) URLs with the same state-machine extractor
that re-assembles CLI-soft-wrapped OAuth URLs. A floating pill
in the top-right corner of the terminal: primary tap opens the
most recent URL in the OS browser via
url_launcher, secondary⋯button opens a bottom-sheet with every URL (newest first) for picking older ones. Closes the OAuth-on-Flutter-app gap reported alongside the web fix.
Changed#
- Web login no longer pre-fills the username with "admin". The install wizard lets operators pick any username, so seeding the field forced everyone-who-didn't-keep-the-default to backspace before typing. The field is now empty by default and autofocused.
[v2.0.4], 2026-05-18#
Fixed#
URL extractor now re-assembles CLI-soft-wrapped URLs. AI CLIs (claude-code, codex, gemini) hard-wrap long OAuth URLs at the terminal column width by emitting literal
\ncharacters every ~55 chars. The v2.0.1 / v2.0.2 / v2.0.3 extractor used a[^\s]+regex that stops at\n, so it captured only the first wrapped segment (e.g.https://...&client_). Tapping the badge opened a truncated URL, the OAuth provider rejected it, and the operator couldn't authenticate.The extractor is now a state-machine walker that anchors on
https?://, consumes URL-body characters, and treats a single internal\nas a soft-wrap when the current line is ≥ 40 chars long (matches real CLI wrap width; well above "\n " prose patterns). Paragraph breaks ( \n\n), single newlines followed by non-URL characters, and short prose lines still terminate the URL correctly.Verified against the actual 450-char claude-code OAuth URL that was failing in production: extractor now produces ONE complete URL (vs. two truncated segments).
[v2.0.3], 2026-05-18#
Fixed#
Terminal URL badge always opens with one tap, regardless of how many URLs the session has accumulated. v2.0.2 made the
N = 1case one-tap, but real sessions usually have ≥ 2 URLs by the time the auth flow runs (the CLI's welcome banner often prints a docs link before the OAuth URL), and that fell back to the two-tap dialog flow. The badge now ALWAYS opens the most recent URL on a single tap, which is the OAuth URL in 100% of theclaude login/gemini auth login/codex logincases.Multi-URL access stays available via a small
⋯button beside the primary anchor: tapping it opens the same list dialog as before, so operators can still grab an older URL when they need it. The dialog row Open buttons are also real anchors (notwindow.open()) for the same popup-blocker reason.This is a web-admin-only fix. The Flutter mobile app's terminal surface doesn't have URL detection yet. Separate follow-up.
[v2.0.2], 2026-05-18#
Added#
- Service-control subcommands:
opendray start,opendray stop,opendray restart,opendray status. Thin wrappers oversystemctl(Linux) andlaunchctl(macOS) so operators don't have to remember the platform-native incantation. On Linux, the binary auto-prependssudoif the caller isn't root. On macOS, defaults to the user LaunchAgent (gui/$UID/com.opendray.opendray); pass--systemto target the LaunchDaemon scope.
Fixed#
- One-tap link open for the OAuth URL badge. When a session has
exactly one detected URL (the common AI-CLI auth case:
claude login/gemini auth login/codex logineach print one OAuth URL), the floating "🔗 1 link" badge is now itself an<a target="_blank">: a single tap goes straight to the browser, no intermediate dialog. The dialog still appears when ≥ 2 URLs are detected, so multi-link sessions still get the disambiguating UI. In the dialog, the "Open" button is also a real anchor now, which avoids popup-blocker gating on some mobile browsers.
[v2.0.1], 2026-05-18#
Removed#
- Docker deployment path. opendray is a host-resident gateway:
it spawns AI CLIs via PTYs and shares process state (
~/.claude, ssh-agent, project files) with them, which is incompatible with the container isolation a production Docker deploy would impose. RemovedDockerfile,docker-compose.yml,docker-compose.test.yml,.dockerignore,.env.example, the GHCR push job from the release workflow, and the Docker-Compose sections from README / docs. - In-app Tutorial page. All 84 markdown sections plus
Tutorial.tsxremoved; docs now live in a dedicated repo that will publish independently. Sidebar entry,/tutorialroute, and i18n keys (nav.tutorial,web.providers.claudeAccounts.tutorialTooltip,web.providers.claudeAccounts.architectureLink) removed in parallel.
Fixed#
- "No Claude accounts" empty state (Providers page + Spawn dialog,
web + mobile) now tells operators the actual setup path: spawn a
session and run
claude loginin the terminal. The previous wording pointed at the gateway-host shell workflow (works only for SSH- capable operators) and incorrectly implied a systemANTHROPIC_API_KEYfallback. The shell workflow remains available in the Providers page text for power-users juggling multiple identities; it's just no longer the headline instruction.
Changed#
- Brand: web favicon, docs hero, iOS
AppIcon.appiconset(15 sizes), Android mipmap (5 densities), andapp/mobile/assets/brand/launcher source refreshed from a new canonical set inassets/icons/logo/. Now tracked in-repo so a future refresh is onecp+ the existingsipsresize loop.
Added: install / uninstall / update tooling#
Lifecycle scripts and binary subcommands that grew out of a fresh-
LXC end-to-end install test. Everything below is curl | bash–
reachable, idempotent, and works on Linux (Ubuntu / Debian) +
macOS; Windows is funneled through WSL2.
- One-line installer wizard (#185 #186)
scripts/install.sh, dual-mode entry: dispatches to the OS installer in a local checkout, or shallow-clones the repo and re-execs when piped fromcurl.scripts/install-linux.sh, apt + systemd; walks the operator through Postgres (existing or freshpostgresql-16+pgvectorinstall), AI-CLI choice, admin credentials, listen address, release-tarball binary install, schema migration, and a hardened systemd unit. Optional--from-sourcebuilds the binary + web bundle from a checkout instead.scripts/install-macos.sh, brew + LaunchAgent (or--launchd-daemonfor system-wide), same flow. Detects Apple Silicon vs Intel for the right release asset.scripts/install-windows.ps1, PowerShell helper for WSL2: detects existing WSL, otherwise prints the install command + reboot guidance, then hands off to the Linux installer inside Ubuntu.
- One-line uninstaller (#191)
- Default mode stops + removes the gateway runtime but keeps
config.toml, data directory (bcrypt keyfile, sessions, notes, vault), logs, and the PostgreSQL database, so a re-install picks up where you left off. --purge(orOPENDRAY_PURGE=1) drops the DB + role, deletes config / data / logs, removes the service user.- Post-purge verification step: walks the standard install
paths and bails loudly with
ls -laoutput if anything survived. "No trace left" gets checked, not assumed.
- Default mode stops + removes the gateway runtime but keeps
opendray updatesubcommand (#194)- Fetches the latest GitHub release, picks the goreleaser
asset matching this host's
GOOS/GOARCH, verifies SHA-256 against the release'sSHA256SUMS, then atomically replaces/proc/self/exevia temp+rename. - Flags:
--check(probe only),--force(re-install same version),--yes(skip confirm),--restart(systemctl restart opendrayafter replace, Linux only). - Fails fast with a "try with sudo" hint when it can't write the install directory, no silent no-op.
- Fetches the latest GitHub release, picks the goreleaser
asset matching this host's
opendray providers <list|update>(#194)- Detects installed AI CLIs (
claude,gemini,codex), prints versions + paths. updatere-runsnpm install -gper CLI;--checkshells out tonpm view <pkg> versionto compare current vs npm-latest.--only claude,geminirestricts to a subset;--jsononlistfor scripted consumers.
- Detects installed AI CLIs (
Security#
- Secrets out of
config.toml(#192). The wizard now writes the database URL + admin bootstrap password to a separate file:- Linux:
/etc/opendray/opendray.env(mode0640 root:opendray), consumed by systemd viaEnvironmentFile=. - macOS:
~/.opendray/opendray.env(mode0600), consumed by a tiny launcher wrapper (~/.opendray/bin/opendray-launcher.sh) that the LaunchAgent'sProgramArgumentsinvokes: launchd has noEnvironmentFileequivalent. config.tomlis now0644and contains only non-secrets (listen, log config,[admin].user, runtime data dir).- Existing opendray env-var override layer
(
OPENDRAY_DATABASE_URL,OPENDRAY_ADMIN_PASSWORD, etc.) does the actual wiring, no Go changes needed.
- Linux:
Fixed (install wizard, all reported during the LXC walkthrough)#
curl | bashprompts work, wizard re-attaches stdin to/dev/ttyso EOF on the pipe doesn't make everyreadfail underset -e(#187).run_priv -E …/run_priv -u …no longer trip "command not found" when running as root, newrun_priv_env/run_priv_ashelpers handle both root + non-root paths (#188).- pnpm moved to the
--from-sourcebranch only; default-path Node install no longer hangs on corepack's silent download (#189). - AI CLI install shows npm's progress bar instead of `--silent
/dev/null` (so a 90-second download doesn't look like a hang) (#189).
- Admin login works after install: wizard writes
[admin].userin addition to the password; matches opendray's auth contract (#190). - Customisable admin username (was hard-coded to "admin") (#190).
- Final-summary URL resolves the host's LAN IP for
0.0.0.0listens instead of printing the<this-host>placeholder (#190). - Colour codes render in the summary block, colour vars use ANSI-C quoting so heredoc interpolation carries real ESC bytes (#190).
uninstall --purgedeletions are unconditional now; survived the previous flag-gated logic that occasionally leftconfig.tomlon disk (#192).- Env-var alternative for the purge flag (
OPENDRAY_PURGE=1 bash) that survivesbash -s -- --flagpaste-newline weirdness (#193).
Documentation#
- README hero: typographic v2 logo + status / license / CI /
GHCR badges + "What is opendray?" five-bullet section + paired
EN / ZH
README.md/README.zh.md(#180 #181 #182). - One-liner install / uninstall snippets at the top of
## Installon both READMEs (#186 #192 #193). docs/getting-started.md(+.zh.md), 15-minute end-to-end walkthrough that mirrors what the wizard does (#183).docs/operator-guide.mdstrengthened on Docker-deploy scope, decision-question framing makes the "no session spawn" limit unmissable (#184).scripts/README.mddocuments the wizard, file layout (now including the secrets / config split), troubleshooting table, and the env-var alternatives for the purge / yes flags.
Branding#
- Unified launcher icons across web favicon, iOS
AppIcon.appiconset(15 sizes), and Android mipmap densities (5) using the cropped typographic v2 logo (#182).
[v2.0.0], 2026-05-17#
Versioning realignment#
- Re-tagged from the previous
v1.0.0tag (issue #165). The major version now reflects this codebase's identity as the second generation of the opendray product (opendray_v2). The previousv1.0.0tag was deleted (had three duplicate draft releases on GitHub, all deleted; no published release; no downstream installers depend on it). - New VERSIONING.md documents the major-as-generation policy and what triggers future bumps.
Added#
- Per-session bypass toggle in the Spawn dialog (mobile + web).
Provider-aware: Claude →
--dangerously-skip-permissions, Codex →--ask-for-approval never, Gemini →--yolo. Off by default; the previous all-or-nothing provider config setting still works for "always bypass" deployments.
Changed#
- Spawn dialog's Claude account picker now appears immediately on open (mobile + web). Previously it waited for the operator to re-tap the provider dropdown because the parent state's provider id stayed unset.
- When 2+ Claude accounts are registered, the
Default (env / system)option disappears from the Claude account picker; the first enabled account auto-selects. Single-account setups retain the Default option.
Fixed#
- Release workflow's
ghcrjob now produces image tags onworkflow_dispatch.docker/metadata-actionwas readinggithub.ref(a branch when dispatched manually), sotype=semverrules emitted zero tags and buildx failed with "tag is needed when pushing to registry". Each rule now passesvalue=${{ env.TAG }}so the same ruleset works for bothpush:tagsandworkflow_dispatchentry points.
Added#
Release workflow gains a
ghcrjob that builds the multi-arch Dockerfile (linux/amd64 + linux/arm64) and pushes toghcr.io/opendray/opendrayon every tag release. Job-scopedpackages: write(the parentreleasejob stays at contents+id-token least-privilege). Tag set covers:1.0.0,:1.0,:v1.0.0, plus:latestfor non-prerelease semver. SHA-pinned actions throughout, matching the existing release- pipeline pattern..github/workflows/release.yml, automated release pipeline. Triggers onv*tag push (or manually via workflow_dispatch with a tag input). Produces a goreleaser draft release with:- cross-compiled archives (linux/darwin × amd64/arm64) +
SHA256SUMS - cosign keyless OIDC signatures (
SHA256SUMS.sig,SHA256SUMS.pem) via Sigstore Fulcio, no long-lived key - SPDX SBOM via anchore/sbom-action
Permissions limited to
contents: write(release upload) andid-token: write(cosign OIDC). Supply-chain hardening: SHA-pinned cosign-installer, sbom-action, and goreleaser-action; fail-fast tag-format validation on workflow_dispatch.
- cross-compiled archives (linux/darwin × amd64/arm64) +
deploy/directory with reference deploy artefacts:deploy/systemd/opendray.service, production-ready systemd unit with sandboxing (NoNewPrivileges,ProtectSystem=strict, etc.),migrate-then-servestartup, 20s graceful-stop window.deploy/lxc/proxmox-pty-notes.md, Proxmox-specific guide covering privileged vs unprivileged container PTY behaviour, the cgroup + bind-mount config required for unprivileged LXCs, networking + pgvector + pg_dump-version checks, and a pre-go-live checklist.deploy/README.md, index pointing operators at the right artefact for their topology.- operator-guide.md "Where to look next" section now links to
deploy/.
ADR 0016 (Proposed): backup-format v2 design for per-install PBKDF2 salt. Captures the four binding decisions (in-header storage, version-byte bump 1→2, per-Seal salt provenance, indefinite v1 read compat) and the three-PR rollout. Implementation pending.
LICENSE file (Apache 2.0), previously declared in README only.
SECURITY.md, threat model, default posture, deployment checklist, report channel.
CONTRIBUTING.md, dev setup, test commands, PR + commit conventions.
CHANGELOG.md, this file.
Changed#
internal/backup/cipher.go: 6-line comment onkdfSaltflagging it as a frozen v1 protocol constant and pointing at ADR 0016. No code behaviour change.- Renumbered ADR
0011-memory-subsystem.md→0014-memory-subsystem.mdto resolve the duplicate-0011 collision with0011-channel-rich-content-and-bridge.md. Updated cross-references in README, ADR 0013, and the embed-onnx stub.
[v1.0.0, retracted], 2026-05-09#
Note. This tag was retracted on 2026-05-17 and the work it covered is folded into v2.0.0 above. See issue #165 and VERSIONING.md for the rationale. Original section preserved verbatim below for historical context.
First stable release. Tagged at commit fe96fd8 on main. Web frontend
- backend feature-complete; mobile + Slack inbound + automated release
workflow deferred to v1.x per the post-v1.0 roadmap. v1
(
Opendray/opendray) keeps running in production through this quarter per ADR 0001.
The feature inventory below was originally captured under
[v1.0-rc] — 2026-05-05; section was promoted to [v1.0.0] on tag.
Added (since the greenfield start)#
- M0, composition root:
internal/app/, config loader (internal/config/), pgx pool + hand-rolled migration runner (internal/store/), event bus (internal/eventbus/), structured logging via slog. - M1, sessions: PTY lifecycle, ring-buffer streaming, WS handler, resume-via-reconnect (per ADR 0003).
- M2, CLI catalog: provider manifests + per-id user config
(
internal/catalog/). - M2.5, admin auth: bearer tokens with constant-time password compare
and 24h TTL (
internal/auth/). - M3, integrations: external-app registry,
/api/v1/proxy/{prefix}/*reverse proxy, integration call log (internal/integration/, ADR 0006, ADR 0010). - M4, channels: channel hub + Telegram, Slack, Discord, DingTalk,
Feishu, WeChat, WeCom (
internal/channel/, ADR 0005, ADR 0011-channel). - Memory: built-in pgvector cross-CLI memory layer
(
internal/memory/, ADR 0014). Three-CLI mirror keeps Claude / Codex / Gemini transcripts aligned. ONNX local-embedding optional via-tags local_onnx. - Ambient memory: auto-capture from active sessions + auto-injection on session start (ADR 0013).
- Backup + export: AES-256-GCM encrypted PostgreSQL dumps,
S3/WebDAV/SFTP/rclone targets, admin export/import bundles
(
internal/backup/, ADR 0012). - Web admin (W0–W5): React 19 + Vite + Tailwind v4 + shadcn/ui +
TanStack Router/Query + Zustand + xterm.js. Single SPA bundled into
the Go binary via
go:embed(ADR 0007, ADR 0008). - Events stream: admin-bearer-authed
/api/v1/integrations/_eventsWebSocket (ADR 0009).
Deferred to post-v1.0#
- Mobile (Flutter) client, replaced by responsive web in v2 phase 2.
- Slack inbound (M5+).
- Deploy automation (release toolchain, goreleaser, Dockerfile, systemd unit) lands in a follow-up PR.
- e2e Playwright harness.