Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 68d60e409b 2026.08.22.02 — theme favourites, ctrl+? shortcuts, command hq in two columns
Schema change this time: theme_favorites is new, and without the push the theme dropdown fails on a missing
relation the first time it reads a starred list. The plan names db:push as a step rather than leaving it to
be inferred from a diff.

Two things the plan states that a reader could not infer. Command HQ MIGRATES a saved layout on load, so
anyone who has opened that screen sees their arrangement replaced once — deliberate, and the screen is
locked so the only loss is column sizes. And the email plugin's multi-account work arrives through
plugins:update from another repository, affecting only hosts where that plugin is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:10:51 +00:00
pastilhasandClaude Opus 5 b1dfa3f7bb spotlight on shift+space, overriding both of its stock behaviours
Fifth setting in three days: ctrl-space → shift-space → alt-space → ctrl-space → shift-space. Ctrl-space was
tried twice on the theory that preventDefault would beat Brave's address bar. It does not — that binding is
at the browser level and the key never reaches the handler. Recorded in the file so it is not tried a third
time.

Shift-space overrides two stock behaviours deliberately, and the owner's reasoning is the right one: it types
a space in a text field, and it scrolls a page UP when nothing has focus. Neither is worth a launcher.
Page-up-by-shift-space is a fossil, and typing a space is what the unmodified spacebar is for.

The cost is stated once in the file rather than argued again: typing in CAPS by holding shift instead of
using caps lock puts a shift-space between every word, which opens the launcher mid-sentence. If that turns
out to bite, the fix is a text-field guard — which this had for one day, and which is exactly what made the
launcher unopenable from the composer, where it is most wanted. Trading that back would be trading down.

Every other modifier must be absent: ctrl+shift+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix. Verified disjoint in the built bundle, both conditions
quoted from it rather than from the source.

Five moves in three days is the argument for making chords configurable rather than for choosing better ones,
which is why the ctrl+? registry names the implementing file for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:05:59 +00:00
pastilhasandClaude Opus 5 f586d748d6 ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space →
ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction —
alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and
would rather move the app than the window manager.

ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made
before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than
rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches
the handler and nothing in this repository can help — the fix would be in Brave.

`!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is
the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the
two conditions are disjoint.

CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls
declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose
help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and
the value is in state/shortcuts.ts.

The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own
conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the
pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now
would rewrite three handlers for nothing visible.

Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by
name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the
UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the
theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:42 +00:00
pastilhasandClaude Opus 5 bcdc769083 commanders are one per line, not a grid
sm:grid-cols-2 xl:grid-cols-3 was reasonable when this panel owned 34% of the screen. It owns part of a 24%
column now, so two columns of card inside it left each one too narrow for its title — every card truncated
and the cost line wrapped.

A grid of one. The card is untouched: same rounded pill, same border and hover, same p-4, same gap-3 between
them. It simply stops being asked to share a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:41:27 +00:00
pastilhasandClaude Opus 5 430f058a6d command hq: two columns, with the navigation stacked
It was 16 / 34 / 50 — a category column, a commander column, and the transcript. So half the screen was
navigation for a list holding a handful of commanders (one, on this machine) while the only thing anybody
reads got the other half.

Now the two navigation panels are stacked into one 24% column — commanders on top, categories beneath — and
the transcript takes 76%.

Categories BELOW the commanders, not above: the commanders are what you came for and the categories are how
you narrow them. Above, the filter would push the list down the screen and be read first; below, the list is
at the top where a list belongs, and the categories can grow their own scrollbar without taking room from it.

The categories panel lost seventy pixels of branding — a Radar tile, "Command HQ", "Watchers &
long-running jobs" — which made sense when it owned a column and does not now that it sits at the foot of
one. The screen's own header says Command HQ two inches above it.

`All commanders` and `Uncategorised` moved INTO the scroll box with the categories. They are the same kind of
thing, a filter over the list, and splitting them into a separate nav block above made the second look like a
different mechanism. The heading and the + button stay pinned; only the list scrolls — the same rule as the
theme dropdown.

MIGRATION, and it is the part that could have gone wrong. This key is persisted per account, so the default
alone moves nobody who has opened the screen. No appType was retired either, so `hasAppType` cannot see it
the way the /chat migration could — the test is structural instead: a horizontal root with three children is
the old shape. It terminates because the replacement has two, and replacing outright is safe on a `locked`
screen where the only user contribution is column sizes. Same reasoning, and same trap, as
ChatHistory/index.tsx.

Two of my edits silently did nothing first, because I searched for `officerdb` where the module is
`officerdev` and used replace without an assertion. Both are asserted now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:38:41 +00:00
pastilhasandClaude Opus 5 9b459b79dd poll gitea locally — the public door is rate-limited and gitea is on this machine
The deploy watcher kept logging `Connection closed by 77.42.39.155 port 22` followed by "make sure you have
the correct access rights", which reads as a credentials problem and is not one. `origin` pointed at
gitea.officer.dev, which resolves to the public address and arrives through Nginx Proxy Manager — where
every connection appears to come from one container IP, internet bots get that IP penalised by
srclimit_penalise, and SSH is refused for long stretches.

Gitea is a plugin ON THIS MACHINE, listening on 100.64.0.13:10023. The watcher was leaving the box and
coming back in through a proxy to read a repository sitting on its own disk.

origin now points at the local address; the public URL is kept as `gitea-public` so nothing is lost. No file
was edited to do it, which matters — the watcher is a running bash script and editing one in place is how a
loop starts executing half of two versions. Changing the remote's URL takes effect on the next tick with
nothing restarted.

Loopback would be better still and is not used: 127.0.0.1 fails host key verification because it has no
known_hosts entry, while the tailnet address is already trusted.

The cost of not knowing this, recorded in the prompt: a tag pushed at 21:47 on 2026-08-21 went unnoticed
until 23:07. Eighty minutes, forty-odd ticks, every one of them correctly logged and retried. The loop was
never at fault — it was asking through a door that is sometimes shut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:27:20 +00:00
pastilhasandClaude Fable 5 ab77c66f04 todo: configurable shortcuts, a shortcut modal, and the omarchy menu revamp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:24:26 +00:00
pastilhasandClaude Opus 5 dd6135cac1 favourite a theme from anywhere, and the browser stops fighting the menu
Three reported faults and two additions, all from one keystroke's worth of use.

THE MENU OPENED BEHIND THE OVERLAY. The theme pill is a Radix DropdownMenuTrigger, and a focused trigger
opens on Space — so ctrl+shift+space opened the browser AND the dropdown behind it. preventDefault could
never have fixed it: React attaches its handlers at the root, which bubbles before window, so the trigger
saw the key first whatever this listener did with it. The listener now runs in the CAPTURE phase and stops
the event dead.

THE VIM KEYS DIED WHEN OPENED FROM AN INPUT. Same shape as the Spotlight bug earlier tonight: the guard
that refuses to steal keys from a text field was still true after the overlay opened, because focus had
never left the field. Opening now blurs, and the guard no longer applies once the list is open — there is no
input in it, so there is nothing left for it to protect. Only the GLOBAL bindings defer to a text field now,
which is the case it was written for.

f STARS WHAT IS FOCUSED, inside the list, so a browse and a shortlist are one pass rather than two.

ctrl+shift+f STARS THE CURRENT THEME FROM ANYWHERE, and deliberately without the typing guard the arrows
beside it need: word selection is what ctrl+shift+←/→ collides with in a text field, and ctrl+shift+f
collides with nothing — no character, no browser binding. Guarding it would only break it in the composer,
which is where you are when a theme starts annoying you.

The header pill now shows a star when what you are wearing is one of yours — only when true, because a
hollow star on every theme is chrome that says nothing two hundred times.

The row star in the overlay is a span with role="button", not a button: it sits inside the row's own
<button>, and nesting one is invalid HTML that browsers resolve by dropping one of them.

useThemeKeys takes the toggle as a callback rather than reaching for it. Favourites are per account and live
behind an API, which is app knowledge; this hook owns keys and selection and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:15:36 +00:00
pastilhasandClaude Opus 5 bd15a186b5 themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording.

ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together
in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it".
Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which
is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the
ctrl+shift+space browser, or one teaches an order the other contradicts.

FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a
DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would
lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are
directories discovered at request time — so a star can outlive its theme and the reader intersects with the
live registry rather than trusting the table.

Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer
"what else is there". Filtering to favourites only is a separate, explicit toggle.

THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line
telling you the shortcuts exist was visible for half a second and then gone under two hundred entries —
which is the same as not having written it. Header and footer are now siblings of a scroll box instead of
passengers in it, and the header names alt+space too.

THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the
sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500,
which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account`
permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as
a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it
away from, and caught that by reading rather than by testing.

Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200
so the login screen keeps its colours.

10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in
the original survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:51:53 +00:00
pastilhasandClaude Opus 5 a15fadbddc 27 tests on the file standing between a member and a shell
authorize.ts had none, and its own header is what says why that mattered: every deny path in the platform
ends there, and the failure mode of a permissive default is not a bug report, it is someone else's session.

It had none for a structural reason rather than an oversight. Every path needed two database calls, and the
only file here that mocks officerdb leaks its stub into whatever runs after it — so the obvious approach
would have traded one untested file for a flaky suite.

So the decisions are split from the fetching. resolveEffectivePermissions, decideApiRequest and
decideWsProvider are pure functions of their arguments; the async wrappers keep the role cache, the
fail-closed catches and the shape callers use. No behaviour change, and the live gate still answers 401 on
file-browser, users and tasks.

REAL registry keys throughout, deliberately: a suite built on invented permissions keeps passing after a
rename, which is the failure an authorization table can least afford. If a key here disappears this file
goes red.

Pinned beyond the happy paths — the owner carries no grants to consult; core is write unconditionally; a
confined grant is dropped without a Linux user and all three drop together; execution and admin grants in
the table are ignored even though no CHECK stops the row existing; an unknown key is ignored without
disturbing what is legitimately held; self-service is exact in method AND path; a socket needs no level; an
unauthenticated caller is refused every door.

Two gaps are stated in the file rather than left to be found: ownerOnly has no declaring permission yet, so
testing it would test the fixtures; and the grant cache's contract is a claim about call sites, not logic.

