| 2026-10-12 |

webclient: a deliberate pull upwards outranks the autoscroll
...
While the agent streamed, the pin to the bottom was re-applied between deltas, so
a small wheel-up was yanked back before the "user scrolled up" flag had a chance
to be set — the view only came loose after a large fling. The flag itself was
written by the scroll handler from distance-from-bottom alone, which needs a
100px move to trip, well past the point the user has already been overruled.
Direction is now read from the gesture. A wheel tick or a finger drag that goes
up sets the flag at once and drops any landing loop still re-clamping, and the
pin stands down for 250ms after real input, in flight outranking it. A wheel on a
list that fits entirely is not treated as a request to leave the bottom — the
view never moves there and the flag would stick with the stream no longer
following. The scroll handler keeps the dragged-scrollbar fallback: entering
"scrolled up" still takes a clear move, while leaving it takes almost nothing, so
the pin re-arms as soon as the user is back at the bottom.
dist rebuilt and committed with the source.
Eugene Sukhodolskiy
committed
6 hours ago
|

memory: a fact written under a new spelling of its key merges into the one that survives
...
The extractor invents a key every time it meets the same fact — `language`,
`preferred_language`, `ui_language` — and the upsert keys on the exact string, so
the base grew several spellings of each durable fact. On prod, 24 facts across 11
clusters share a key once noise prefixes are dropped, 10 of them under the exact
same value.
upsert_fact(dedupe=True) now compares the *tokens* of the normalized key — set
equality, not overlap, and never the value: `ui_language` and `language` are one
key while `dark` and `dark_ui` are two. The row already holding the writer's exact
key wins; otherwise the incoming one does. Losers are marked, not deleted: each
keeps its key and its value and gains `merged_into`, so the merge is reversible
with one UPDATE. Off by default, so the memory tool still writes exactly the row
it asked for.
Every read path filters `merged_into IS NULL`, and the mark is reachable only
where it is meant to be — get_all_facts/fact_count gained include_merged, and
GET /admin/memory?include_merged=true exposes it. search_facts deliberately has
no such flag: the injection path must not be able to see a hidden row. Forgetting
a key drops its hidden aliases first, so none is left pointing at nothing.
Three fixes that fell out of the same read:
- expires_at was overwritten with EXCLUDED.expires_at on every upsert, and the
extractor always passes None — so it silently cleared any expiry the agent had
set. Both DO UPDATE clauses now COALESCE.
- The ILIKE fallback was the one read path that served expired facts.
- The `list` action promised "all stored facts" and returned categories. The code
was right and the description was wrong; the description now says so.
The extraction prompt stopped allowing infrastructure — servers, hosts, addresses,
running services — while the agent and the planner already forbade it; that
loophole is where 423 technical facts came from. It also forbids secrets outright,
and asks for stable snake_case keys. Expiry is now documented as being for facts
with a known end, not for facts you merely doubt.
Facts reach the prompt with their age ("updated 34d ago") alongside confidence:
nothing expires on a clock, so the injection says how old a fact is and lets the
agent weigh it.
Eugene Sukhodolskiy
committed
6 hours ago
|

mcp: every wait on the MCP path gets a bound, and a failed reload keeps what it had
...
reload_tools kept dying on prod, and only a restart cured it. Nothing on the
MCP path was bounded: ClientSession was built without read_timeout_seconds, so
the SDK waited forever (anyio.fail_after(None)); _send awaited a future with no
timeout; disconnect() awaited the shielded runner with no timeout; and
disconnect_all() walks the pool one client at a time, so a single wedged client
blocked every reload that came after it. A cancelled caller only cancelled
itself — the runner stayed wedged, which is why it was every reload, not one.
Bounds, in two deliberate layers. The SDK read timeout ends a request nobody
answers *inside* the runner, so the client stays usable; the caller-side bound
is the backstop for an op queued behind a stuck one, and it aborts the runner
rather than leaving it for the next command to queue behind. connect 60s,
list_tools 45s, call_tool 300s (SDK 240s), session read 30s, disconnect 15s,
each overridable, each logging the culprit.
disconnect() and disconnect_all() no longer swallow CancelledError — that is
exactly how the reload budget stopped being enforceable.
The registry is now rebuilt in one step: collect every server's tools first,
then commit the whole set in a single synchronous swap. clear_external_tools()
ran *before* the rebuild, so a rebuild that failed halfway left the model
holding MCP tool names that resolved to nothing. A failed pass now leaves the
previous registrations untouched, and the reload phase carries its own budget
so the tool always returns a report.
Two more defects fell out of the audit. The streamable_http transport was
handed a bare httpx.AsyncClient, whose 5s default read killed the GET stream of
an idle server — six of the nine prod servers are streamable_http. And
register_mcp_tools called log.warning() in a module where log was never
defined, a NameError on precisely the failure path it was meant to report.
Eugene Sukhodolskiy
committed
6 hours ago
|
mcp keys: KeyResolver gains list_servers
...
visible_servers() calls the resolver, not the store, so the BYOK
visibility check crashed with AttributeError for every non-admin
run (user sessions, recalls, synapse reactions) and silently
dropped all gated servers from the tool list. The resolver now
delegates to the store through the same async factory resolve()
uses; any failure resolves to an empty dict — no personal keys —
instead of an exception.
root
committed
7 hours ago
|

sessions: a pasted image lands in the session directory, and the notice says where
...
An image pasted into the chat lived only in the transcript. The system prompt of
every session names `session_files/<id>/` as the place its files live, so an agent
looking for "the attached images" went there, found nothing, and spent turns on
fruitless `find` calls before giving up.
Now the agent's non-hidden turn writes each pasted image into that directory as
`image_N.<ext>` — the extension sniffed from the bytes, the bytes stored as they
arrived, so a PNG keeps its alpha channel. Numbering runs across the session, and
a picture sent twice reuses the file already holding it.
The paths go into the hidden context copy of the message: images past the context
budget are not sent to the model at all, so the notice names them for `image_view`
instead. The line is written by the agent rather than the websocket handler, which
is the layer that knows the names the files actually took — including for a message
queued behind a running turn.
A save that fails is logged and skipped: it never costs the turn.
Eugene Sukhodolskiy
committed
9 hours ago
|
| 2026-10-10 |

synapse: route an event by the role of the account it was delivered for
...
The dispatcher's choice of profile was checked against a hardcoded "user" role,
so an admin's own events could never reach the admin-only profiles their routing
instruction named. The event fell to whichever profile all() happened to yield
first — a profile without the tools the event needed — and the instruction looked
like it did not work. The role now comes from navi_users for the account the event
was delivered for (_user_role, failing closed to "user" and logging), and it is the
role the run carries as current_user_role.
Alongside, in the same path:
- the dispatcher is offered each profile as "id (name) — description", so a rule
that names a profile in words has something to match it against;
- an unroutable event lands on a role-aware fallback (secretary for an admin,
assistant otherwise) instead of the first of all(), and every substitution is
logged as synapse.reaction_profile_downgraded with both ids;
- session_key is folded to one spelling — whitespace collapsed, lowercased — so a
chat cannot fork on "TG:42" against "tg:42";
- the conversation rule is read from whichever instruction document states it,
not from the dispatcher one alone.
A refused profile is still refused: hidden stays hidden for everyone, and an
admin-only profile stays out of reach for a regular user's event.
Eugene Sukhodolskiy
committed
2 days ago
|

webclient: the push permission is asked once a session, after signing in
...
The permission was only ever asked from the switch in Settings, and a call
without a user gesture is ignored by Firefox and Safari and answered with a
quiet icon by Chrome — so the app now offers a bar whose button click is that
gesture. It is asked once per browser session (there is no marker that tells a
fresh sign-in from a reload, so the session is the honest bound), and a
refusal ends it for good: only an explicit block counts, and after that the
switch in Settings is the single way back to it.
The policy lives in useNotificationPrompt.js as a layer over usePush, which
is untouched: the granted-but-unsubscribed trap is what makes the eligibility
gate "permission is still default", otherwise the bar would silently
re-subscribe whoever turned notifications off by hand. The banner itself is
presentation only, and is the component proposed to gnexus-ui-kit
(docs/tasks/gn-permission-banner.md there) — the kit takes the gesture, the
view and the contract, never the storage keys or the timing.
Eugene Sukhodolskiy
committed
2 days ago
|