Found while writing them: readOnlyWrites paths are relative to the permission's api prefix, and getting that
wrong fails as "no permission covers this path" rather than as a level error — a misleading message for a
misleading path, now commented at the test that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:42:56 +00:00
pastilhasandClaude Opus 5 7e054ddf11 deploy report 2026.08.22.01 — 6 of 6 hosts, officer-dev skipped
Rolled deploy/2026.08.22.01 to alpha, fofos, macbookpro, edge-pertento-bridge,
production-pertento-bridge and deadpool-rezio-bridge. Every host fast-forwarded
98177c3b -> 78db9c8c, ran plugins:update, restarted officer, and came back online.

No bun install and no db:push, as the plan required.

officer-dev skipped as the commander's own host, and already on this code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:30:15 +00:00
pastilhasandClaude Opus 5 0c2ff88c88 a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat
rather than navigating to Command HQ — right, and it cost the composer its commander controls, because
`isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander,
rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a
watcher wants, and the one the dialog makes default inside a commander.

GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same
isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation.

The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed.
The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route
to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only
would have broken the brand-new composer, which is where the whole thing starts.

Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and
isCommander=true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:29:35 +00:00
pastilhasandClaude Opus 5 c982353381 the boot check now reads the sockets that are served, not a list beside them
assertPermissionTotality exists to refuse a surface nobody has gated, and it was reading the wrong list:
Object.keys(handlers) — the map of implementations — while Bun served a hand-written route table twenty
lines away. The comment beside it said neither could be a stale copy of the other. That was false, and it
had a live instance: /api/cliamp/ws and /api/cliamp/audio/ws stayed in the route table for two days after
their handlers and registry claims were commented out. Not exploitable — isWsProviderAllowed finds no
permission and 403s a member, while the owner upgrades onto a dead socket — but precisely the drift the
guard is for, invisible to the guard.

Now there is one declaration. WS_ROUTES maps path → provider, the route table is generated from it, and the
check reads it. A socket route cannot exist without being checked.

Two smaller guards arrived with it. A route naming a provider with no handler refuses to boot, with the path
in the message, instead of 500-ing on first connect. And the provider union that was inline in upgradeWs is
named, so the route table typechecks against what that function can actually serve — which caught my first
attempt, where WS_ROUTES was Record<string, string>.

sidecar and dev-proxy are listed explicitly as served-but-absent-from-WS_ROUTES, because they upgrade
through their own handlers rather than upgradeWs. Named so the absence cannot read as an oversight.

Half the TODO was stale and it is recorded as such rather than quietly closed: the eight red tests it cites
have been green since cliamp was deleted on 2026-08-15. Deleting cliamp removed the symptom and left the
shape, which is the more interesting half.

Verified against the real prefix lists rather than by reasoning: the served set passes, one unclaimed
provider is refused by name, all five generated routes answer 401, and the server boots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:25:48 +00:00
43 changed files with 1564 additions and 139 deletions
+7 -7
View File
@@ -20,7 +20,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**87 open, 22 closed**, across 29 themes.
**87 open, 25 closed**, across 29 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
@@ -35,9 +35,9 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 4 | 1 |
| [Known bugs](todo/known-bugs/) | 3 | 2 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 18 | 3 |
| [Multi-user](todo/multi-user/) | 16 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
@@ -45,7 +45,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
@@ -66,10 +66,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
- `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md)
- `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md)
- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md)
- `2026-08-22` **All keyboard shortcuts configurable** — [shell/all-keyboard-shortcuts-configurable](todo/shell/all-keyboard-shortcuts-configurable.md)
- `2026-08-22` **Ctrl+? shows every keyboard shortcut in a modal** — [shell/ctrl-shows-every-keyboard-shortcut-in-a-modal](todo/shell/ctrl-shows-every-keyboard-shortcut-in-a-modal.md)
- `2026-08-22` **Revamp the menus — spotlight, themes — toward an Omarchy-like shape** — [shell/revamp-the-menus-toward-an-omarchy-like-shape](todo/shell/revamp-the-menus-toward-an-omarchy-like-shape.md)
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
+104
View File
@@ -0,0 +1,104 @@
# 2026.08.22.01 — deploy report
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
No host failed, no process came back `errored`.
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
## Per host
### `alpha` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
`~/dockers/officer.dev/platform` was not touched).
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
photos, soulseek, transmission, vault.
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
- All 19 processes `online` afterwards.
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
captured an explicit `0`.
### `fofos` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
touch it, and it was left alone. No tracked change.
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
fix of 2026-08-21 is holding.
### `macbookpro` — succeeded
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
interactive shell, exactly as the plan and `docs/servers.md` require.
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
### `edge-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
### `production-pertento-bridge` — succeeded
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
and the command still exited 0 — recorded so the next reader does not chase it.
- `pm2 restart officer`: 9 → 10, `online`.
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
All `online`, none touched.
### `deadpool-rezio-bridge` — succeeded
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
- HEAD `98177c3b` → `78db9c8c`, tree clean.
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
the others'. Not an error.
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
### `officer-dev` — SKIPPED, and current
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
another agent working in this same tree during the rollout. Its work was left alone; only this report was
staged, by path.
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
at `78db9c8c`, the tag — but the next release will include them.
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
## Verification
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
## What was not done
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
happy with the rest. It needs no pull.
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
files arrived, not that each parses.
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
+57
View File
@@ -0,0 +1,57 @@
# 2026.08.22.02
Theme favourites, a shortcuts modal on `Ctrl+?`, Command HQ rebuilt into two columns, 27 tests on the
authorization resolver, and the email plugin's multi-account backend.
**SCHEMA CHANGE.** `theme_favorites` is new, so `bun db:push` is a step. Without it, opening the theme
dropdown fails on a missing relation the first time it reads a starred list.
**No dependency change** — nothing in `deploy/2026.08.22.01..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **Theme favourites**, per account, in `theme_favorites`. Star from the dropdown, from the browser
(`f`), or from anywhere (`Ctrl+Shift+F`). The lists rotate to the theme you are wearing rather than
sorting it to the top, so neighbours survive.
- **`Ctrl+?`** renders every shell shortcut from a registry — `state/shortcuts.ts` — ported in shape from
the owner's `vimazing`.
- **Spotlight is on `Shift+Space`.** It was `alt-space` in the last release. `Ctrl+Space` cannot work:
Brave claims it at the browser level and the key never reaches the page.
- **Command HQ is two columns** — navigation stacked into one 24% rail, transcript at 76%. **It migrates a
saved layout on load**, so anyone who has opened that screen will see their arrangement replaced once.
That is deliberate; the screen is `locked` and the only user contribution was column sizes.
- **27 tests on `permissions/authorize.ts`**, which had none. Its decisions are now pure functions, split
from the database calls. No behaviour change.
- **`assertPermissionTotality` reads the sockets that are served**, generated from one `WS_ROUTES`
declaration, instead of a parallel list of handlers.
- **The email plugin's multi-account backend**, via `plugins:update` — `?account=<id>` names a mailbox,
`?account=all` spans them, absent is unchanged. Only hosts with that plugin installed are affected.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in the
report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. See
`docs/servers.md`.
## `officer-dev` — skip it, and it is already current
The commander runs there and skips its own host. That host was restarted repeatedly through the day as this
work landed, so it is running this tree rather than lagging. Report it as skipped and current.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved; nothing `errored`.
`theme_favorites` exists — `to_regclass('theme_favorites')` is not null. This is the one step whose absence
shows up as a broken dropdown rather than a failed boot, so verify it directly where `psql` is available and
say where it is not.
`scripts/deploy-claim.sh state 2026.08.22.02` says `done`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.22.01",
"version": "2026.08.22.02",
"private": true,
"type": "module",
"workspaces": [
+15
View File
@@ -18,6 +18,21 @@ there is a release to deploy that has not been deployed.
git ls-remote --tags origin 'refs/tags/deploy/*'
```
**Poll a LOCAL remote, never the public hostname.** On `officer-dev`, gitea is a plugin on the same machine
— it listens on `100.64.0.13:10023` and on loopback — so `origin` points there. `gitea.officer.dev` resolves
to the public address and arrives through Nginx Proxy Manager, where every connection appears to come from
one container IP: internet bots get that IP penalised by `srclimit_penalise`, and SSH is then refused for
long stretches.
That is not hypothetical and it is not subtle when it happens. On 2026-08-21 a tag pushed at 21:47 was not
noticed until 23:07 — eighty minutes of `ls-remote` being closed on, forty-odd ticks, all correctly logged
and retried. On 2026-08-22 it refused at 08:35 and again at 12:58. The loop is not at fault in any of those:
it logs the exit code, sleeps, and carries on. It is simply asking through a door that is sometimes shut.
If `ls-remote` fails with `Connection closed by <public ip> port 22`, that is this, and the remote is wrong
rather than the credentials. Loopback needs a `known_hosts` entry that may not exist; the tailnet address is
already trusted.
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
a commit message, not a branch, not a push.
@@ -1,15 +1,29 @@
import { useEffect } from 'react';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
import { defaultLayout, isLegacyThreeColumn } from './defaultLayout';
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
// /command-hq: navigation and detail, all panels always mounted (like /chat), selection carried by the URL
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
// "Route, surface, placement" and "Layout, v1".
// "Route, surface, placement".
export const CommandHqScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
/*
Move a saved three-column layout onto the two-column one.
The default alone cannot do it: this key is persisted per account, so anybody who has opened the screen
keeps the arrangement they had. See `isLegacyThreeColumn` for why the test is structural rather than an
appType check, and why replacing outright is the safe direction on a `locked` screen.
*/
useEffect(() => {
if (!workspace.isLoaded) return;
if (!isLegacyThreeColumn(workspace.value)) return;
workspace.setValue(defaultLayout);
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
@@ -1,14 +1,60 @@
import type { LayoutNode } from 'officerdev';
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
// detail pane gets the most room because it is a live chat transcript once something is selected.
/*
Two columns, with the navigation stacked into the left one.
── Why it changed from three ──
It was `16 / 34 / 50`: a category column, a commander column, and the transcript. So half the screen was
navigation for a list that holds a handful of commanders — one, on the machine this was written on — while
the only thing anybody reads got the other half. A category pane of its own is a filter for a list short
enough to see whole.
Now: commanders on top, categories beneath them, transcript taking three quarters.
── Why the categories are BELOW the commanders ──
Because the commanders are the thing you came for and the categories are how you narrow them when there
are too many to scan. Above, the filter would push the list down the screen and be the first thing read;
below, the list is at the top where a list belongs and the filter is where you reach for it. It also means
the categories can grow their own scrollbar without ever taking room from the commanders.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'command-hq-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
{
node: {
type: 'group',
id: 'command-hq-nav',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 62 },
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 38 },
],
},
size: 24,
},
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 76 },
],
};
/**
* Is this a saved copy of the three-column layout this replaced?
*
* No appType was retired — all three panels survive and only their arrangement changed — so `hasAppType`
* cannot see this the way the /chat migration could. The test is structural instead: the old shape is a
* horizontal root with THREE children, and the new one has two.
*
* It terminates for that reason: the replacement does not satisfy the test.
*
* Replacing outright is safe because the screen is `locked` — its structure is dictated by code, and the
* only thing a person can have contributed is the column sizes, which is a cheap thing to lose once. The
* same reasoning, and the same warning, as `ChatHistory/index.tsx`: get the test backwards and a stranded
* panel collapses onto `appTypes.fallback` and is written back, which is how /offscale once rendered three
* copies of one picker.
*/
export function isLegacyThreeColumn(node: LayoutNode): boolean {
return node.type === 'group' && node.direction === 'horizontal' && node.children.length === 3;
}
@@ -8,6 +8,7 @@ import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
import { ShortcutsModal } from './ShortcutsModal';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
@@ -51,6 +52,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
<Spotlight />
{/* Outside the content region for the same reason Spotlight is: ctrl+? has to work from a maximised
panel, an error boundary, or mid-navigation. */}
<ShortcutsModal />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
@@ -1,8 +1,10 @@
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check } from 'lucide-react';
import { Check, Star } from 'lucide-react';
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
import * as Dropdown from '@/components/ui/dropdown-menu';
import type { Theme } from '@/components/ui/ThemeProvider';
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
import { useThemeFavorites } from '@/state/useThemeFavorites';
const {
DropdownMenu,
@@ -21,7 +23,7 @@ const {
*
* ── The overlay ──
*
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
* you browse, so as little of it as possible should be covered.
@@ -30,21 +32,39 @@ const {
*/
export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme();
const { open, setOpen, focused, setFocused } = useThemeKeys();
const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
const [onlyFavorites, setOnlyFavorites] = useState(false);
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
// readonly, so this builds mutable arrays rather than pushing into its own type.
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
(acc[t.family] ??= []).push(t);
return acc;
}, {});
/*
One ordering, used by both lists.
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
is the registry order — families sit together in it — and lifting the selected theme out of its family
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
`theme/rotate.ts`.
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
the order it was given, so the theme you are wearing still leads.
*/
const ordered = useMemo(() => {
const rotated = rotateToSelected(themes, themeId);
const listed = onlyFavorites ? rotated.filter((t) => favorites.has(t.id)) : rotated;
return favouritesFirst(listed, favorites);
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
const families = useMemo(() => groupByFamily(ordered), [ordered]);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={`Theme: ${theme.name} · ctrl+shift+←/→ to cycle · ctrl+shift+space for the list`}
title={`Theme: ${theme.name} · ctrl+shift+←/→ cycle · ctrl+shift+space browse · ctrl+shift+f ${
isFavorite(themeId) ? 'unstar' : 'star'
}`}
aria-label={`Theme: ${theme.name}`}
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
@@ -53,23 +73,74 @@ export const ThemeSwitcher = () => {
style={{ color: 'var(--header-fg)' }}
>
{theme.name}
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
ctrl+shift+←/→ · ctrl+shift+space
</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(families).map(([family, entries]) => (
<div key={family}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
{t.name}
</DropdownMenuItem>
))}
</div>
))}
{/*
The CONTENT does not scroll — the list inside it does.
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
visible for the first half-second and then gone for good, which is the same as not having written
it. Header and footer are now siblings of a scroll box rather than passengers in one.
*/}
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
<div className="shrink-0 border-b border-border px-2 py-1.5">
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
<span className="block">ctrl+shift+←/→ cycle · ctrl+shift+space browse</span>
<span className="block">ctrl+? for every shortcut</span>
</DropdownMenuLabel>
<button
type="button"
onClick={(ev) => {
// The dropdown closes on any item activation, and this is a filter rather than a choice.
ev.preventDefault();
ev.stopPropagation();
setOnlyFavorites((was) => !was);
}}
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
}`}
>
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
{onlyFavorites ? `Favourites only (${favorites.size})` : `Favourites (${favorites.size})`}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{ordered.length === 0 ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No favourites yet — star one with the button on its row.
</p>
) : (
families.map(([family, entries]) => (
<div key={family}>
{entries.map((t) => (
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer pr-1">
<Check className={`mr-2 h-4 w-4 shrink-0 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{t.name}</span>
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
<button
type="button"
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
title={isFavorite(t.id) ? 'Remove from favourites' : 'Add to favourites'}
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
toggle(t.id);
}}
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
>
<Star className={`h-3.5 w-3.5 ${isFavorite(t.id) ? 'fill-current text-primary' : ''}`} />
</button>
</DropdownMenuItem>
))}
</div>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -90,8 +161,11 @@ export const ThemeSwitcher = () => {
}}
onClick={(ev) => ev.stopPropagation()}
>
<ul className="flex flex-col gap-1">
{themes.map((t) => {
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
same thing in both, or the browse teaches you an order the menu then contradicts. */}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((t) => {
const isFocused = t.id === focused;
return (
<li key={t.id}>
@@ -101,19 +175,43 @@ export const ThemeSwitcher = () => {
setTheme(t.id);
setOpen(false);
}}
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
}`}
>
{t.name}
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
<span className="min-w-0 flex-1 truncate">{t.name}</span>
{t.id === themeId && <span className="shrink-0 text-xs opacity-70">active</span>}
{/*
A span, not a button: this sits inside the row's own <button>, and a button inside
a button is invalid HTML that browsers resolve by dropping one of them. The click
handler and the role are set explicitly instead, and `f` does the same thing from
the keyboard.
*/}
<span
role="button"
tabIndex={-1}
aria-label={isFavorite(t.id) ? `Unstar ${t.name}` : `Star ${t.name}`}
title={isFavorite(t.id) ? 'Remove from favourites (f)' : 'Add to favourites (f)'}
onClick={(ev) => {
// Or the row's own onClick applies the theme and closes the overlay, which is
// the opposite of what starring is for.
ev.preventDefault();
ev.stopPropagation();
toggle(t.id);
}}
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
>
<Star className={`h-4 w-4 ${isFavorite(t.id) ? 'fill-current' : ''}`} />
</span>
</button>
</li>
);
})}
</ul>
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
header does. */}
<p className="mt-6 text-center text-xs opacity-60">
j/k or ↑/↓ move · l or → preview · h or ← reset · enter keep · esc cancel
j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel
</p>
</div>
</div>,
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { Keyboard } from 'lucide-react';
import { isShortcutsChord, SHORTCUT_SECTIONS } from '@/state/shortcuts';
/*
`Ctrl+?` — every shortcut the shell owns, on one screen.
Ported in shape from `vimazing`, where the controls are declared as data and the help screen renders them.
Its renderer was a recursive `any` walk over arbitrary content, which is right for a game whose help also
carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the
interesting part is `state/shortcuts.ts`.
Card per section, two columns on anything wider than a phone — the same arrangement vimazing used for its
controls grid, and for the same reason: these are short lists that read badly as one long column.
*/
const Row = ({ keys, description }: { keys: string; description: string }) => (
<div className="flex items-baseline gap-3 py-1">
{/* Fixed width, tabular: the chords line up into a column you can scan, which is the whole point of
putting them on the left. */}
<kbd className="w-32 shrink-0 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-center font-mono text-[11px] text-foreground/90">
{keys}
</kbd>
<span className="text-sm text-muted-foreground">{description}</span>
</div>
);
export const ShortcutsModal = () => {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (isShortcutsChord(ev)) {
// No focus check: `Ctrl+?` types nothing in a text field, and a help list you cannot open from the
// composer is a help list you cannot open — the lesson from the Spotlight guard.
ev.preventDefault();
setOpen((was) => !was);
return;
}
if (ev.key === 'Escape' && open) setOpen(false);
};
// Capture, so a panel with its own key handling cannot swallow it first.
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[901] flex items-start justify-center bg-black/50 pt-[8vh] backdrop-blur-sm"
onMouseDown={() => setOpen(false)}
>
<div
className="flex max-h-[80vh] w-[min(94vw,860px)] flex-col overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-5 py-3">
<Keyboard className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<span className="ml-auto text-xs text-muted-foreground">Ctrl+? · Esc to close</span>
</div>
{/* Only this scrolls — the header stays, for the same reason the theme dropdown's does. */}
<div className="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 sm:grid-cols-2">
{SHORTCUT_SECTIONS.map((section) => (
<section key={section.title} className="rounded-xl border border-border bg-muted/20 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/80">{section.title}</h3>
{section.note && <p className="mt-1 text-[11px] leading-snug text-muted-foreground">{section.note}</p>}
<div className="mt-2">
{section.shortcuts.map((shortcut) => (
<Row key={shortcut.keys} keys={shortcut.keys} description={shortcut.description} />
))}
</div>
</section>
))}
</div>
</div>
</div>
);
};
@@ -11,12 +11,28 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
//
// ── Keyboard first, and the trigger ──
//
// `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner
// telling us what the chord actually collides with on the machine he uses:
// `shift-space`, and ONLY that. Fifth setting: ctrl-space → shift-space → alt-space → ctrl-space →
// shift-space. Every move was the owner naming a collision, and by now the list is the useful part:
//
// ctrl-space focuses the address bar in Brave
// shift-space types a space — see below, this was the real problem
// alt-space taken by the window menu, and the owner rebound it rather than give up the chord
// ctrl-space Brave takes it for the address bar, at the browser level. Tried twice, on the theory that
// preventDefault would win. It does not — the key never reaches this handler.
// alt-space the GNOME window menu. He rebound the desktop to keep it, disliked the replacement, and
// would rather move the app than the window manager.
// shift-space where it lands, with BOTH of its stock behaviours deliberately overridden.
//
// ── Shift-space overrides two things, on purpose ──
//
// It types a space in a text field, and it scrolls a page UP when nothing has focus. The owner's judgement,
// and it is his to make: neither is worth a launcher. Page-up-by-shift-space is a fossil nobody reaches for,
// and typing a space is what the unmodified spacebar is for.
//
// The cost, stated once because it is the one that will bite: typing in CAPS by holding shift rather than
// using caps lock puts a shift-space between every word. That opens the launcher mid-sentence. If it turns
// out to be a nuisance, the fix is a guard for text fields only — which this had for a day, and which is
// what made the launcher unopenable from the composer, where it is most wanted.
//
// This is also why the ctrl+? registry names the file that implements each chord: five moves in three days
// is the argument for making them configurable, not for choosing better.
//
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
// combination inside a text field: it types a space, and people hold shift through one more often than
@@ -24,7 +40,7 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
// A launcher you have to prepare for is not a launcher.
//
// Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
// There is NO focus check, and here that is a choice with a cost rather than a free one — see above. The guard shift-space
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
@@ -200,15 +216,18 @@ export const Spotlight = () => {
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
// `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and
// nothing at all on some), while the physical key is `Space` everywhere.
// `code`, not `key`: shift+space reports `key: ' '` on most layouts and something else on a few, while
// the physical key is `Space` everywhere.
//
// Every other modifier must be ABSENT. `ctrl+shift+space` is the theme browser, and one chord opening
// both is the bug that took a capture-phase listener to fix on the theme side.
//
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
// below covers the only thing the guard was protecting against — a layout that would otherwise type
// a character.
if (ev.altKey && ev.code === 'Space') {
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.code === 'Space') {
ev.preventDefault();
// Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses
// this panel's own input, and on paper that is enough — but "on paper" is what produced arrow keys
+127
View File
@@ -0,0 +1,127 @@
/*
Every keyboard shortcut the shell owns, as data.
── Why a registry ──
Ported in shape from the owner's `vimazing` (`vim-maze/src/gameInfo.ts`), where the controls are declared
once and the help screen renders them. The alternative is what this replaced: chords spelled out inside
three separate key handlers, and a help text nobody updates because it lives somewhere else.
Two things follow from having it. `Ctrl+?` can render an accurate list without anybody maintaining prose.
And when shortcuts become configurable — the next big piece — this is the table a binding UI edits, rather
than a grep across handlers.
── What it is NOT, yet ──
Declarative but not authoritative: the handlers still contain their own conditions, so a chord changed here
does not move. Making this the single source is part of the configurable-shortcuts work, and doing it now
would mean a rewrite of three handlers for no visible gain. Where a chord appears in both places the file
that owns it is named in `implementedIn`, so the pair can be found in one search.
*/
export type Shortcut = {
/** As a person reads it — `Ctrl+Shift+Space`, not a KeyboardEvent spelling. */
keys: string;
description: string;
/** The file whose handler actually implements it, until this registry is authoritative. */
implementedIn: string;
};
export type ShortcutSection = {
title: string;
/** Shown under the title when the section needs a caveat that a per-row description cannot carry. */
note?: string;
shortcuts: Shortcut[];
};
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
title: 'Navigation',
shortcuts: [
{
keys: 'Shift+Space',
description: 'Spotlight — go to any screen, plugin or setting by name',
implementedIn: 'Layout/Spotlight/Spotlight.tsx',
},
{ keys: 'Ctrl+?', description: 'This list', implementedIn: 'Layout/ShortcutsModal.tsx' },
],
},
{
title: 'Themes',
note: 'The browser applies as you move, so you watch the app change rather than reading names.',
shortcuts: [
{
keys: 'Ctrl+Shift+Space',
description: 'Open the theme browser',
implementedIn: 'theme/useThemeKeys.ts',
},
{
keys: 'Ctrl+Shift+← / →',
description: 'Previous / next theme, without opening anything',
implementedIn: 'theme/useThemeKeys.ts',
},
{
keys: 'Ctrl+Shift+F',
description: 'Star or unstar the current theme, from anywhere',
implementedIn: 'theme/useThemeKeys.ts',
},
],
},
{
title: 'In the theme browser',
shortcuts: [
{ keys: 'j / k ↑ / ↓', description: 'Move through the list', implementedIn: 'theme/useThemeKeys.ts' },
{
keys: 'l →',
description: 'Apply the focused theme and keep browsing',
implementedIn: 'theme/useThemeKeys.ts',
},
{ keys: 'h ←', description: 'Put back the theme you started on', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'f', description: 'Star or unstar the focused theme', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Enter', description: 'Keep what you are looking at', implementedIn: 'theme/useThemeKeys.ts' },
{ keys: 'Esc', description: 'Cancel and restore', implementedIn: 'theme/useThemeKeys.ts' },
],
},
{
title: 'In Spotlight',
shortcuts: [
{ keys: '↑ / ↓', description: 'Move through the results', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Enter', description: 'Go there', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
{ keys: 'Esc', description: 'Close', implementedIn: 'Layout/Spotlight/Spotlight.tsx' },
],
},
{
title: 'Chat',
note: 'Recall only takes the arrow key from an EMPTY composer, and any other key ends a recall — so a draft is never overwritten.',
shortcuts: [
{
keys: '↑ / ↓',
description: 'Previous / next prompt you sent, shell-style',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{
keys: 'Enter',
description: 'Send · on an empty composer with something queued, send that now',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
{ keys: 'Shift+Enter', description: 'Newline', implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts' },
{
keys: 'Esc',
description: 'Stop the turn that is generating',
implementedIn: 'Chat/EmbeddableChat/useEmbeddableChat.ts',
},
],
},
];
/**
* Is this the `Ctrl+?` chord?
*
* `?` is `Shift+/` on most layouts and its own key on some, so both spellings are accepted rather than
* guessing which keyboard is attached. `code === 'Slash'` is the physical key, which is stable where `key`
* is not.
*/
export function isShortcutsChord(ev: KeyboardEvent): boolean {
if (!ev.ctrlKey) return false;
return ev.key === '?' || (ev.shiftKey && ev.code === 'Slash');
}
@@ -0,0 +1,55 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
/*
Starred themes, per account.
With 200+ installed, "which of these did I like" is a question the list cannot answer by itself, and the
answer belongs to the account rather than to the browser — the same taste follows you to the phone.
Optimistic, and that is not a nicety here: the star sits in a dropdown you are about to close, so a
round trip before the icon fills means the confirmation arrives after the thing it confirms has gone.
*/
const KEY = ['themes', 'favorites'];
export function useThemeFavorites() {
const client = useClient();
const queryClient = useQueryClient();
const query = useQuery<{ favorites: string[] }>({
queryKey: KEY,
queryFn: () => client.get<{ favorites: string[] }>('/theme-favorites'),
// Taste does not change while you look at it, and the mutation below keeps the cache honest.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: false,
});
const toggle = useMutation({
mutationFn: ({ themeId, favorite }: { themeId: string; favorite: boolean }) =>
client.put<{ themeId: string; favorite: boolean }>(`/theme-favorites/${themeId}`, { favorite }),
onMutate: async ({ themeId, favorite }) => {
await queryClient.cancelQueries({ queryKey: KEY });
const previous = queryClient.getQueryData<{ favorites: string[] }>(KEY);
const current = previous?.favorites ?? [];
queryClient.setQueryData(KEY, {
favorites: favorite ? [...new Set([...current, themeId])] : current.filter((id) => id !== themeId),
});
return { previous };
},
// Put it back on failure. A star that stayed filled after the write was refused is worse than one that
// never filled: it reports a preference the server does not hold.
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(KEY, context.previous);
},
});
const favorites = new Set(query.data?.favorites ?? []);
return {
favorites,
isFavorite: (themeId: string) => favorites.has(themeId),
toggle: (themeId: string) => toggle.mutate({ themeId, favorite: !favorites.has(themeId) }),
};
}
+1
View File
@@ -38,6 +38,7 @@ export * from './chat-notification-prefs';
export * from './nudges';
export * from './ai-provider-usage';
export * from './anthropic-accounts';
export * from './theme-favorites';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
+1
View File
@@ -35,6 +35,7 @@ export * from './chat-session-models/schema'; // chat_session_models
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './agent-panels/schema'; // agent_panels
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,28 @@
import { and, eq } from 'drizzle-orm';
import { db } from '../db';
import { themeFavorites } from './schema';
/** The theme ids this account has starred. Order is not meaningful — the caller owns the ordering. */
export async function listThemeFavorites(userId: number): Promise<string[]> {
const rows = await db
.select({ themeId: themeFavorites.themeId })
.from(themeFavorites)
.where(eq(themeFavorites.userId, userId));
return rows.map((r) => r.themeId);
}
/**
* Star or unstar, returning the state it ended in.
*
* Idempotent in both directions on purpose: a double-tap, or two tabs racing, must not end with the star
* in a state neither of them asked for. `onConflictDoNothing` makes the insert safe to repeat, and the
* delete is naturally so.
*/
export async function setThemeFavorite(userId: number, themeId: string, favorite: boolean): Promise<boolean> {
if (favorite) {
await db.insert(themeFavorites).values({ userId, themeId }).onConflictDoNothing();
return true;
}
await db.delete(themeFavorites).where(and(eq(themeFavorites.userId, userId), eq(themeFavorites.themeId, themeId)));
return false;
}
@@ -0,0 +1,37 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* Which themes an account has starred.
*
* ── Why a table, when a preference could be one JSON column ──
*
* Because it is per account and unbounded. `user_settings` would hold an array that grows with taste and
* has to be read-modify-written by whoever toggles a star, and two tabs doing that at once lose one of the
* stars. A row per favourite makes a toggle an INSERT or a DELETE, which cannot race with itself.
*
* ── Why the theme id is text with no foreign key ──
*
* Themes are not database rows. Most are compiled into the bundle; the rest are directories under
* `platform/themes/` discovered at request time, so a theme can appear or vanish without anything writing
* SQL. A foreign key would either be unenforceable or would make installing a theme a migration.
*
* The consequence is deliberate: a star can outlive its theme. That is why the reader intersects with the
* live registry rather than trusting the table — a favourite naming a theme nobody has installed is
* ignored, not shown as a broken entry, and it costs one row until the account unstars something.
*/
export const themeFavorites = pgTable(
'theme_favorites',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The theme's registry id — `duck-dark`, `catppuccin`. Not a foreign key; see above. */
themeId: text('theme_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
// See src/databases/CLAUDE.md → "Composite keys".
(table) => [uniqueIndex('uq_theme_favorites_user_theme').on(table.userId, table.themeId)],
);
+67 -16
View File
@@ -162,16 +162,60 @@ const handlers: Record<string, any> = {
sidecar: sidecarWebsocket,
};
// Both doors into the platform, checked against the permission registry before either opens.
//
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
// from the map directly above, so neither list can be a stale copy of the thing it describes.
/**
* Every websocket path Bun serves, and the provider each one upgrades to.
*
* ── Why this exists rather than a hand-written route table ──
*
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
*
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
* days after their handlers and registry entries were commented out — not exploitable, since
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
* shape.
*
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
* handler or without a registry claim fails the boot rather than serving quietly.
*/
const WS_ROUTES: Record<string, UpgradableProvider> = {
'/api/tasks/run/ws': 'task-runner',
'/api/tasks/pipeline/ws': 'pipeline',
'/api/terminal/ws': 'terminal',
'/api/chat/ws': 'chat',
'/api/chat/notify/ws': 'chat-notify',
};
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
/*
Both doors into the platform, checked against the permission registry before either opens.
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
parallel list that agrees with it by hand.
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
failure this catches is the other one — a route reachable with nothing claiming it.
*/
assertPermissionTotality({
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
wsProviders: Object.keys(handlers),
wsProviders: SERVED_WS_PROVIDERS,
});
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
// into a refusal to boot with the name in it.
for (const [path, provider] of Object.entries(WS_ROUTES)) {
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
}
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
// module whose only job is a side effect is one an import sweep quietly deletes.
@@ -312,11 +356,16 @@ console.log(
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
);
async function upgradeWs(
req: Request,
server: any,
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
) {
/**
* A provider reachable by upgrading a URL.
*
* Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
* deliberately absent — they upgrade through their own handlers, not through here.
*/
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 });
@@ -441,11 +490,13 @@ const server = serve({
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
},
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
...Object.fromEntries(
Object.entries(WS_ROUTES).map(([path, provider]) => [
path,
(req: Request, server: any) => upgradeWs(req, server, provider),
]),
),
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
+10
View File
@@ -257,6 +257,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
offset: windowed ? start : 0,
title: context?.title ?? null,
partCount: context?.partCount ?? 1,
/*
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
decided whether to by looking at the route it was rendered on. So the same commander, opened from
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
travels with the conversation.
*/
isCommander: isCommandersDir(who.email, detail.cwd),
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { Context } from 'hono';
import { listThemeFavorites, setThemeFavorite } from 'officerdb';
import { createRouter } from '../../create-router';
/*
Starred themes, per account.
── Why this is not on `/themes` ──
Because that prefix is deliberately UNAUTHENTICATED: the sign-in screen renders in the chosen theme, so
the list and the stylesheets have to be readable before anyone has an account. A per-account preference on
an anonymous prefix has no caller to attribute it to — the first version of this lived there and answered
500 to an anonymous GET, which was the honest consequence arriving early.
── Why the `account` permission ──
It is `core`, so every account holds it and it cannot be denied, and it already owns `/user`, `/dock` and
`/api-keys` — preferences and credentials belonging to the caller themselves. A theme favourite is the
same shape as a dock preference: it grants nothing, it is about this account's own eyes, and denying it
would only mean an account that can choose a theme cannot remember which ones it liked.
Every route here reads `user.id` from the token. None of them names an account, so there is nothing to get
wrong about whose stars these are.
*/
export const themeFavoritesRouter = createRouter();
// GET /theme-favorites — this account's starred theme ids.
themeFavoritesRouter.get('/', async (ctx: Context) => {
const user = ctx.get('user');
return ctx.json({ favorites: await listThemeFavorites(user.id) });
});
// PUT /theme-favorites/:id — star or unstar one.
//
// PUT rather than POST because it SETS a state rather than appending: sending `{favorite:true}` twice ends
// where sending it once does, which is what a double-tap and two racing tabs both need. The store is
// idempotent in both directions for the same reason.
themeFavoritesRouter.put('/:id', async (ctx: Context) => {
const user = ctx.get('user');
const themeId = ctx.req.param('id');
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
const favorite = body.favorite !== false;
return ctx.json({ themeId, favorite: await setThemeFavorite(user.id, themeId, favorite) });
});
+6
View File
@@ -5,6 +5,12 @@ import { discoverThemes, readThemeCss } from '../../themes/discover';
/**
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
*
* **This prefix is UNAUTHENTICATED** (`UNPROTECTED_API_PREFIXES` in hono.ts) and must stay that way: the
* sign-in screen paints in the chosen theme, before any account exists to ask. So nothing per-account can
* live here — theme FAVOURITES are on `/theme-favorites`, behind the `account` permission, for exactly that
* reason. Adding them here returned 500 on an anonymous request, which was the polite version of the
* problem.
*
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
+3
View File
@@ -6,6 +6,7 @@ import { authRouter } from './api/auth';
import { serverSettingsRouter } from './api/server-settings/server-settings';
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
import { themesRouter } from './api/themes/themes';
import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router';
@@ -122,6 +123,8 @@ const isDavPath = (path: string) =>
// disambiguate. `/pipeline-jobs` and `/jobs` deliberately share one router.
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
['/server-settings', serverSettingsRouter],
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter],
['/skills', skillsRouter],
['/tasks', tasksRouter],
+216
View File
@@ -0,0 +1,216 @@
import { describe, expect, test } from 'bun:test';
import type { UserRole } from 'officerdb';
import type { EffectivePermissions } from './authorize';
import { decideApiRequest, decideWsProvider, resolveEffectivePermissions } from './authorize';
import type { PermissionLevel } from './registry';
import { CORE_PERMISSIONS } from './registry';
/*
The resolver, which its own header calls the file standing between a Member and a shell — and which had no
automated tests at all until 2026-08-22.
It had none because every path needed two database calls. The decisions are now split from the fetching
(`resolveEffectivePermissions`, `decideApiRequest`, `decideWsProvider` are pure), so this file needs no
mock, no database and no `officerdb` stub — which matters beyond convenience: the one file in this
repository that mocks `officerdb` leaks its stub into whatever runs after it.
REAL registry keys throughout, deliberately. A suite built on invented permissions would keep passing
after a rename, which is the failure mode a test of an authorization table can least afford. If a key here
disappears, this file should go red.
*/
const member = (osUser: string | null = 'm1'): { role: UserRole; osUser: string | null } => ({
role: 'Member',
osUser,
});
const grants = (...pairs: Array<[string, PermissionLevel]>) => new Map<string, PermissionLevel>(pairs);
const resolve = (user: { role: UserRole; osUser: string | null }, g = grants()) => resolveEffectivePermissions(user, g);
/** No account at all — an unauthenticated or deleted caller, as the async wrapper reports it. */
const NOBODY: EffectivePermissions = { isOwner: false, grants: new Map() };
describe('resolving what an account holds', () => {
test('the owner bypasses, and carries no grants to consult', () => {
const owner = resolve({ role: 'Super Admin', osUser: null });
expect(owner.isOwner).toBe(true);
// Empty on purpose: an owner's grants are never read, and filling them would invite somebody to.
expect(owner.grants.size).toBe(0);
});
test('a member gets every core permission at write, granted nothing', () => {
const held = resolve(member()).grants;
for (const permission of CORE_PERMISSIONS) expect(held.get(permission.key)).toBe('write');
});
test('core is write even though nothing granted it', () => {
// A read-only "change your own password" is not a coherent thing to offer, which is why core is not
// levelled. Pinned because a future levels-everywhere refactor would silently break it.
expect(resolve(member()).grants.get('account')).toBe('write');
});
test('an app grant is honoured at the level given', () => {
expect(resolve(member(), grants(['calendar', 'read'])).grants.get('calendar')).toBe('read');
expect(resolve(member(), grants(['calendar', 'write'])).grants.get('calendar')).toBe('write');
});
// The rule that only exists because getOwnerHomeDir ignores its argument: a confined grant with no Linux
// user to confine to would resolve to the OWNER's home.
test('a confined grant needs a Linux account', () => {
expect(resolve(member('m1'), grants(['files', 'write'])).grants.get('files')).toBe('write');
expect(resolve(member(null), grants(['files', 'write'])).grants.has('files')).toBe(false);
});
test('every confined permission is dropped together, not one by one', () => {
const held = resolve(member(null), grants(['files', 'write'], ['terminal', 'write'], ['chat', 'write']));
for (const key of ['files', 'terminal', 'chat']) expect(held.grants.has(key)).toBe(false);
});
// The database has no CHECK stopping these rows; the API refuses to write one. This is the layer that
// has to hold if one ever exists.
test('an execution grant in the table is ignored', () => {
expect(resolve(member(), grants(['tasks', 'write'])).grants.has('tasks')).toBe(false);
});
test('an admin grant in the table is ignored', () => {
expect(resolve(member(), grants(['user-admin', 'write'])).grants.has('user-admin')).toBe(false);
expect(resolve(member(), grants(['server-admin', 'write'])).grants.has('server-admin')).toBe(false);
});
test('a grant naming a permission that no longer exists is ignored', () => {
const held = resolve(member(), grants(['cliamp', 'write'], ['a-plugin-uninstalled-last-year', 'read']));
expect(held.grants.has('cliamp')).toBe(false);
expect(held.grants.has('a-plugin-uninstalled-last-year')).toBe(false);
// …and does not disturb what is legitimately held.
expect(held.grants.get('account')).toBe('write');
});
});
describe('deciding an HTTP request', () => {
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
test('the owner is allowed anything, including paths nothing serves', () => {
expect(decideApiRequest(owner, 'DELETE', '/api/users/9').allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this').allowed).toBe(true);
});
test('a path no permission covers is refused, not allowed by default', () => {
const d = decideApiRequest(NOBODY, 'GET', '/api/nothing-serves-this');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('no permission covers this path');
});
test('an execution permission is refused however it is granted', () => {
// Refused by KIND, before any grant lookup — so a row granting it changes nothing.
const forged = resolve(member(), grants(['tasks', 'write']));
const d = decideApiRequest(forged, 'GET', '/api/tasks');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('runs as the server owner');
});
test('an admin permission is refused', () => {
const d = decideApiRequest(resolve(member()), 'GET', '/api/users');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('restricted to the server owner');
});
// The one self-service route in the registry: updating your own profile lives on the router that also
// holds account administration.
test('a self-service route is allowed with no grant at all', () => {
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users').allowed).toBe(true);
});
test('self-service is exact — neither the method nor the path may drift', () => {
expect(decideApiRequest(resolve(member()), 'POST', '/api/users').allowed).toBe(false);
expect(decideApiRequest(resolve(member()), 'PUT', '/api/users/7').allowed).toBe(false);
});
test('no grant means no access, with the permission named', () => {
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser');
expect(d.allowed).toBe(false);
expect(d.reason).toContain('your role does not have access to');
});
test('read grants reads and refuses writes', () => {
const held = resolve(member('m1'), grants(['files', 'read']));
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
const write = decideApiRequest(held, 'POST', '/api/file-browser');
expect(write.allowed).toBe(false);
expect(write.reason).toContain('read-only');
});
test('write grants both', () => {
const held = resolve(member('m1'), grants(['files', 'write']));
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(true);
expect(decideApiRequest(held, 'POST', '/api/file-browser').allowed).toBe(true);
});
// A read that has to be a POST — the body is too big for a query string — must not need write.
//
// The path is `/api/file-browser/download`, not `/api/download`: readOnlyWrites entries are RELATIVE to
// the permission's own api prefix. Worth a comment because the absolute form looks right, is what I wrote
// first, and fails as "no permission covers this path" rather than as a level problem — a misleading
// error for a misleading path.
test('a readOnlyWrites path is allowed at read level', () => {
const held = resolve(member('m1'), grants(['files', 'read']));
expect(decideApiRequest(held, 'POST', '/api/file-browser/download').allowed).toBe(true);
// And the same POST elsewhere under the prefix still needs write.
expect(decideApiRequest(held, 'POST', '/api/file-browser/move').allowed).toBe(false);
});
// The two rules composing: granted, but with no kernel boundary to be granted inside.
test('a granted confined permission is still refused without a Linux account', () => {
const held = resolve(member(null), grants(['files', 'write']));
expect(decideApiRequest(held, 'GET', '/api/file-browser').allowed).toBe(false);
});
test('an unauthenticated caller is refused everything', () => {
for (const path of ['/api/file-browser', '/api/chat', '/api/users', '/api/tasks']) {
expect(decideApiRequest(NOBODY, 'GET', path).allowed).toBe(false);
}
});
});
describe('deciding a websocket', () => {
test('the owner opens anything', () => {
expect(decideWsProvider({ isOwner: true, grants: new Map() }, 'terminal')).toBe(true);
});
test('a provider no permission claims is refused', () => {
expect(decideWsProvider(resolve(member()), 'not-a-provider')).toBe(false);
});
test('a confined socket needs the grant AND the Linux account', () => {
expect(decideWsProvider(resolve(member('m1'), grants(['terminal', 'read'])), 'terminal')).toBe(true);
expect(decideWsProvider(resolve(member(null), grants(['terminal', 'read'])), 'terminal')).toBe(false);
});
// No method to reason about, so any level opens it. Pinned because "read-only socket" is a tempting idea
// that would silently close terminals granted at read.
test('read is enough — a socket has no method', () => {
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat')).toBe(true);
expect(decideWsProvider(resolve(member('m1'), grants(['chat', 'read'])), 'chat-notify')).toBe(true);
});
test("an execution permission's socket is refused however it is granted", () => {
const forged = resolve(member('m1'), grants(['tasks', 'write']));
expect(decideWsProvider(forged, 'task-runner')).toBe(false);
expect(decideWsProvider(forged, 'pipeline')).toBe(false);
});
test('an unauthenticated caller opens nothing', () => {
for (const p of ['terminal', 'chat', 'task-runner', 'sidecar']) expect(decideWsProvider(NOBODY, p)).toBe(false);
});
});
/*
NOT covered here, and stated rather than left as a gap somebody has to notice:
- `isOwnerOnlyRoute`. No permission in the registry declares `ownerOnly` yet — the field was added on
2026-08-21 for plugin settings routes and has no user. It is checked before self-service and before
kind, which is the part worth a test, and it cannot get one until a real permission uses it. Covering it
with an invented permission would test this file's fixtures rather than the registry.
- The grant cache and `invalidateRoleGrants`. They live on the async side, around a database call, which
is exactly the half this split moved away from. Their contract — the one writer in
`api/users/permissions-routes.ts` calls the invalidator — is a claim about call sites, not about logic,
and a test here could only re-state it.
*/
+68 -35
View File
@@ -65,6 +65,57 @@ async function grantsForRole(role: UserRole): Promise<Map<string, PermissionLeve
* write such a row, but this is the layer that has to hold if one ever exists — a constraint the database
* does not enforce is a constraint the reader must.
*/
/**
* The resolution itself, with no database in it.
*
* Split out on 2026-08-22 so it can be tested. `authorize.ts` is described in its own header as the file
* standing between a Member and a shell, and it had no automated tests at all — because every path through
* it needed two database calls, and the only file in this repository that mocks `officerdb` has a stub that
* leaks into whatever runs after it. Pulling the decision away from the fetching removes the need for a
* mock rather than working around one: everything below this line is a pure function of two arguments.
*
* The async wrapper keeps the caching, the error handling and the single-question shape callers rely on.
*/
export function resolveEffectivePermissions(
user: { role: UserRole; osUser: string | null },
roleGrants: Map<string, PermissionLevel>,
): EffectivePermissions {
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
const grants = new Map<string, PermissionLevel>();
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
// without it — see below.
const hasOsAccount = !!user.osUser;
for (const [key, level] of roleGrants) {
const permission = PERMISSION_BY_KEY.get(key);
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines.
if (!permission) continue;
// A confined permission touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed, always.
//
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
if (permission.kind === 'confined') {
if (!hasOsAccount) continue;
grants.set(key, level);
continue;
}
if (permission.kind !== 'app') continue;
grants.set(key, level);
}
return { isOwner: false, grants };
}
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
if (!userId) return empty;
@@ -73,39 +124,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
const user = await getUserById(userId);
if (!user) return empty;
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
const grants = new Map<string, PermissionLevel>();
for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
// without it — see below.
const hasOsAccount = !!user.osUser;
for (const [key, level] of await grantsForRole(user.role)) {
const permission = PERMISSION_BY_KEY.get(key);
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines.
if (!permission) continue;
// A confined permission touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed, always.
//
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
if (permission.kind === 'confined') {
if (!hasOsAccount) continue;
grants.set(key, level);
continue;
}
if (permission.kind !== 'app') continue;
grants.set(key, level);
}
return { isOwner: false, grants };
return resolveEffectivePermissions(user, await grantsForRole(user.role));
} catch {
// A transient database error must never grant anything. Deny, and let the next request retry.
return empty;
@@ -123,7 +142,16 @@ export async function isApiRequestAllowed(
method: string,
path: string,
): Promise<{ allowed: boolean; reason?: string }> {
const { isOwner, grants } = await getEffectivePermissions(userId);
return decideApiRequest(await getEffectivePermissions(userId), method, path);
}
/** The decision, given an already-resolved account. Pure, and where every rule below actually lives. */
export function decideApiRequest(
effective: EffectivePermissions,
method: string,
path: string,
): { allowed: boolean; reason?: string } {
const { isOwner, grants } = effective;
if (isOwner) return { allowed: true };
const permission = permissionForApiPath(path);
@@ -170,7 +198,12 @@ export async function isApiRequestAllowed(
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
*/
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
const { isOwner, grants } = await getEffectivePermissions(userId);
return decideWsProvider(await getEffectivePermissions(userId), provider);
}
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
export function decideWsProvider(effective: EffectivePermissions, provider: string): boolean {
const { isOwner, grants } = effective;
if (isOwner) return true;
const permission = permissionForWsProvider(provider);
+4 -1
View File
@@ -147,7 +147,10 @@ const CORE_REGISTRY: Permission[] = [
// to present the authority the account already has, so denying it would only force the holder to keep
// using a password in places a password should not go. What a key can then DO is decided by the same
// permission checks as any other request from that user; nothing here widens them.
api: ['/user', '/dock', '/api-keys'],
// `/theme-favorites` sits here rather than on `/themes`, which is unauthenticated so the sign-in screen
// can paint. A starred theme is the same shape as a dock preference: the caller's own taste, granting
// nothing, and not a sensible thing to deny an account that can already choose a theme.
api: ['/user', '/dock', '/api-keys', '/theme-favorites'],
routes: ['/settings/profile'],
},
{
@@ -19,6 +19,8 @@ type ResumeState = {
messages: ChatMessage[];
total: number;
offset: number;
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
isCommander: boolean;
};
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
@@ -104,6 +106,9 @@ const ChatPanelInner = ({
promptPrefix={promptPrefix}
panelId={panelId}
sessionId={sessionKey ?? undefined}
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
// set it via `context`; either being true is enough — see `manager.isCommander`.
isCommander={resume?.isCommander === true}
{...chatContext}
/>
</div>
@@ -221,6 +226,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
messages: detail.messages as unknown as ChatMessage[],
total: detail.total,
offset: detail.offset,
isCommander: detail.isCommander === true,
});
} catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
@@ -23,6 +23,14 @@ type EmbeddableChatProps = {
chat?: UseChatType;
context?: string;
contextId?: string;
/**
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
*
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
*/
isCommander?: boolean;
onMessageComplete?: () => void;
};
@@ -49,6 +49,8 @@ type UseEmbeddableChatParams = {
chat?: UseChatType;
context?: string;
contextId?: string;
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
isCommander?: boolean;
};
/**
@@ -611,6 +613,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
// composer, so the option was never rendered anywhere at all.
context: params.context,
/*
One answer to "is this a commander", from either of the two things that can know.
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
would lose the brand-new composer, which has no session yet.
*/
isCommander: params.context === 'command-hq' || params.isCommander === true,
messages,
historicalCount,
streamingText,
@@ -199,12 +199,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
promise the platform has to keep rather than only a sentence in the prompt.
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
earlier. `manager.isCommander` is true when either the screen or the session says so.
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
server. Only the radio button was missing, so the feature looked unbuilt.
*/
isCommander={manager.context === 'command-hq'}
isCommander={manager.isCommander}
onSubmit={(intent, preamble, armWatcher) => {
// Claimed by the next job to start, which is how the preview tells this one apart from the
// background jobs an agent starts on its own.
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Plus, Radar } from 'lucide-react';
import { Plus } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
@@ -55,23 +55,21 @@ export const CommandHqCategories = () => {
};
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
<Radar className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Command HQ</div>
<div className="truncate text-xs text-muted-foreground">Watchers &amp; long-running jobs</div>
</div>
</div>
/*
One scrollable list, and a heading that does not scroll with it.
<nav className="flex flex-col gap-0.5 px-2 pb-3">
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
</nav>
This was a nav COLUMN with the screen's branding at the top — a 9px Radar tile, "Command HQ", and
"Watchers & long-running jobs" — costing about seventy pixels. All three are gone: the panel now sits
at the FOOT of the left column rather than owning it, so the vertical room is the whole budget, and the
screen's own header already says Command HQ two inches above. Repeating it in a filter panel was
branding where a list should be.
<div className="mt-2 flex items-center justify-between px-4 pb-1">
`All commanders` and `Uncategorised` are inside the scroll box with the categories rather than in a
separate nav above it. They are the same kind of thing — a filter over the list — and splitting them
into two nav blocks made the second look like a different mechanism.
*/
<div className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
<button
type="button"
@@ -83,7 +81,9 @@ export const CommandHqCategories = () => {
</button>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
{categories.map((category) => (
<Row
key={category.id}
@@ -140,7 +140,16 @@ export const CommandHqGrid = () => {
</Link>
</div>
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3">
{/*
One per line, full width — a grid of one.
It was `sm:grid-cols-2 xl:grid-cols-3`, which was reasonable when this panel owned 34% of the screen
and is not now that it owns part of a 24% column: two columns of card inside it left each one too
narrow for a title, so every card truncated and the cost line wrapped. Nothing about the card changed
— same rounded pill, same `p-4`, same `gap-3` between them — it simply stops being asked to share a
row.
*/}
<div className="flex flex-col gap-3 p-4">
{commanders.map((commander) => (
<Card
key={commander.id}
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
title?: string | null;
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
partCount?: number;
/**
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
*
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
* to decide it from the route, so the same commander opened in /chat quietly lost it.
*/
isCommander?: boolean;
};
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
+2
View File
@@ -9,3 +9,5 @@ export { useThemeAsset } from './useThemeAsset';
export { useThemeKeys } from './useThemeKeys';
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
export type { Theme, ThemeId } from './themes';
// Ordering helpers for the pickers — pure, and tested in rotate.test.ts.
export { rotateToSelected, favouritesFirst, groupByFamily } from './rotate';
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test';
import { favouritesFirst, groupByFamily, rotateToSelected } from './rotate';
const ids = (list: Array<{ id: string }>) => list.map((t) => t.id);
const list = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
describe('rotating to the selected theme', () => {
test('puts the selected one first and wraps the rest behind it', () => {
expect(ids(rotateToSelected(list, 'c'))).toEqual(['c', 'd', 'a', 'b']);
});
// The property that makes this a rotation rather than a sort: nobody loses a neighbour.
test('every adjacency survives, only the starting point moves', () => {
const rotated = ids(rotateToSelected(list, 'c'));
const doubled = [...ids(list), ...ids(list)].join(',');
expect(doubled).toContain(rotated.join(','));
});
test('leaves the order alone when the selected one is already first', () => {
expect(ids(rotateToSelected(list, 'a'))).toEqual(['a', 'b', 'c', 'd']);
});
// An id from an uninstalled theme, or none chosen yet. Rotating by a guess would be worse than not.
test('leaves the order alone for an unknown or missing id', () => {
expect(ids(rotateToSelected(list, 'gone'))).toEqual(['a', 'b', 'c', 'd']);
expect(ids(rotateToSelected(list, undefined))).toEqual(['a', 'b', 'c', 'd']);
});
test('does not mutate the input', () => {
const original = [...ids(list)];
rotateToSelected(list, 'd');
expect(ids(list)).toEqual(original);
});
});
describe('favourites first', () => {
test('lifts favourites, keeping the order within each half', () => {
expect(ids(favouritesFirst(list, new Set(['d', 'b'])))).toEqual(['b', 'd', 'a', 'c']);
});
test('changes nothing when there are none', () => {
expect(ids(favouritesFirst(list, new Set()))).toEqual(['a', 'b', 'c', 'd']);
});
// Composed with the rotation: the theme you are wearing leads its half.
test('composes with the rotation', () => {
expect(ids(favouritesFirst(rotateToSelected(list, 'c'), new Set(['c', 'a'])))).toEqual(['c', 'a', 'd', 'b']);
});
});
describe('grouping by family', () => {
const themed = [
{ id: 'duck-dark', name: 'Duck Dark', family: 'Duck' },
{ id: 'cat', name: 'Catppuccin', family: 'Catppuccin' },
{ id: 'duck-light', name: 'Duck Light', family: 'Duck' },
] as never as Parameters<typeof groupByFamily>[0];
test('keeps families in first-seen order, so a rotation decides them', () => {
expect(groupByFamily(themed).map(([family]) => family)).toEqual(['Duck', 'Catppuccin']);
});
// Members of a family separated by the rotation still collect together — the alternative is the same
// family heading appearing twice.
test('collects a family split by the rotation', () => {
const groups = groupByFamily(themed);
expect(groups[0]![1].map((t) => t.id)).toEqual(['duck-dark', 'duck-light']);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { Theme, ThemeId } from './themes';
/**
* The theme list, rotated so the one you are wearing is first.
*
* With 200+ themes the list is far taller than any container, and the theme you have on is the anchor for
* every decision you are about to make — "what did I just come from", "what is next to this". Sorting the
* selected one to the top would answer the first question and destroy the second, because it takes that
* theme out of its family and leaves a hole where it was.
*
* A ROTATION keeps every neighbour: the order is unchanged, the starting point moves. Duck Dark's
* neighbours are still Duck Light and whatever followed it; they have simply wrapped around behind you.
*
* The registry order is the ordering to preserve — families sit together in it, and that grouping is the
* only structure a list of two hundred names has.
*/
export function rotateToSelected<T extends { id: ThemeId }>(list: readonly T[], selected: ThemeId | undefined): T[] {
if (!selected) return [...list];
const at = list.findIndex((entry) => entry.id === selected);
// Not present: an id from a theme that has been uninstalled, or none set yet. Leave the order alone
// rather than rotate by a guess.
if (at <= 0) return [...list];
return [...list.slice(at), ...list.slice(0, at)];
}
/**
* Favourites first, then everything else — each half keeping the rotation it was given.
*
* Two-tier rather than a filter, because hiding the rest makes the list unable to answer "what else is
* there". A filter is a separate, explicit act; this is the default shape.
*/
export function favouritesFirst<T extends { id: ThemeId }>(list: readonly T[], favourites: ReadonlySet<ThemeId>): T[] {
if (favourites.size === 0) return [...list];
const starred: T[] = [];
const rest: T[] = [];
for (const entry of list) (favourites.has(entry.id) ? starred : rest).push(entry);
return [...starred, ...rest];
}
/** Grouped by family for the dropdown, over an already-ordered list. Families appear in first-seen order. */
export function groupByFamily(list: readonly Theme[]): Array<[string, Theme[]]> {
const groups = new Map<string, Theme[]>();
for (const theme of list) {
const existing = groups.get(theme.family);
if (existing) existing.push(theme);
else groups.set(theme.family, [theme]);
}
return [...groups.entries()];
}
+64 -6
View File
@@ -10,6 +10,7 @@ import type { ThemeId } from './themes';
* ctrl+shift+→ next theme, from anywhere, no UI
* ctrl+shift+← previous theme
* ctrl+shift+space open the list, then browse it
* ctrl+shift+f star or unstar the current theme, from anywhere
*
* One modifier for all three, which took some choosing. `ctrl+alt+arrows` switches WORKSPACE on GNOME and
* `ctrl+alt+l` locks the screen, so ctrl+alt is unusable for this. `shift+alt` is free on Linux but is the
@@ -20,6 +21,8 @@ import type { ThemeId } from './themes';
* action invited the guess that `ctrl+shift+j` moved down — which opens the browser console instead. One
* spelling, no guessing.
*
* Inside the list `f` stars or unstars what is FOCUSED, so a browse and a shortlist are one pass.
*
* Inside the list the vim keys stay, UNMODIFIED — `j`/`k`/`l`/`h` alongside the arrows. No modifier means
* no collision with anything the browser owns, and this is a vim person's application.
*
@@ -38,8 +41,22 @@ import type { ThemeId } from './themes';
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
* because the behaviour belongs to themes, while a dialog would drag a component library in behind it.
*/
export function useThemeKeys() {
type ThemeKeysOptions = {
/**
* Star or unstar the focused theme — bound to `f` while the list is open.
*
* Passed in rather than owned here: favourites are per ACCOUNT and live behind an API, which is app
* knowledge. This hook owns keys and selection and nothing else.
*/
onToggleFavorite?: (themeId: ThemeId) => void;
};
export function useThemeKeys(options: ThemeKeysOptions = {}) {
const { themeId, setTheme, themes } = useTheme();
// A ref so the window listener never needs re-binding when the callback identity changes — which it does
// on every render, since it closes over a mutation.
const onToggleFavorite = useRef(options.onToggleFavorite);
onToggleFavorite.current = options.onToggleFavorite;
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState<ThemeId>(themeId);
const startedOn = useRef<ThemeId>(themeId);
@@ -61,10 +78,24 @@ export function useThemeKeys() {
const onKeyDown = (event: KeyboardEvent) => {
const { themeId: current, focused: focus, open: isOpen } = state.current;
// Toggle the list. `ctrl+alt+space` still works — it is what this shipped with an hour ago and
// fingers are quicker to learn than to unlearn.
// Toggle the list. `ctrl+alt+space` is still accepted alongside `ctrl+shift+space` — it is what this
// shipped with first, and fingers are quicker to learn than to unlearn.
if (event.ctrlKey && (event.shiftKey || event.altKey) && event.code === 'Space') {
event.preventDefault();
/*
Two problems solved by two lines, and both were reported from the same keystroke.
`stopPropagation` stops the header's dropdown ALSO opening. The theme pill is a Radix
DropdownMenuTrigger, and a focused trigger opens on Space — so if the pill happened to have focus,
the chord opened the overlay and the menu behind it. React attaches its handlers at the root, which
bubbles before window, so the trigger saw the key first no matter what this listener did with it.
This listener now runs in the CAPTURE phase and stops the event dead.
The blur stops the overlay opening with focus still in a text field, which made j/k/l do nothing:
the guard below correctly refuses to steal keys from an input, and the input never lost focus.
*/
event.stopPropagation();
(document.activeElement as HTMLElement | null)?.blur?.();
setOpen((was) => {
if (!was) {
startedOn.current = current;
@@ -83,6 +114,22 @@ export function useThemeKeys() {
const target = event.target as HTMLElement | null;
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
/*
Star or unstar the CURRENT theme, from anywhere, list open or not.
Not behind the `typing` guard, unlike the arrows beside it, and the difference is the point: word
selection is what `ctrl+shift+←/→` collides with inside a text field, and `ctrl+shift+F` collides
with nothing — it types no character and no browser claims it. Guarding it would only mean the
binding fails in the composer, which is where you are when a theme annoys you.
*/
if (event.ctrlKey && event.shiftKey && (event.key === 'f' || event.key === 'F')) {
event.preventDefault();
event.stopPropagation();
// The one you are WEARING, not the one focused in the list — this fires with no list open.
onToggleFavorite.current?.(current);
return;
}
// Cycle from anywhere, list open or not. Arrows only — see the note above.
if (event.ctrlKey && event.shiftKey && !typing) {
if (event.key === 'ArrowRight') {
@@ -97,9 +144,18 @@ export function useThemeKeys() {
}
}
if (!isOpen || typing) return;
// Only the GLOBAL bindings above defer to a text field. Once the list is open there is no input in
// it, focus was blurred to open it, and a `typing` check here is what made every vim key dead when
// the overlay was opened from the composer.
if (!isOpen) return;
switch (event.key) {
case 'f':
case 'F':
// Star or unstar what is focused, without moving or applying it.
event.preventDefault();
onToggleFavorite.current?.(focus);
return;
case 'j':
case 'J':
case 'ArrowDown':
@@ -140,8 +196,10 @@ export function useThemeKeys() {
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
// CAPTURE, so the chord is claimed before React's root-level handlers see it — see the note above about
// the dropdown trigger opening alongside the overlay.
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [setTheme, step]);
return { open, setOpen, focused, setFocused };
@@ -2,7 +2,7 @@
id: known-bugs/a-commander-opened-from-chat-loses-its-context
title: 'A commander opened from /chat loses its commander controls'
theme: known-bugs
status: open
status: done
created: 2026-08-21
date_source: stated
order: 4
@@ -28,3 +28,16 @@ closes this and makes the same thing work in any future surface that renders a c
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
option and a component prop, neither reading the other's — which is what hid the original bug twice.
---
**Fixed 2026-08-22.** `GET /chat/sessions/:id` now reports `isCommander`, derived from the session's own cwd
by the same `isCommandersDir` the list and `/chat/live` already used — so the answer travels with the
conversation instead of with the screen showing it.
The composer reads ONE value, `manager.isCommander`, true when either source says so. Both are needed and
neither is sufficient: the SCREEN knows when Command HQ starts a commander, before a session or a transcript
exists for any route to report on, and the SESSION knows once it does, wherever it is opened. Screen-only
was this bug; session-only would have lost the brand-new composer.
Verified on a real commander: `172cdd8c` resolves `cwd=…/commanders` and `isCommander=true`.
@@ -1,8 +1,8 @@
---
id: multi-user/assertpermissiontotality-checks-the-wrong
title: "`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13"
title: '`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13'
theme: multi-user
status: open
status: done
created: 2026-08-13
date_source: stated
order: 12
@@ -17,3 +17,27 @@ a dead socket. But the boot check that exists to stop exactly this cannot see it
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14.
---
**Fixed 2026-08-22.** Half of this item was already stale and half was still exactly true, which is worth
separating because the stale half is what would have made someone close it.
Stale: the eight red tests in `registry.test.ts` are green, and have been since cliamp was deleted on
2026-08-15 — that removed the live instance of the drift along with the routes.
Still true: the two lists were still two lists. `assertPermissionTotality` read `Object.keys(handlers)`, the
map of implementations, while Bun served a hand-written route table twenty lines away. The comment beside
the check claimed neither could be a stale copy of the other, and that was simply false.
The fix is one declaration. `WS_ROUTES` maps path → provider, the Bun route table is GENERATED from it, and
the boot check reads it — so a socket route cannot exist without being checked. Two smaller guards came with
it: a route naming a provider with no handler now refuses to boot with the path in the message, and the
provider union that was inline in `upgradeWs` is named (`UpgradableProvider`) so the route table typechecks
against what that function can actually serve.
`sidecar` and `dev-proxy` are listed explicitly as served-but-not-in-WS_ROUTES, because they upgrade through
their own handlers. Named rather than omitted, so their absence cannot read as an oversight.
Verified by running the check against the real prefix lists: the served set passes, and adding one unclaimed
provider is refused with `websocket provider 'cliamp-audio' is served but no permission claims it`.
@@ -1,8 +1,8 @@
---
id: multi-user/permissions-authorize-ts-has-no-automated
title: "`permissions/authorize.ts` has no automated tests"
title: '`permissions/authorize.ts` has no automated tests'
theme: multi-user
status: open
status: done
created: 2026-08-20
date_source: assumed
order: 11
@@ -12,3 +12,32 @@ order: 11
registry functions and the totality check; the resolver that does the owner bypass, the grant
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell.
---
**Done 2026-08-22. 27 tests, no mock and no database.**
The reason it had none was structural: every path through the resolver needed two database calls, and the
only file in this repository that mocks `officerdb` leaks its stub into whatever runs after it — so the
obvious approach would have traded one untested file for a flaky suite.
So the decisions were split from the fetching. `resolveEffectivePermissions(user, roleGrants)`,
`decideApiRequest(effective, method, path)` and `decideWsProvider(effective, provider)` are pure; the async
functions keep the caching, the fail-closed catches and the shape callers use. The tests use REAL registry
keys throughout, deliberately — a suite built on invented permissions keeps passing after a rename, which is
the failure an authorization table can least afford.
What is pinned, beyond the happy paths: the owner carries no grants to consult; core is `write`
unconditionally; a confined grant is dropped without a Linux user, and all three confined permissions drop
together; execution and admin grants in the table are ignored even though no CHECK stops the row existing;
an unknown key is ignored without disturbing what is legitimately held; self-service is exact in both method
and path; `readOnlyWrites` is relative to the permission's prefix; a socket needs no level; and an
unauthenticated caller is refused every door.
Two things deliberately NOT covered, and said so in the file rather than left to be noticed: `ownerOnly`,
because no permission declares it yet and covering it would test the fixtures rather than the registry; and
the grant cache, whose contract is a claim about call sites rather than logic.
Found while writing them: `readOnlyWrites` paths are relative to the permission's api prefix, and getting
that wrong fails as "no permission covers this path" rather than as a level error — a misleading message for
a misleading path.
@@ -0,0 +1,33 @@
---
id: shell/all-keyboard-shortcuts-configurable
title: "All keyboard shortcuts configurable"
theme: shell
status: open
created: 2026-08-22
date_source: stated
order: 3
---
**All keyboard shortcuts configurable.** Stated 2026-08-22 as coming soon. Every binding in the app
should be something the owner sets, not something the code decides.
**There is nothing central to configure today.** Nine separate `keydown` listeners are registered across
the app — the code editor, the file browser and its grid and dialogs, the terminal, the chat launcher,
the embeddable chat, the file viewer's editor, and Spotlight — each owning its own keys, in its own
component, with no registry and no way to see the set at once. So the first half of this is an
inventory, and the second is a registry the bindings are read FROM rather than written INTO the
components.
Spotlight is the cautionary tale and it is already written down in `Spotlight.tsx:14`: the launcher went
`ctrl-space` → `shift-space` → `alt-space`, three changes in a day, because each chord turned out to
collide with something — `shift-space` types a space, which is why that version needed a focus check
that `alt-space` does not. **A hard-coded chord costs a code change and a release every time it is
wrong**, which is the argument for this item in one sentence.
Worth deciding with the config format: a binding that conflicts with another must be refused or flagged
at the moment it is set, since the failure otherwise is silent and shows up as "that key does nothing".
And a browser cannot claim every chord — some are the browser's or the window manager's — so the UI has
to be able to say a binding was rejected by something above it.
Storage lands on `todo/settings/` — this is exactly the kind of preference that should be a portable
file rather than a row.
@@ -0,0 +1,22 @@
---
id: shell/ctrl-shows-every-keyboard-shortcut-in-a-modal
title: "Ctrl+? shows every keyboard shortcut in a modal"
theme: shell
status: open
created: 2026-08-22
date_source: stated
order: 4
---
**Ctrl+? shows every keyboard shortcut in a modal.** Stated 2026-08-22, alongside making the bindings
configurable. One chord, and the whole set is on screen.
It is the same work seen from the other end, and the order matters: **the modal is trivial once a
registry exists and impossible to keep honest before that.** Today the bindings live in nine separate
`keydown` listeners across the app, so a hand-written list of shortcuts would be a second copy of
something nobody updates — a help screen that lies is worse than none. Build it as a render of the
registry from `shell/all-keyboard-shortcuts-configurable`, never as its own list.
Two things to get right when it is built: the modal should show which bindings apply **here** versus
everywhere, since most of the nine are scoped to one app or panel; and it is the natural place to
surface a conflict or a chord the browser refused, because that is when someone is looking.
@@ -0,0 +1,26 @@
---
id: shell/revamp-the-menus-toward-an-omarchy-like-shape
title: "Revamp the menus — spotlight, themes — toward an Omarchy-like shape"
theme: shell
status: open
created: 2026-08-22
date_source: stated
order: 5
---
**Revamp the menus — spotlight, themes — toward an Omarchy-like shape.** Flagged 2026-08-22 as a plan
the owner has and has deliberately not worked out yet: *"i have plans… i think some time soon we will
take a more Omarchy like approach but i am too happy right now to think about that."*
**Recorded so it is not lost, not so someone can start it.** The shape is his and does not exist on
paper yet — ask him before designing anything here.
What is already true and will be the raw material: Spotlight exists and opens on `alt-space`
(`Spotlight.tsx`), the dock is full and cannot grow, and all 22 themes are generated from Omarchy's own
palettes by `scripts/generate-themes.ts` — so the borrowing has already started on the colour side and
this is the same borrowing extended to the menus. `shell/dock-effects-and-auto-hide` and the launcher
item are both part of whatever this becomes rather than separate work.
Note the two keyboard items in this theme are probably prerequisites rather than neighbours: an
Omarchy-like shell is keyboard-driven first, and that needs bindings to be configurable and discoverable
before the menus are rebuilt around them.