synapse: a reaction continues its conversation, and the request becomes a record
...
Every Synapse event used to open its own service session, so two messages of
one Telegram chat landed in two unrelated chats. The dispatcher now returns a
`session_key` — derived from the payload according to the user's routing
document — and a later event carrying the same key joins the session that
opened it, within `reaction_session_ttl_minutes` of idle time (0 restores the
old always-fresh behaviour). A session that is still running is never joined:
`create_run` overwrites `state.run`, so a second run would orphan the first
one's subscribers — a busy thread gets a fresh session instead. When the
dispatcher names a different profile for the event, the session's profile is
switched through `set_profile`, never through `save()`, which does not rewrite
`name` or `profile_id`: the thread keeps its name and its history.
What the user reads in the transcript is now only the request. The event
record is a system message (`metadata.source == "synapse_event"`) drawn by a
new SynapseEventNotice.vue in the visual language of the recall badge, and the
framing plus the JSON envelope become a hidden user turn written once, on the
first event of a thread — not on every one. The instructions from Settings
reach the model through a new `current_reaction_instructions` ContextVar and
the `[Reaction session]` block of the system prompt, so they appear in no
transcript at all. The stored system record still has to reach the model, so
ContextBuilder's allowlist becomes `_LLM_SYSTEM_SOURCES = {task_note,
synapse_event}`.
The dispatcher gets a document of its own — how to route, rather than what to
do — stored beside the reaction instructions, versioned separately, and edited
in its own field in the settings panel. `synapse_instruction_versions` gains a
`doc` discriminator, so existing rows keep `'reaction'` and their history
verbatim. The new columns are added by `_MIGRATE`, and the index over `doc`
lives there too: `_DDL` runs first, so an index over a column the migration has
not added yet kills the whole batch on an existing database — which is what
the prod database is. A guard test keeps it that way.
Tests: 1776 passed, 1 skipped (backend), 242 passed (webclient, 28 files).
Eugene Sukhodolskiy
committed
2 days ago
|

webclient: the small UI components in en, uk and ru
...
The sixth and last area of the interface translation. The components in
components/ui/ now build their text through t(): the login screen, the welcome
screen, the confirmation dialog, the offline banner, the image lightbox, the
selection toolbar, the card grid and the form, plus the sidebar's close button,
which is one of the strings that moved.
24 new slugs in all three dictionaries, and one moved:
- 24 in ui.* — the login and welcome screens (the heading, the tagline they
both show, the GNEXUS button), the confirmation dialog, the offline banner,
the lightbox (its heading, its close button, the failed-to-load message), the
selection toolbar's Reply and its tooltip, the card grid's three section
headings and its "+{n} more", and the form's submit button, its submitted
note and its ten validation messages;
- common.close, moved out of sidebar.*: the sidebar's close button and the
lightbox's say the same word, so it sits in the vocabulary area now.
Four details worth knowing:
- OfflineBanner's sentence was hard-coded Russian inside an otherwise English
file — for a reader in English or Ukrainian it was the one Russian sentence
in the app. It is a slug now, and the Russian value is that same sentence
verbatim, so nothing changes for the reader who could already read it.
- The form's validation messages interpolate: "{field} is required" carries the
field's own label, and the numeric limits carry {n}. The two length messages
are dictionary plurals now, so English says "Minimum length is 1 character"
where it used to say "1 characters", and Russian and Ukrainian get the three
forms a numeral needs — "1 символ", "2 символа", "5 символов".
- The card grid's "+4 more" is one slug with a count, not a sentence glued
together in the template.
- ui.tagline is written once and read by both the login and the welcome screen,
which show the same words.
The English values are verbatim the literals the code hard-coded, so the
English interface is unchanged and no test needed editing.
Checked live in Chromium against the built bundle on the dev backend, all three
languages: the login screen (reached with no account at all, its language
coming from navi.locale), the offline banner with the context put offline under
it, the selection toolbar over a real selection in a message, and the
confirmation dialog opened from a session row's Delete. Two further passes open
a session whose tool output carries a card grid and one that carries images, for
the card grid's section headings and the lightbox's heading and close button.
The form is the one component no saved session renders — nothing in the dev
database carries a form payload — so its messages were printed through t()
instead, including the plural ones: "Поле «Email» обязательно", "Минимальная
длина — 2 символа", "Мінімальна довжина — 5 символів", "Minimum length is 1
character".
Eugene Sukhodolskiy
committed
2 days ago
|

webclient: the chat area in en, uk and ru
...
The fifth area of the interface translation. The chat screen now builds its
text through t(): the header (the rename tooltip, the sidebar toggle, the
artifacts toggle, and the recall banner with its Cancel and Skip next
buttons), the list around it (the connection-lost banner and its Retry, and —
unreachable for now — the empty state), the input bar (the sending notice, the
paperclip, the placeholder, Send and Stop generation), the attachment strip
(its alt text and its remove button), the message list (Loading older
messages… and the scroll-to-bottom tooltip) and the queued-messages chip. Also
four toasts the WebSocket composable raises — background task started /
finished, MCP server connected / disconnected — and the compression notice's
own pending text.
26 new slugs in all three dictionaries:
- 20 in chat.* — the header and recall banner, the input bar, the attachment
strip, the queued chip and the empty state;
- 4 in toast.* — the two background-task and the two MCP toasts;
- 2 in common.* — task and toolsCount.
Four details worth knowing:
- The recall banner used to read the server's call_type enum out loud
("Scheduled once recall at …"). The word is now a slug
(chat.recallOnce / chat.recallRecurring / chat.recallImmediate), so it agrees
with the sentence around it — Russian and Ukrainian both change the
adjective's gender and the preposition, which a bare enum cannot.
- The banner's time was formatted with the browser's locale; it now formats
against locale.value, so the date follows the interface language rather than
the machine.
- The queued chip counted with `count === 1 ? '' : 's'`. It is now a dictionary
plural driven by {n}, which is what Russian and Ukrainian need — "1
сообщение", "2 сообщения", "5 сообщений".
- toolsCount moved from messages.* to common.*: the tool-count badge in the
assistant message and the MCP-connected toast both render it, so the same
word lives in the vocabulary area instead of twice under two areas.
docs/i18n.md records the move.
The English values are verbatim the literals the code hard-coded, so the
English interface is unchanged and no test needed editing.
Checked live in Chromium against the built bundle on the dev backend, all
three languages: the header title, the input placeholder, both input-row
buttons, the recall banner and its buttons, twelve message rows and the
attachment's alt and remove — read fresh for each language by picking it in
Settings first — with a scan of the page's text, titles, aria-labels, alt texts
and placeholders for a slug that leaked into the interface as the pass
condition.
Two states a saved session cannot be put into are covered by a throwaway mount
instead, and the file was removed again: the streaming input bar (Sending…,
Stop generation) and the queued chip, plus the recall words and the toast
titles, printed in Russian — "1 сообщение в очереди" / "2 сообщения" /
"5 сообщений".
The connection-lost banner never appears in this harness: with no socket token
the server refuses the socket and the client never exhausts its retries inside
the test's window, on the current build and on HEAD's alike. It is covered by
the existing tests/unit/composables/useWebSocket.test.js and by reading
ChatArea.vue; the same goes for chat.nothingOpen / chat.startConversation,
which App.vue's WelcomeScreen shadows before ChatArea can render its own empty
state — the branch is dead today and keeps its slugs for when it is not.
Eugene Sukhodolskiy
committed
2 days ago
|
| 2026-10-09 |

webclient: the message area in en, uk and ru
...
The fourth area of the interface translation. Everything a conversation is made
of now builds its text through t(): the thinking card and the status line it
replaces while the model reasons, the plan card, the tool cards (both the plain
one and its sub-agent steps), the published-content card, the UI-component card
and the compression notice, plus the two new chat bubbles' copy button and the
"Scheduled recall" mark on an injected user message.
25 new slugs in all three dictionaries:
- 23 in messages.* — the cards' own headers and labels (Thinking, Plan,
Arguments, Show all / Hide older tools, Useful / Not useful, Open file,
Content, Unknown UI component: {name}, Context summary, Compressing context…,
Context compressed), the running and elapsed labels (Running {tool}…,
Preparing next action…, running, starting) and the two counted ones that a
compact JSON preview renders, "{n} items" and "{n} keys";
- 2 in common.* — Copy and Copied!, which the assistant and the user message
both need.
Three details worth knowing:
- The English values are verbatim the literals the code hard-coded, with one
exception: "{n} tools" and "({before} → {after} messages)" are now dictionary
plurals, so English says "1 tool" and "1 message" where it used to say
"1 tools" and "1 messages". No test asserted either string.
- The braces around the two compact-JSON counts stay in the template
(`[${t('messages.jsonItems', { n })}]`), so a translator never has to keep a
bracket it cannot see the other half of.
- The message list's ContentCard shows the same published-file widget as the
artifacts drawer, so it reads the six artifacts.* slugs of that widget rather
than carrying six duplicates under messages.*; and its two tool-output labels
— Result and Live output — are the words the tool card already uses, so they
sit in common.* and the drawer's copies moved there too. docs/i18n.md records
both.
Checked live in Chromium against the built bundle on the dev backend: five
saved sessions chosen so that between them they render every component of the
area (thinking cards, plan cards, 55 tool cards, published-content cards,
UI-component cards and a compression notice), six loads per language, with
every collapsed block expanded first and a scan for a slug leaking into the
rendered text as the pass condition. The sub-agent step cannot be reached from
a saved session — the runtime does not persist tool steps — so SubagentStep was
rendered on its own in a throwaway mount instead: "выполняется", "Аргументы",
"Результат", "[3 элемента]".
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: the sidebar in en, uk and ru
...
The third area of the interface translation. AppSidebar, SessionList and
SessionItem now build their text through t(): New Chat, the profile strip and
its filter buttons, the list label (Conversations / Service sessions, and
"30 results" while searching), the empty and loading states, the per-session
icons' tooltips (pinned, scheduled recall, service session), the row actions
(pin / unpin / cancel recall / skip next / delete and its confirm dialog), and
the footer identity (Local mode / Log out / Log in / Settings / Admin).
Also the shared time labels in composables/useTime.js — "just now" and
"5 min ago" — which the messages area renders too; they go to common.*
because both screens say the same words. The label is a cached string, not a
template that re-renders, so the composable now watches locale and rewrites
itself when the language changes. The absolute fallbacks format against
locale.value instead of the browser locale.
31 new slugs: 28 in sidebar.*, plus common.delete, common.minutesAgo (plural)
and confirm.deleteSession. English values are verbatim the old literals, so
the English interface is unchanged and no test needed editing.
Checked live in Chromium against the built bundle on the dev backend: the
sidebar was read in all three languages both in the plain list and with the
search field open, including every title / aria-label / placeholder / alt, with
a slug-leak scan as the pass condition.
One English string stays put on purpose: GnSearchField's aria-label
="Clear search" is baked into the UI kit component, not passed in, so it is
English on every gnexus client; docs/i18n.md now says so.
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: the artifacts drawer in en, uk and ru
...
The second area of the interface translation. Every user-visible string in
ArtifactsPanel.vue now goes through t(): the four tab chips, the empty states,
the action labels (open preview / open raw file / download / copy link), the
pull-to-refresh captions, the background-task badges and ages, the terminal
detail rows and the live-output header.
60 new slugs, in all three dictionaries:
- 39 in artifacts.*;
- 11 in the shared col.* vocabulary (Tool, Status, Started, Finished, PID,
Command, CWD, Uptime, Background, Reason, Sub-agent tokens) — the drawer is
where these table headers first appear;
- 10 in common.* — Download, Yes, No, N/A, the tool-output labels Result and
Live output, and the four the sidebar needs as well: the three
pull-to-refresh captions and "just now". These are the same words on both
screens, so they sit in the shared vocabulary area and the drawer keeps only
what is about the drawer.
Four details worth knowing:
- The English values are verbatim the literals the code hard-coded, so the
English interface is byte-identical and the existing tests stay untouched.
- Counted strings use dictionary plurals: '{n} links in session' is
one/few/many in ru and uk, one/other in en.
- Times (formatTime / formatDate) and ages now format against locale.value
instead of the browser locale, so a Russian interface shows 16:56 where
English shows 04:56 PM.
- Unit suffixes in formatUptime (s/m/h) are left alone: they are symbols
inside a number, not words.
Checked live in Chromium against the built bundle on the dev backend: the
drawer was driven through all four tabs in all three languages on two real
sessions — one with published STL files and a viewed-images group, one with
eight extracted links — with a slug-leak scan as the pass condition, and the
time/age formatting difference confirmed in the rendering.
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: the settings screen in en, uk and ru
...
Settings is the first area off hard-coded English: 122 strings for the shell and
its five tabs — App, Account, Notifications, Synapse and MCP — plus the 43 the
shared vocabulary contributes (common.*, col.*, confirm.*, toast.*).
The English dictionary carries the very literals the templates used, so the
interface a test reads is byte-for-byte what it was and the 56 assertions on
English UI text stay green; only a slug that is genuinely counted had to be
restructured into a plural. In ru and uk the counted strings get the three
Slavic forms driven by n. Product names are never translated: Navi, Synapse,
MCP, Ollama, Tool call, Subagent and Token read the same in all three
languages, and the settings screen keeps one settings.* area with
panel-prefixed slugs rather than a slug per panel, as the other gnexus clients
do.
Strings carrying markup are split around it instead of hiding tags in the
dictionary: the install steps keep their inline icons and <code>http://</code>
in the template, with the words of each sentence on either side. Four lists of
labels — the settings tabs, two table headers, and the reaction options — are
now computed rather than module-level constants, since a list built at import
keeps the language that was active then and ignores a switch. Dates in the
panels go through the interface language as well, not the browser's.
Checked in Chromium against the built bundle: all three languages on all five
tabs, with a scan for a slug leaking into the rendered text as the pass
condition, and the account left with no override afterwards.
Eugene Sukhodolskiy
committed
3 days ago
|

i18n: en/uk/ru, and the interface language of the account
...
The client hard-coded English in every template. It now goes through t() from
src/i18n/ — a locale ref, {param} interpolation, plural dictionaries driven by
n, and flat per-area dictionaries in src/i18n/messages/ carrying the same slugs
in all three languages. vue-i18n is deliberately not pulled in: the platform
handbook rules it out, and what it would buy — plural forms, a reactive locale,
an English fallback — is about eighty lines here. A slug nobody carries falls
back to English, so a screen already translated and one still speaking
hard-coded English can coexist while the work goes area by area.
The language comes from locale_effective in GET /auth/me: the choice made in
Settings (navi_users.locale_override, a new column) over the gnexus-auth
account locale (navi_users.locale, a straight mirror the user.profile_updated
webhook keeps writing — the override sits in its own column precisely so that
mirror cannot wipe it), over en. PATCH /auth/me writes the override, and null
means "follow the account" again. With NAVI_AUTH_ENABLED=false there is no
account to follow, so the choice is kept in localStorage under navi.locale.
The picker is the first block of Settings → Account. Each language is named in
its own language, which is why those three strings are identical in all three
dictionaries. Lists of labels are computed rather than module-level constants:
one built at import would keep the language that was active then and ignore a
switch.
Checked in Chromium against the built bundle: choosing Русский rewrites
<html lang> and the document title, survives a reload, and Auto brings the
account's own language back.
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: an App section, and a link to any settings section
...
Settings gains an App section: how to install Navi in the browser — a real
Install button where the browser offers beforeinstallprompt, per-platform
steps where it cannot, since Safari and Firefox never expose that API and iOS
goes through the share sheet — and the Android APK, with its version, size and
checksum, behind a download the app hands to the system browser. The PWA
listener is armed from main.js because beforeinstallprompt fires once, during
startup, long before the panel is ever mounted.
App is now the first tab and every section is linkable: #settings/app,
#settings/mcp, and so on. Parsing moved into utils/hash.js, which the three
places in App.vue that route on the hash now share — before it, #settings/app
was read as the id of a session and opened an empty chat. Switching tabs
rewrites the address with replaceState, so Back leaves Settings instead of
walking back through the tabs, and a bare or unknown #settings normalises to
the first tab.
Checked in Chromium against both the dev server and the built bundle: each
hash opens its section, no /api/sessions/ request is ever made for one, a tab
click leaves history.length alone, and at 390 and 320px the strip keeps five
tabs with the active one scrolled into view and no sideways overflow.
Eugene Sukhodolskiy
committed
3 days ago
|
android-client: a release-signed APK, published through the server
...
Release builds had no signingConfig, so assembleRelease produced an APK that
Android refuses to install. Sign with a key kept out of the repo: the keystore
lives in ~/.navi-android/, and android-client/keystore.properties (gitignored,
600) points at it and carries the passwords. A clone without that file still
builds — it just gets an unsigned APK, which is the right thing to fail into
for someone who has no business signing a release.
tools/publish-apk.sh builds the release APK into webclient/public/download/
and writes a JSON sidecar (version, size, sha256, build time) for the settings
page to read. The server mounts that directory at /download, so the APK ships
with a plain git pull like everything else. dist/download is ignored: vite
copies public/ into dist/ on every build, and serving the copy out of public/
means one binary in git instead of two.
Eugene Sukhodolskiy
committed
3 days ago
|
webclient: round the PWA icons
...
The icon artwork is a full-bleed dark tile, so every surface that shows it
without a mask of its own (the desktop install, the taskbar) drew a square.
Give the alpha channel a rounded-rectangle mask at 22.37% of the side — the
iOS/Android icon radius — sampled 4x so the corner reads as a curve rather
than a staircase.
The maskable pair is a byte-for-byte copy of the plain pair, so it is rounded
the same way; the transparent corners fall well inside any mask a launcher
applies. The apple-touch-icon is left alone: iOS masks it itself.
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: keep the artifacts tab icons on a phone
...
The artifacts panel has two squeeze ladders that did not know about each
other. At <=980px the mobile block hides the tab label, leaving icon +
count; a container query at <=360px separately hides the icon, because
the docked panel has no room for icon + label + count. The query
measures the panel, and on a phone the panel is the viewport — so on a
360px-wide screen (the most common Android width) both fired and every
tab became a bare counter: "Artifacts 0 Links 0 Files 0
Backgrounds 0". Between 361 and 980px the icons were fine, which is why
it only showed up on phones.
Wrap the icon-hiding step in @media (min-width: 981px), so it describes
the docked panel only, where the label is still attached and dropping
the icon still buys something. The panel is otherwise untouched.
Measured in Chromium against the built stylesheet and the component's
real scoped markup, old versus new side by side: at 320px and 360px the
icons go 0/4 -> 4/4 with no row overflow; 390, 430 and 900px are
unchanged; and every docked width tested (300, 340, 370, 450, 537px)
reports byte-identical values before and after. The pre-existing
overflow of the docked row between roughly 300 and 450px is untouched.
Eugene Sukhodolskiy
committed
3 days ago
|

profiles: give the restricted profiles image_view
...
The 3D designer renders PNG previews with mcp__navi-3d__render_stl and then
had no way to look at them: image_view was withheld from all three
restricted profiles, and designer_3d's system prompt said so outright
("You cannot look at the render yourself"). It shipped geometry it had
never seen, with only lint/compile output behind it.
Add image_view to all three rather than to designer_3d alone. The three
sets are kept identical precisely because switch_profile lets a session
move between them, so a restricted session can reach image_view from any
of the three anyway; putting it in one profile would have made the
restriction look like a boundary it is not.
designer_3d now has to call it on every preview the renderer returned
(one angle is not enough), fix what it sees, and treat the visual check
as an addition to the mechanical ones rather than a replacement. The
prompt also asks for session paths only — image_view itself accepts any
image path or URL, so that part is a rule, not a check. Sub-agents keep
the tool out of their set: they gather facts, they never render.
docs/profiles.md loses the image_view omission row and gains the grant,
its raster-only limit and the accepted residual risk.
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: animate <details> cards in every browser, not just Chromium
...
Open/close of the tool cards jumped in Firefox and Safari: the previous
attempt leaned on interpolate-size/::details-content, and interpolate-size
shipped in Chromium alone, so the @supports block degraded to a hard
attribute flip everywhere else.
Drive the element's own height from JS instead. Nothing there is
engine-specific: open animates to scrollHeight, close to the summary
height, overflow is clipped for the duration and the inline styles come
off on transitionend with a timer as a backstop. The element keeps its
native semantics — keyboard, screen readers, find-in-page — because the
open attribute is still what changes; the toggle event still fires, so
ContentCard's :open/@toggle pairing is untouched. prefers-reduced-motion
settles in a single frame.
A newly inserted card grows its grid row from 0fr to 1fr, which
interpolates everywhere and resolves to whatever the card measures, so
the card below slides instead of being displaced.
Measured in Chromium 153 and Firefox 155 against the real component
markup and the built bundle: 33 -> 586px over 18 distinct heights in
both, and the same for insertion, the nested Arguments block and the
plan card. The scroll behaviour of MessageList was left alone.
Eugene Sukhodolskiy
committed
3 days ago
|

webclient: a stale session list no longer erases a session just created
...
Starting a session from the welcome screen sets selectedProfileId and
then awaits the create POST. The profile watcher fires on the microtask
that the await yields, so the list refetch is issued while the POST is
still in flight — it returns a page that predates the new session, and
fetchSessions replaced the whole array, so the placeholder createSession
had just inserted was gone. The session only came back on a manual
refresh. Reproduced in a store test.
sessions.js now remembers a session it created along with the id of the
last list request that had already started at that moment, and a page
that started after the creation is still believed. So the rescue is
bounded by one round trip — it cannot turn into a ghost row: a request
that left once the session existed reports it missing and it goes, and
deleteSession clears the mark outright. Search and special-list pages
are never padded this way, since they answer a different question.
Eugene Sukhodolskiy
committed
3 days ago
|
webclient: let the profile card description run to its full length
...
The three-line clamp I added with the card-height fix cut the text of
eight of the nine profiles — every description ended in an ellipsis, so
the grid told you less about each profile the more there was to say.
Measured in a browser against the built CSS: 8 of 9 clipped at 1440px,
9 of 9 at 390px. With the clamp gone, none are clipped at 1440, 1100,
900, 768, 600 or 390px.
`grid-auto-rows: 1fr` already keeps the cards in a row level, so the
clamp bought nothing it was not paying for: the row is as tall as the
longest description (230px on desktop, 205px on a phone), and the
welcome pane scrolls when the grid outgrows it.
Eugene Sukhodolskiy
committed
3 days ago
|
webclient: one icon per profile, and one height for every card
...
Six of the ten profiles rendered the same robot, because the map in
WelcomeScreen only knew four ids. The map moves to utils/profileIcons.js,
covers all ten — including the close pairs (`coder`/`developer`, the two
3D profiles) — and falls back to a hashed glyph from a pool rather than
one robot, so custom profiles differ from each other and stay stable
across renders. Covered by five unit tests.
Cards were ragged because each row took the height of its own content:
`grid-auto-rows: 1fr` makes every row as tall as the tallest card. A
fixed pixel height was the obvious alternative and is worse — "Server
Administrator" wraps on a phone. A three-line clamp on the desktop
description keeps one wordy profile from inflating every row.
Eugene Sukhodolskiy
committed
3 days ago
|
webclient: let the welcome pane scroll, and pin the mobile menu toggle
...
The profile grid grows with the profile list, and on a phone it is taller than
the viewport once there are more than a couple of profiles. Nothing could
scroll: `#app` and `.app-main` both hide overflow, and `.welcome-screen` had no
overflow of its own, so the cards were clipped rather than pushed down.
The pane now scrolls, and its vertical centering is `safe center` — with a plain
`center` the overflow goes past the flex start edge and cannot be scrolled back
to. The sidebar toggle becomes `position: fixed` so it does not scroll away with
the content it sits above.
Eugene Sukhodolskiy
committed
3 days ago
|
webclient: rebuild dist for the role-aware MCP keys panel
...
`webclient/dist` is tracked and served straight by the API server — there is no
node on the prod host — so the source change alone would have left prod showing
the old copy, telling an ordinary user that the shared key from the config is in
use for a server that is simply not connected for them.
Eugene Sukhodolskiy
committed
3 days ago
|

profiles: restricted profiles for ordinary users, and a role gate that holds
...
`is_admin_only` was checked in one place out of nine and was not read from
config.json at all, so all seven profiles were reachable by every account with
role `user`. The flag now lives in config.json — the file is the baseline, a
`profile_overrides` row still wins on top of it — and one predicate,
`admin_only_blocked`, is the single place the rule is expressed. The nine
surfaces that list, switch to, spawn or resolve a profile all consult it:
`POST /sessions`, the WebSocket, switch_profile, list_profiles, the system
prompt's "Available profiles" block, spawn_agent and the Synapse reaction
runner. The prompt cache is now keyed by (profile, role), so a user's prompt
can never be served an admin's profile list.
The seven existing profiles (developer, discuss, dispatcher, modeler_3d,
navi_code, secretary, server_admin) are marked admin-only. Three new ones take
their place for ordinary users: assistant, designer_3d and coder. They share one
native tool set — ssh_exec, peer, reload_tools, create_mcp_server, test_mcp_tool,
image_view and gmail are withheld — and differ only in system prompt, model and
MCP groups. navi-web's raw `request` group, and the whole of gnexus-creds and
tgclient, are withheld too.
MCP per-user keys gain the missing half of the rule: a server that declares a
`user_key` slot is refused to anyone but an admin who has no personal key, and
is left out of their tool list entirely, instead of quietly falling back to the
owner's credential and appearing as a tool that cannot work. The refusal names
the server and points at Settings.
Also closes `GET /agents/prompts`, which served every profile's system prompt to
anyone, with no user dependency at all.
The accepted residual risk is written down in docs/profiles.md: the working
directory is a convention, not a sandbox.
Eugene Sukhodolskiy
committed
3 days ago
|
| 2026-10-08 |

tools: a non-admin may reach their own session directory
...
A non-admin was confined to user_data/<user_id>/ alone, which made four
tools reject the directory the user's own uploads land in: share_file
refused the file it was asked to send back, filesystem could not read it,
and terminal/code_exec could not be pointed at it. In a prod session the
agent worked around the refusal by copying the uploaded file into its
sandbox with code_exec — the very bypass the injected security policy
forbids — and the copy collided with a same-named file, so the user got a
link to Project_1.mp3 instead of their own upload, plus an 8.6 MB duplicate.
navi/tools/_internal/areas.py now names the two roots a user owns, and the
four call sites share it. Relative paths still resolve into the sandbox,
and each tool keeps its own way of refusing: terminal returns
sandbox_violation, code_exec silently falls back to the sandbox root. The
share_file refusal lists both roots instead of "outside user sandbox", so
the agent retries in the right one instead of routing around it.
The session directory belongs to the same user: its id arrives from the
runtime context, never from tool arguments, and only the session's owner
can open it.
Eugene Sukhodolskiy
committed
4 days ago
|
persona: the name rule, and a template that actually loads the persona
...
persona.txt gains a third strict rule: the first name is Navi — Latin even
inside Russian text, «Нави» known but not spoken by default — and the
instance codename is a second name, Latin and capitalised, never
transliterated. The model had only ever seen the Latin name plus the
identity block's lowercase codename, which is where "Нэви" came from.
None of this reached a server: deploy/env.template never set
NAVI_PERSONA_FILE, so an install from the template ran with
settings.navi_persona empty and every rule in the file inert. The variable
joins the template, where .env.example already had it.
Eugene Sukhodolskiy
committed
4 days ago
|

mcp: credentials leave the tree, and sub-agents lose the task board
...
Five tracked configs (gnexus-creds, gntodo, hard-panel, synapse, tgclient)
carried live bearer tokens — in git, in every clone, and handed to the admin
client by GET /admin/mcp/config. They now name a variable, and the value lives
in the service .env (chmod 600, untracked).
Substitution happens once, at transport open, next to the project-relative
path resolution: that is the single place a transport is built, so every
stored and serialised config stays placeholder-only and save_mcp_servers —
reached by create_mcp_server and PUT /admin/mcp/config — cannot write a secret
back into a tracked file. A variable that is not set refuses the connection
instead of dropping the header, which would fail open: a server may answer an
unauthenticated request as an anonymous user rather than returning 401.
Sub-agents also drop gntodo, gnexus-creds, tgclient and synapse from their
scopes, and the runner injects a scope anchor unconditionally — a sub-agent
asked to echo one word had picked a project off the task board and researched
it for forty iterations.
Docs: docs/mcp.md#secrets, a migration recipe in deploy/UPDATE.md, the variable
names in .env.example and deploy/env.template, and a regression guard over the
tracked configs.
The old values stay in git history; rotating all five at their sources is the
remediation, not a follow-up.
Eugene Sukhodolskiy
committed
4 days ago
|