Commit Graph
295 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 81f32a1faa spotlight: ctrl-space, type a few letters, go
The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you
actually use rather than a complete one — which is the other half of this and is not done yet.

It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed,
permission-filtered answer to "what can this account open here": the shell's screens, plus whatever
installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer
to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in
Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited.

Settings sections are added, because they are what people hunt for and the dock deliberately does not
carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to
IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings
screens to the shell to save five lines.

Matching is subsequence, not substring, and lives in its own file so it can be tested with strings:
"chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode
what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with
shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and
paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests
cover the ORDER rather than the matching, because returning the right set is easy and returning it with
the right thing first is the product.

ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not
cmd-k either — that belongs to the browser on some platforms and to text fields on others.

Mounted beside the header and the dock rather than inside the content region, so it still gets you off
a screen that is maximised, erroring or mid-navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:48:46 +00:00
pastilhasandClaude Opus 5 45d4823238 browser pairing and its extension zip leave the platform
The last two pieces the extraction missed. IntegrationsSettings/BrowserRelay.tsx drove the plugin's
pairing from a platform screen, calling /browser/relay-token and working only because the plugin
happens to serve that path — the same coupling removed everywhere else this morning.

public/browser-relay-extension.zip was a hand-built snapshot that had drifted 58 lines behind the
extension it claimed to be: built in August from something predating a February commit, never rebuilt,
and missing the multi-session recovery path. The plugin builds the archive from its own directory per
request now, so there is nothing left to fall behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:20:38 +00:00
pastilhasandClaude Opus 5 e44c0c7665 the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two
panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history,
and nothing regressed, because none of it had been running.

What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches
a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the
browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so
the registry stops describing one thing in the other's words, and it drops the /browser frontend route
it could no longer serve.

Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering
panels through appType placeholders — a plugin supplies its own route and panels through
Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover
from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no
longer had: dead at runtime, invisible to the compiler.

The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where
it lives now. It was written for whoever did this extraction, and this is that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:05:11 +00:00
pastilhasandClaude Opus 5 43aeffd9d3 api keys: revoked ones stop filling the screen, and can be forgotten
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks
like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a
list whose job is "which of my keys still work" answered with a wall of dead ones.

Nobody decided that. Revoke was implemented and the list was never taught the difference.

Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys"
toggle rather than merely dimmed, because on any account in use for a while they outnumber the live
ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an
account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a
bug in the list.

DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not
null` clause is in the statement rather than checked before it, so there is no ordering in which a
live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key
cannot be honoured again either way, since authentication matches a hash and a deleted row has none.
What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it
was killed — which is why it stays a second, deliberate step rather than what revoke does.

Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key
works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:22:29 +00:00
pastilhasandClaude Opus 5 c4042886fc api keys live in profile, not integrations
Reported by the owner, who went looking for them in user management and then in profile, and found
them in neither. They were under Integrations.

A key is not an integration with anything. It is a second way to present the authority the account
already has, and the permission registry already says exactly that: `/api-keys` is `core`, sitting
beside `/user` and `/dock`, with the comment "a key is not new authority". Profile, next to the
password it stands in for, is where somebody looks.

Moved rather than duplicated, and the route moves with it — /settings/profile/api-keys. Nothing
linked to the old path, so there is no redirect to keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:36 +00:00
pastilhas 7b975784b8 atelier providers move into the plugin, where the rule already said
`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: "A plugin contributes panels, not settings sections, so it now
lives inside the email workspace itself."

the atelier extraction brief said the opposite, and the brief was mine and
wrong. sonnet found the contradiction, recorded it in BLOCKERS.md under a
heading that says "against a direct precedent", and followed the instruction
anyway — which is what it was told to do. so the wrong call got implemented
carefully rather than caught.

settings in the platform mean the plugin is not self-contained: it cannot be
configured on an older platform, and uninstalling leaves a dead section in the
settings screen for someone to delete by hand. neither is true of a panel.

the section, its import and its now-unused icon are gone from here. the screen
is `plugins/atelier/web/Providers/` and a fourth panel.
2026-08-20 17:58:06 +00:00
pastilhas 7579620f81 atelier settings: enter saves the api key
the key field was an Input and a Button side by side, outside a form, so Enter
did nothing. pasting a key and pressing enter is the strongest habit there is
in a password field, and when it silently does nothing it reads as "the key
did not save" — which is the one thing this screen must never be ambiguous
about.

TODO.md tracks nineteen panels with the same defect. this is not the twentieth.

everything else in the screen was already right and is left alone: type=password,
autoComplete=off, the server never returning a key, and the field starting
empty with a placeholder that says whether one is set.
2026-08-20 16:39:51 +00:00
pastilhas 6d0b2743a5 settings: Atelier providers under Integrations, per plugin decision
decision 2 of the atelier extraction: providers (which adapter, which model, whose key) becomes an
Officer settings screen rather than a panel, since it is owner-level configuration in the same shape
as the Apify token beside it. Talks to /api/atelier/providers over HTTP through useClient() — no
import from plugins/atelier/ in either direction.

recorded in BLOCKERS.md: this goes against IntegrationsSettings' own comment from the email
extraction five days earlier ("a plugin contributes panels, not settings sections"). Followed the
pre-made decision anyway per the kickoff prompt's own instructions, and wrote down the
contradiction rather than picking one silently.
2026-08-20 04:02:29 +00:00
pastilhas 17243c2201 rescan: say when a skill exists but is not offered
the button counts skills — one directory with a SKILL.md is one skill. it now
also reports how many are actually OFFERED to an agent, which is a different
number: the loader skips any without a `description`, because a line that does
not say when to reach for something costs context in every conversation and
earns nothing.

so a skill can be present, listed, editable in the UI, and invisible to every
agent, with nothing anywhere saying why. the toast names the gap and the reason
rather than leaving two numbers to be noticed by whoever thinks to compare them.
2026-08-20 01:10:16 +00:00
pastilhas ab214d1a9e rescan: count affordances, and say what a rescan cannot do
the header's rescan walks ITEM_TYPES, and affordances are not one — no
directory per item, no canonical file — so they were invisible to it. write a
new affordance, press the button, and nothing in the response mentions it.

counted by LOADING them rather than counting .md files, so the number means
"affordances the agent will actually receive" and not "markdown files
present": one with no body is skipped by the loader and should not be reported
as though it were working.

counting is as far as this can honestly go, and the toast now says so.
affordanceBlock() is read when a chat session is CREATED, so a new affordance
is already live without a server restart — for the NEXT session. nothing can
retrofit one into a conversation already running, because its system prompt
was assembled when it started. the button's value here is "the file is on disk
and the platform can see it", which was the part genuinely unknowable from the
UI.

also creates the affordances directory, so it is discoverable on a machine
that has never had one, the same way the item dirs already are.
2026-08-19 23:35:11 +00:00
pastilhasandClaude Opus 5 b14b1bf678 green that means "healthy" becomes the success token
Fifteen sites across thirteen files where a raw green stood in for a
semantic state: connection dots, health lights, completed-job ticks, the
copied-to-clipboard check, the installed badge, the checksum-matched line.
All of them meant "on / connected / succeeded", and matched each other only
because several people happened to pick the same palette number.

The values do move: --success is #16a34a where green-500 was #22c55e and
emerald-500 #10b981. That is the point — one token, so a theme can change
its mind once, and under plain.css (the leak-detection theme, which greys
--success to #404040) these now go colourless instead of staying green.

Left alone, deliberately: the confetti palette and the Start button in
start-confetti-button, the duck-avatar debug HUD, MetricCard's up/down
delta ramp, CommandBlock's check on its hardcoded dark block, the
react-email template, and the plain.css comment that names the anti-pattern.
Reds are untouched — that is a separate pass, so some ternaries now read
bg-success : bg-red-500 until it happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:35:01 +00:00
pastilhasandClaude Opus 5 0a9649a12b brand: the drawn Officer wordmark, unskewed
Replaces the old mark everywhere it is referenced — the header reads it through
--logo-src and every theme points at the same file, and the auth landing page and
the emailer read it directly, so one asset covers all three.

The CSS skew goes with it. That is where the old logo's slant came from, and
applying it to this one shears letterforms that were kerned pair by pair by hand
and signed off as finished. See landing/assets/brand/WORDMARK.md for the
construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:23:18 +01:00
pastilhasandClaude Opus 5 73cdc2cb77 bell: notify a chat's page and list row, any chat, any time
Client half of the per-chat notification preference — DetailBar gets a bell
dropdown (every end of turn / only when input is needed / none), a global
useChatNotifications hook holds one connection to /api/chat/notify/ws for
the whole session so a background chat can still ring the bell and fire a
browser Notification, and the session list shows a badge on any row set to
notify. The server side (schema, the notify-delivery socket, the sidecar
signal at turn-end/plan-gate/question-gate) landed already in 5cba1b65,
swept in from a concurrent session sharing this working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:32:06 +00:00
pastilhasandClaude Opus 5 b27410a6fc sign in with an email or a username
Only an address worked, though usernames are already unique and already
validated as /^[a-zA-Z0-9._-]+$/ — which is what makes this safe: no `@`, so
the two namespaces cannot overlap and there is nothing to guess. An `@` decides
which lookup runs, and it decides it exactly.

The field is still called `email` on the wire, because every client sends that
name and renaming it would break them for nothing.

Fixed a latent bug while passing: the JWT took `email` from the REQUEST rather
than the row. Identical while an address was the only way in — and a username
in the `email` claim the moment it was not, leaving the DAV door, the managed
home path and everything else keyed on email looking up a user who does not
exist.

The login field is `type="text"`, not `type="email"`, or the browser refuses to
submit a username and validates someone out of an account they have.
`autoComplete="username"`, which is what password managers store beside a
password for a field that takes either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:10:39 +00:00
pastilhasandClaude Opus 5 994de45296 wrap inputs in real forms, so enter submits instead of doing nothing
24 panel and screen files had their fields sitting in a <div> instead of a
<form> — typing a value and pressing Enter did nothing, and the button was
the only way in. Fixed the sites that are a real form, a single field with
an obvious action, or a multi-field editor; left search/filter boxes and a
few genuinely ambiguous sites alone (see TODO.md for the full breakdown).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:51:58 +00:00
pastilhasandClaude Opus 5 d49776307d gitea, extracted into plugins/gitea — the platform half
Eleventh extraction, and the only one that deleted its UI instead of moving it.
Seventeen files under apps/Gitea/ reimplemented a fraction of an interface
Gitea already has; the plugin renders the real one in an iframe over a
same-origin proxy.

Deleting rather than moving removed a platform change nobody now has to build:
useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a
plugin gets two, so an as-is extraction would have required teaching the plugin
system to declare extra route patterns. Nothing inside an iframe needs a route.

Nothing working was lost. The router had been commented out of hono.ts since
2026-08-13 with api: [], and officer-gitea was running nowhere — the screen
rendered and every request 404'd, like soulseek, jellyfin and invoices before
it.

Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS,
the only installable feature that was, on the reasoning that it fronts a remote
instance and installs nothing locally. As a plugin its tile arrives from
assets/icon.png like every other — which is also why the icon added to
public/icons/ this morning was reverted rather than kept.

'gitea' stays in the ServiceName union: the connection lives in core
service_connections, and gitea is the reason that table has its "member rows
carry only a token and inherit the owner's URL" shape at all.

registry.test.ts's grantable-permission test named gitea as "the case this was
built for"; a plugin's permissions are registered at install and are never in
that compile-time list, so it now names a core one. catalogue.test.ts
re-anchored to vnc/notify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:59:10 +00:00
pastilhasandClaude Opus 5 d8cccad84c Revert "dock: Gitea gets the teacup"
This reverts 061e2137.

The artwork is right, the location is not: gitea is about to become a plugin,
and a plugin carries its own icon in assets/ — published to /plugins/<name>/ on
install, and removed when it is uninstalled. Putting it in the platform's
public/icons/ makes it the one plugin whose tile the platform ships, and it
would keep being served after an uninstall.

Added here only because the plugin repository did not exist yet. It does not
yet, either — this goes back in as plugins/gitea/assets/icon.png once it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:38:48 +00:00
pastilhasandClaude Opus 5 061e213780 dock: Gitea gets the teacup
git + tea, drawn to the icon brief: a green voxel teacup with the branch diagram
pressed into the glaze as a maker's emblem, and Officer Duck on the rim.

Core dock item with no phone app behind it, so there is an icon and no splash —
a splash belongs to something that launches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:30:00 +01:00
pastilhasandClaude Opus 5 839199bf53 dock: Calendar and Contacts carry their artwork, not a glyph
Both were redrawn to the icon brief today, so the dock shows the same voxel mark
the phone apps do rather than a lucide CalendarDays and Contact.

tile: 'bare' for the same reason a plugin's artwork gets it — the art brings its
own frame, and a coloured square behind it reads as a mistake. color stays
because the active glow, the indicator dot and the settings tint all still use
it.

These two are core rather than plugins, so the files live in public/icons/
alongside the duck rather than arriving through publishAssets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:48:26 +01:00
pastilhasandClaude Opus 5 bbb16d6ed3 bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half
Tenth extraction, and the first where the blocker was a permission rather than
a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin
permissions are always kind: 'app'. mount.ts forces that and the manifest has
no field for anything else, deliberately, so a plugin cannot ask to be
restricted. Extraction therefore could not preserve admin.

Raised before moving any code, because turning "the server owner's bitcoin"
into a row in the grants list beside Photos and Music is the kind of change
nobody notices until someone ticks it.

Made anyway, and the objection inverts: every wallet query is scoped by
user_id, taken from X-Officer-User which the platform injects from the session,
so granting the permission gives an account its OWN wallet rather than access
to anyone else's — including to the owner, whose bypass covers permission
checks and not query scoping. kind: 'admin' was single-user thinking.

Named bitcoin-wallet, not wallet: appName drives route, API prefix, table
prefix and install key together, so the name should say what it is. Tables are
bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before
committing to it — no wallet table exists on any server, and db:push would
treat a rename as a drop plus a create.

registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture
and its no-selfService fixture. Now user-admin/server-admin and terminal, with
a note that a plugin's permissions are always `app` — which is exactly why this
one needed a decision rather than a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:18:44 +00:00
pastilhasandClaude Opus 5 f2c6ebaa4d calendar and contacts get dock tiles again
Their tiles were contributed by the app-store `caldav` catalogue entry, so
deleting that entry in 9c309c5a took them with it. The routes kept working
while the dock and the dock settings list went silent — the same
silently-half-working shape as the plugin permission collision, arrived at from
the other direction: there the surface existed with no route, here the route
exists with no surface.

Now in CORE_DOCK_ITEMS, which is read by BOTH DashboardLayout and
DockSettings — one list, so the dock and its settings screen cannot disagree
about what exists. That is also why the user saw both symptoms at once.

Calendar joins DEFAULT_DOCK_PATHS since it is now baseline functionality every
install has. Contacts deliberately does not: it is available in dock settings,
but two tiles for one sidecar in everyone's default dock is a crowded default
rather than a useful one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:39:15 +00:00
pastilhasandClaude Opus 5 f566fda0b2 invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the
permission reduced to api: [], so assertPermissionTotality was satisfied, the
server booted clean, and every panel request 404'd. Closes the same data hazard
as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix,
frontend route, table prefix, sidecar suffix, install key — and before
extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted
apart, which was harmless only while they were separate hardcoded strings. The
route is the part a person sees, and the permission key and label were already
`invoices`, so the API prefix was the odd one out. Cost three call sites and a
table rename to invoices_accounts, which was free only because the table
existed on no server — verified before deciding, not after.

members.test.ts re-anchored again, this time to caldav and memos: services with
no extraction planned. That assertion has now been re-pointed four times by
services leaving for plugins/, which is the whole reason installer.test.ts was
made literal earlier tonight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:47:54 +00:00
pastilhasandClaude Opus 5 aebd6ff0c0 jellyfin, extracted into plugins/jellyfin — the platform half
Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the
router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and
the permission reduced to api: [], so assertPermissionTotality was satisfied,
the server booted clean, and every panel request 404'd. Also closes the same
data hazard — jellyfin_servers was absent from the drizzle barrel, so bun
db:push would have dropped it.

No frontend call site needed repointing: mountPrefix derives /jellyfin from the
appName, which is what the router already hardcoded.

Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin
broke 18 tests, the FOURTH extraction in a row to break the same files — photos
and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts
now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it
pins planSteps and runInstall, which are installer logic, and a service leaving
for plugins/ says nothing about whether the installer still plans the right
steps. catalogue.test.ts stays the place real entries are checked well-formed.
members/catalogue/preflight re-anchored to memos, which has jellyfin's exact
shape.

The .prettierignore rule for the vendored hls.mjs moved into the plugin rather
than being left behind naming a deleted path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:17:43 +00:00
pastilhasandClaude Opus 5 b196c9cb55 soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek
had been switched off since 2026-08-13: hono.ts carried the router import and
its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and
registerProxiedPrefix was never called; officer-slskd was not in the ecosystem
at all. Because the permission entry had been reduced to api: [], the totality
check was satisfied and the server booted clean — the UI rendered and every
request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a
permission, and is silent about a feature that mounts nothing.

Also closes a live data hazard: schema.ts had the soulseek export commented
out, so the three tables were absent from the drizzle barrel and bun db:push
would have dropped soulseek_favorites, soulseek_browse_snapshots and
soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts
documented the workaround rather than the problem; deleted with the rest.

The connection stays in core service_connections under service 'slskd' while
the mount is now /api/soulseek. Those differ on purpose.

Test fixtures: slskd was canonical in three app-store tests, the third
extraction running to break them. installer.test.ts now builds a literal
fixture instead of reaching into the catalogue — it pins planSteps, which is
installer logic, and no catalogue entry is both provisioned and members:'none'
any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea.

Unrelated but mine: check:theme caught 5 palette classes I introduced in
CommandHQ earlier tonight against a baseline of 0. Converted to bg-success /
bg-warning. check:theme is not part of bun test, which is why the green suite
never showed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:55:35 +00:00
pastilhasandClaude Opus 5 f8028364f1 transmission, extracted into plugins/transmission — the platform half
Sixth extraction, straight after photos. Everything transmission now lives at
gitea.officer.dev/plugins/transmission.

Deleted, not commented: the sidecar, the api router, the Transmission app (28
files including detail/ and dialogs/), the screen, the App.tsx route pair, the
Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the
officerdev barrel re-exports, the permission registry entry, the hono.ts import
and mount comments, and the app-store catalogue entry.

No database change: transmission owns no tables. Its connection lives in
service_connections, a core table it now depends on from outside — a real
coupling, recorded in the plugin's own PLUGIN.md rather than left to be
discovered.

── Test fixtures, repointed rather than deleted ──

Transmission was the canonical `members: 'none'` fixture — a single-tenant
daemon with no user concept — across three app-store test files. slskd has the
identical shape, so the assertions moved there. Deleting them would have
quietly dropped coverage of the no-members install path along with the plugin,
which is the same trap the vault extraction hit with `members: 'invite'`.

── Vocabulary swept, per the runbook ──

Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as
the worked example of the Workspace/Panel shape. Both are plugins now, so those
pointers dangled at code no longer in the tree. Repointed at /command-hq and
/soulseek, which are core.

bunx tsgo clean. 1080 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:48 +00:00
pastilhasandClaude Opus 5 a327dfc82c photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration
already server-side, frontend already panels. Everything photos now lives at
gitea.officer.dev/plugins/photos.

Deleted, not commented: the sidecar, the api router, the db directory, the
Photos app (25 files), the screen, the App.tsx route pair, the Dashboard
barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev
barrel re-exports, the permission registry entry, both database barrels, the
hono.ts import comment, and the app-store catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── Test fixtures, repointed rather than deleted ──

Photos was the canonical app-store fixture across four test files, chosen for
being existing|provisioned with members: 'accounts'. Jellyfin has the identical
shape, so the assertions moved there — they are about the installer, not about
photos, and deleting them would have quietly dropped coverage of the provisioned
install path along with the plugin.

One loss worth naming: Immich's `provisioned` install mode went with the
catalogue entry. Docker provisioning for plugins does not exist yet, so photos
is point-at-your-own-instance until it does.

bunx tsgo clean. 1080 pass / 0 fail, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:52 +00:00
pastilhasandClaude Opus 5 a9be3a92c9 command hq: web frontend v1 — nav, grid, detail
Builds the Workspace/Panel screen the design doc's "Layout, v1" described but
nothing had implemented yet: /command-hq and /command-hq/:sessionId, three
always-mounted panels (officerdev/src/apps/CommandHQ/) — a flat category nav
filtering the grid via ?category=, a commander grid reading GET /commanders,
and a detail pane that is either an empty state, the new-commander composer, or
the selected commander's resumed transcript.

Follows the frontend route conventions in CLAUDE.md throughout: Workspace/Panel
framework (locked, three fixed panels, no standalone screen), category and
session both addressable in the URL rather than a channel, real <Link>s for
every row and card, a route pair (/command-hq + /command-hq/:sessionId) the
same shape as /chat's.

The composer reuses the exact mechanism /chat already uses to create a
commander — an ordinary turn with context: 'command-hq' via useChat/
EmbeddableChat — rather than the mobile-only POST /commanders/new HTTP route.
Once the harness reports a real transcript id the URL is replaced onto it
(useChat's own permalink writes /chat/<id>, which is the wrong screen here, so
that's done by hand). fileAsCommander always files a new commander
uncategorised — it has no way to know a category was selected in the composer
— so if one was, a client-side PATCH sets it after the fact, the same race
POST /commanders/new already resolves server-side for mobile, just resolved
here on the client instead.

The detail panel's transcript resume mirrors ChatPanelWrapper's fetch-then-
mount pattern (GET /chat/sessions/:id first, then useChat with
resumeSessionId + initialMessages + paginate) rather than reusing it directly
— Command HQ's version is simpler (no dashboard/agent-panel concerns) and
scopes rename/category/disarm through Command HQ's own PATCH route rather
than /chat's, since a commander lives in a different directory than an
ordinary chat session.

Card status is the three-state read COMMS/command-hq/mobile-4.md proposed and
the design record adopted: green running, amber armed-but-not-running (the
watcherArmed && !running pair that makes a silently-dead watcher visible),
grey neither.

Registered in AppRegistry.tsx, re-exported from officerdev's barrel (route
helpers + types, per CLAUDE.md's rule for anything an officer-web screen
needs), a CORE_DOCK_ITEMS tile (not in DEFAULT_DOCK_PATHS — opt-in via the
Dock settings screen, same as Jobs/Monitor/Activity), and a usePageTitle rule.

bunx tsgo clean, suite at its 7 pre-existing baseline failures, none of them
this feature's. NOT yet clicked through against a running server — the
composer's redirect-on-real-id, the disarm button, the category-race PATCH
have all typechecked and nothing more. That's the next real test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:41:13 +00:00
pastilhasandClaude Opus 5 b63badaf67 remove the email residue the extraction left in the platform
a sweep for remnants after 2026-08-18. nothing was broken - no dangling
import, symbol, route or permission key anywhere in src/ - but the platform
was still speaking the plugin's vocabulary in a dozen places.

the one that mattered is live code rather than prose: ChatPanelWrapper
hardcodes workspace.id === 'email' to send context: 'email', which is the
frontend half of the pair whose backend half (resolveEmailCwd) carries a
fifteen-line justification. the two ends now both say so, including that
adding a SECOND id there is the signal the platform needs a real way for a
plugin to declare a chat context.

the rest: two dead imports left by removing the settings section, an empty
'Email' type-section header, a protocol.ts section header with no members
naming the plugin's SSE design, and a queue comment pointing at
sidecar/email/sync-runner.ts - a path in another repository now.

CLAUDE.md, AGENTS.md and working-on-officer.md described email as a platform
feature, an app permission and a pm2 process. those are the files a fresh
agent reads first, so they were the highest-risk staleness in the tree.

TODO.md's whole Email section moved to the plugin's own repository. it held
OPEN items pointing at sidecar/email/store.ts, which is exactly how a leftover
list sends someone to edit files that do not exist.

the delete-account dialog promised it would remove email accounts and
playlists. both tables belong to plugins now, so that cascade only happens
where the plugin is installed - it now says 'anything an installed plugin
keeps for them' instead of naming two it cannot guarantee.

imapflow and mailparser STAY in package.json. the plugin has no package.json
of its own and resolves from the platform's node_modules, so removing them
would break the installed plugin's sidecar. the fix is giving plugins their
own dependency manifest, not deleting these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 01:01:23 +00:00
pastilhasandClaude Opus 5 98c37c4ce9 email leaves the platform for plugins/email
the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.

three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (fbb6d6c7).

both survivors now read the DISK instead. findEmailAccountName() in
data-path.ts picks the newest emails.db under an account's email_accounts/
directory - no import, no query, and nothing to throw when the plugin is not
installed. the layout was already the platform's, so this costs no coupling.
the agent keeps its email_db tool and an email-context chat still opens in the
right mailbox.

the google oauth callback no longer appends /email to the dock: a plugin's
route is the plugin's business.

app-store/catalogue.ts entry deleted, and that is the whole point rather than
tidying. permissionAvailability derives from sidecar_installs and a plugin only
ever gets a plugin_installs row, so a leftover entry makes the permission
permanently unavailable - denied route, no dock tile - on a machine where the
plugin is installed and healthy. it has bitten headscale and nearly music.

two app-store tests used email as their config-only fixture; repointed at
notify, which is the same shape.

google-auth stays in core and the plugin imports it via @@/. that direction is
allowed and google-auth knows nothing about mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:28:13 +00:00
pastilhasandClaude Opus 5 1b811dfa28 make plan mode reachable in chat
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.

verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.

canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.

sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.

plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.

not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:50:24 +00:00
pastilhas d59bc0d152 one sessions panel with three views
The /chat sidebar was two panels in a vertical 25/75 split — Live above, Sessions below — plus a directory
dropdown in the list header. Three problems that compounded: the Live panel was empty almost always, so a
quarter of the sidebar was permanently blank; the two panels had entirely disjoint indicators, so a
conversation with a turn running looked ordinary in the history; and the dropdown showed one directory at a
time, which turned "where was I working on that" into a search.

Now one panel, three tabs. Tree answers "what have I got, by project". Latest answers "what was I just
doing". Live answers "what is running". Same rows, same indicators, one place.

Tree is an accordion over /chat/pwds, each header carrying the full path from ~, the session count, and a
live count — plus New Chat here, since the tree only lists directories that already have history. A
COLLAPSED NODE ISSUES NO REQUEST: sessions are read by scanning transcripts off disk, and eager mounting
would be one scan per directory for rows nobody asked to see. `useClaudeSessions(cwd, {enabled})` is the
whole mechanism. The per-directory live count comes from the poll's own `cwd`, never from
`liveSessionTitle`, which reads a whole transcript and whose own comment warns against calling it from
anywhere hotter than a ten-second poll.

Latest needs a new server mode, `GET /chat/sessions?all=1` → `listAllClaudeSessions`. It loops directories
rather than scanning flat, because /clear chains are merged per group and a flat scan would let two
unrelated chains in different directories collapse into each other. Fetched only while the tab is showing.

Rows now carry the model, from the batched `getChatSessionModels` — one query per page, which is what that
query was added for.

THE MIGRATION IS THE DANGEROUS PART AND IT IS INVERTED. Every persisted layout still names `chat-live`, an
appType that no longer exists; `normalizeLayout` would collapse it onto the fallback and WRITE THAT BACK,
which is what made /offscale render three server pickers this morning (ed3806f9). The existing guard adopted
layouts LACKING the panel, so it had to flip to adopting layouts that HAVE it. defaultLayout.test.ts pins
both halves: the replacement must not contain the appType the guard tests for, or the effect fires on its
own output forever.

Deletes LiveSessions.tsx and PwdSelector.tsx — both replaced. Fixes the inherited bug in passing: the
"generating" Loader2 never had `animate-spin`, so the one indicator meaning "work is happening right now"
was a static icon.

View and expanded-node state go in localStorage, not the URL (a tab is a preference, and every session link
would carry it), not usePanelConfig (a NO-OP setter in a locked workspace, and /chat is locked), and not
useDashboardState (its PATCH only accepts layout-shaped objects). /chat/g/<path> still works and opens that
directory in the tree.

Untested against a running server: officer has not been restarted since these changes, and I am not
restarting it mid-conversation. `?all=1` and the model field are verified only by typecheck.
2026-08-17 17:30:44 +00:00
pastilhas 813bc200bf a session remembers which model it is using
resume a conversation and the model picker showed the first entry in the catalogue instead of the model the
session was actually on. that was invisible for as long as the first entry was `opus`, and became obvious
the day `fable` went above it — so this is a pre-existing bug my model-list change exposed, not one it
introduced.

two defects, stacked.

the value did not survive a refresh: `initialModel` came from `props.model ?? locationState?.model`, and
both are volatile — a panel channel and react-router location state. on a hard load neither exists, the
settings default is skipped (it only runs for NEW chats), and ModelSelector's fallback effect fired
`onModelChange(providerModels[0].id)`.

and even when it did arrive it could not match. a transcript records the API model; the picker offers CLI
aliases. `claude-opus-5` is in a different id space from `claude-code/opus[1m]`, and the mapping is one-way:
opus and opus[1m] both serve claude-opus-5, opusplan serves whichever model answered. read out of a real
59MB transcript, the distinct values were claude-opus-5, <synthetic>, claude-sonnet-5,
claude-haiku-4-5-20251001, claude-fable-5 — note the synthetic marker and the inconsistent date suffix.

so the transcript cannot answer "what should the picker show", and a new table does:

  chat_session_models (session_id pk, model, updated_at)

officer's own id space, so opus[1m] and opusplan round-trip. keyed on the TRANSCRIPT uuid rather than
officer's session id, because that is what the session list joins on — which is why the write waits for the
harness to announce the id rather than firing at turn start. records the EFFECTIVE model, so a refused
switch stores what answered.

not derived from chat_session_events, which does now hold model:switched: right data, wrong index. that
table holds every assistant message and is unbounded, and "latest model for these 200 sessions" would scan
far more than the question deserves. not a chat_sessions table either — a session IS its transcript, and a
row holding title and cwd would be a second source of truth for two fields that already have one.

BACKFILL RUNS FROM postdb:push, so every server picks it up on its next schema push. bun does fire pre/post
hooks for colon-named scripts — verified on 1.3.14 before building on it, because a post hook that silently
never runs is the worst outcome here. insert-only, exits 0 on every failure path (the push has already
applied; a cosmetic backfill must not make it look failed). seeded 11 of 12 transcripts on this machine.

it reads the FIRST model, which sounds wrong for a table storing the current one — but switching only
became possible today, so no older transcript can hold two, and first costs a bounded head read instead of
reading 59MB.

also: the family map returns null for mythos rather than a neighbouring family (Project Glasswing only — a
picker entry that 401s is worse than none), and ModelSelector no longer picks a default once a conversation
has started. an unset picker is the honest state when the server cannot say.

tests: the alias map against the five real transcript values, and the selector's default gated on
hasStarted. re-broke the guard to confirm it bites.

still to do: `model` on the session LIST rows, which the sessions-list revamp wants. the storage and the
batched read (getChatSessionModels) are in place for it.
2026-08-17 17:03:19 +00:00
pastilhasandClaude Opus 5 54f63b70df arrows are the only global binding
`ctrl+shift+h`/`l` are gone. The arrows do that job, and two spellings for one action is
what invited the reasonable guess that `ctrl+shift+j` moved down — which opens the
browser console.

Worth recording that the reported conflict was not one we had: nothing bound
`ctrl+shift+j`. The global letters were `h` and `l` only, and the `j`/`k` keys live
inside the list with NO modifier. But an interface that makes someone guess a wrong key
is the interface's fault, not the guess's.

Inside the list the vim keys stay, unmodified: `j`/`k`/`l`/`h` beside `↓`/`↑`/`→`/`←`.
Nothing there can collide with a browser shortcut, and this is a vim person's
application.

Verified: `ctrl+shift+l` no longer cycles, `ctrl+shift+→` does, and `j` then `l` still
browses inside the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:32:05 +00:00
pastilhasandClaude Opus 5 f492905023 one modifier, and arrows beside the vim keys
Standardised on `ctrl+shift` for all three global bindings, and added arrows everywhere
the vim keys work — `h`/`←`, `l`/`→`, `j`/`↓`, `k`/`↑` are now interchangeable.

  ctrl+shift+→  or  ctrl+shift+l      next theme
  ctrl+shift+←  or  ctrl+shift+h      previous theme
  ctrl+shift+space                    the list

── Why ctrl+shift and not the alternatives ──

Checked GNOME's actual defaults rather than reasoning from convention:

  <Control><Alt>Left/Right         switch workspace
  <Control><Shift><Alt>Left/Right  move window to workspace
  <Super><Alt>Left/Right           switch workspace

So ctrl+alt is unusable — those arrows change your workspace, and `ctrl+alt+l` locks the
screen, which is presumably why the original bindings put the letters on ctrl+shift in
the first place. `shift+alt` is free on Linux but is the keyboard-layout switcher on
Windows and `alt+space` opens the window menu.

`ctrl+shift` has no OS binding at all. Its one collision is select-by-word inside a text
field — which is now explicitly protected.

── The guard moved earlier ──

Typing was only checked once the list was OPEN. With arrows on the global cycle that was
no longer enough: `ctrl+shift+←/→` is select-by-word in every text field, and stealing it
mid-sentence to change a colour scheme would be indefensible. The check now runs before
the global cycle too.

Verified against a focused textarea: theme unchanged, and the word selection still
happened. The first run of that test reported a hijack, and the test was wrong — it
clicked a selector that matched nothing, swallowed the failure, and read body-focus as
a text field.

`ctrl+alt+space` still opens the list. It shipped an hour ago and fingers learn faster
than they unlearn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:30:33 +00:00
pastilhasandClaude Opus 5 4ecd45c4a9 themes get their own control, and the vimazing keys
Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where
a preference goes when nobody has decided it matters. It now has its own button in the
header, between the controls and the avatar, grouped by family.

── The keys, ported from the owner's vimazing ──

  ctrl+shift+l / ctrl+shift+h   cycle forward / back, from anywhere, no UI
  ctrl+alt+space                open the list
  j / k                         move the focus
  l or space                    apply the focused theme to the app BEHIND the list
  h                             back to where you started
  enter                         keep what you are looking at
  escape                        restore and close

The browse is the point, and it is why the overlay is a portal over a 55% backdrop
rather than a modal: you are not reading names, you are watching the application change.
The theme you started on is captured when the list opens, so `h` and `escape` undo a
whole browse however many previews happened inside it.

Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea,
select or anything contenteditable.

── Where it lives ──

The key handling is in the theme package — it is behaviour that belongs to themes, and
it needs no UI. The list and the button are Officer's, because a dialog would drag a
component library into a package that currently has none.

Verified by driving the real keyboard in a browser:

  start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay
  j, l -> tokyo-night (previewed) · escape -> plain, closed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:15:55 +00:00
pastilhasandClaude Opus 5 997f7e7a87 the account stores a theme, not a colour mode
Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.

`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.

The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.

── Two bugs found by testing this, both mine, both silent ──

**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.

**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.

Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:

  stored: {"colorMode":"dark"}  ->  returned: {"colorMode":"dark","theme":"duck-dark"}

── Needs a restart ──

The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.

950 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:31:22 +00:00
pastilhasandClaude Opus 5 e9cb244818 the wallpaper, the header and the mark belong to the theme
Asked whether these could follow the theme too. They are the three most visible things
on screen and were the three least reachable: `Background.tsx` hardcoded
`url('/landscape1.webp')`, the header carried `rgba(255,255,255,0.25)` and `#1d2724`
in an inline style, and the logo was an `<img src>`.

Between them that is most of what "the app looks like Officer" actually meant. The
Plain theme turned every panel white and all three stayed exactly as they were, which
is how they were found — the leak detector doing its job on its first outing.

Verified across all three themes:

  duck-dark   wallpaper=landscape1.webp  header=rgba(255,255,255,0.25)
  duck-light  wallpaper=landscape1.webp  header=rgba(255,255,255,0.25)
  plain       wallpaper=none             header=rgb(255,255,255)

── Images could not be done the obvious way ──

`--logo-image: url('/officer-logo.svg')` fails the build. Bun's CSS bundler resolves
`url()` at BUILD time and these files are served from `public/` at RUNTIME, so it goes
looking for a module that does not exist. Colours apply themselves; images cannot.

So a theme publishes a quoted PATH — an ordinary string token the bundler ignores —
and `useThemeAsset` reads it and wraps it in `url()`. One hook rather than the read
inline in two components, because the quote-stripping is the kind of detail that gets
forgotten the second time.

The logo is a `<div role="img">` with a background image rather than an `<img>`, since
CSS cannot set a `src` and a white-labelled build has to replace the mark from its
theme rather than by editing the header.

The header's title colour goes through a `.header-ink` class: `EditableTitle` accepts
a className and not a style, and the token stays the source of truth either way.

Panel radius and the ~985 raw palette usages are still outstanding — see
THEMES-NOTES.md.

tsgo clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:52:43 +00:00
pastilhasandClaude Opus 5 105766a4eb extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.

Proven both directions, because either alone would be worthless:

  Duck renders IDENTICALLY to before — checked against screenshots taken before
  anything was touched, not asserted.

  Plain renders as white with black ink, no component edits — panels turn white, the
  teal avatar turns black, the grid fills vanish.

── What moved ──

`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.

Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.

The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.

Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.

The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.

── Bug found by the Plain theme ──

`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.

── Tools ──

Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.

Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.

── Not done, deliberately ──

`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.

tsgo clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:00:38 +00:00
pastilhasandClaude Opus 5 430346e14b a restarted job outlives the server that restarted it
The other half of the same report. The pill came back after a refresh, and then a
`pm2 restart officer` killed the dev server outright — because `restartBackgroundTask`
spawned it as a plain child of the platform. A job the AGENT starts is a child of the
`officer-claude-code` sidecar, a PM2 peer, so those were never affected. The tray
therefore showed two chips that looked identical and behaved differently, and the
restart button's own help text promised the opposite of what happened.

Measured against a throwaway PM2 app rather than reasoned about, because the two
plausible causes call for different fixes — a signal to the process GROUP, or a walk
of the process TREE:

    plain `sh -c`           killed
    `setsid sh -c`          killed     <- a new session is NOT enough
    `setsid --fork sh -c`   survived

PM2 walks the tree by ppid, so a new session leaves the job a child and it is found
anyway. `--fork` is what matters: setsid forks and its parent exits at once, so the
job is reparented to init before any restart can enumerate it.

── What that costs, and how it is paid ──

The handle `Bun.spawn` returns is now the setsid parent, which exits immediately.
Waiting on it reported every job as finished the moment it started, so exit detection
moved to `watchForExit`: poll who holds the output file, and when nobody does, read
the status the wrapper recorded. That is the same liveness question Stop and Pause
already ask, so it adds no second notion of "running" — and a paused job still holds
its file, so SIGSTOP correctly reads as running rather than as an exit.

Two details that would each have been a bug:

  - A GRACE PERIOD. Between the fork and the job opening its output there is a moment
    with no holders; polling into it announces a completion for a job that never ran.
  - The command is a POSITIONAL PARAMETER to the wrapper, not interpolated into it.
    Interpolation would let a command containing quotes terminate the wrapper's own
    quoting. Tested with `echo "quoted 'both' ways"`.

A missing status file is reported as a clean finish, not a failure: that is what Stop
looks like, and "you stopped it" is nearer the truth than "it crashed".

Also corrected the button text. It said background jobs survive "because they are
their own PM2 processes" — true of agent-started jobs, false of restarted ones, and
now the wrong reason for both. They survive because they are outside this process's
tree.

── Verified ──

End-to-end through `buildHonoApp`, on a synthetic task:

    POST /restart -> 200
    holders: pid 906186 (ppid 1, session leader), pid 906188 (ppid 906186)
             probe process was 906173 — in neither chain
    quotes survived the wrapper
    durable events: task:started -> task:notification (completed), exit file "0"

My own assertion in that probe was wrong and the output is what corrected it: it
demanded ppid 1 of EVERY holder, but only the session root is reparented — its own
children hang off it, which is the point.

Not in `bun test`: nothing in the suite touches Postgres or spawns processes.

tsgo clean, 919 pass / 7 fail unchanged. Needs a server restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:41:36 +00:00
pastilhasandClaude Opus 5 eaa38c7d43 the maintenance buttons tell you when they fail
Asked whether there was feedback on failure, and for the restart there was not —
there was the opposite.

`useClient` throws on any status >= 400. The restart handler caught everything and
reported success, on the reasoning that a dropped connection is normal when the
process answering the request is the one being replaced. That is true, and it made
the honest refusal built into the route unreachable: a 400 saying "not running under
PM2" arrived through the same catch, so the button would have said "Restarting…"
while nothing restarted, and stayed disabled forever.

The two failures are opposites and are now told apart by whether an error carries a
STATUS. A status means the server answered and declined — real error, message shown,
button re-enabled. No status means the connection dropped — expected, and the
success path.

Both buttons also unwrap the message. The client throws with the raw response body,
and these routes answer with JSON, so an unhandled failure showed
`{"ok":false,"error":"…"}` and asked the reader to find the sentence inside the
punctuation. Five tests on that, including the cases where it must NOT invent one:
JSON with no error field, and a non-Error thrown value.

Found while answering the question rather than by testing, which is the wrong order.

── One thing I should not have done ──

I exercised the restart route against the live server to see its refusal shape. PM2
knows `officer` here, so it returned 200 and scheduled a real restart; the only
reason it did not happen is that my test process called `process.exit(0)` before the
250ms timer fired. Luck, not design. `pm_uptime` confirms the platform never
restarted.

tsgo clean, frontend builds, 919 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:52:55 +00:00
pastilhasandClaude Opus 5 1426c3a856 rebuild the frontend without restarting, and restart when you must
Settings → System → Maintenance. Two buttons that are deliberately not a pair.

── Rebuild: real, and free ──

The same `rebuildFrontend` a plugin install already runs. `Bun.build` reads source
from disk on every call and writes `build/`, and the server serves that directory by
reading each file per request — so a rebuild lands on the next page load with no
process restart, no dropped websocket and no chat session lost.

── Restart: the only way to load server code, and it costs ──

Bun caches modules for the life of the process. Measured rather than assumed: editing
a file and re-importing returns the ORIGINAL export, and a cache-busting query picks
up that one specifier while everything it imports stays cached. So `rebuildHonoApp`
— which install already does — re-wires the same module instances into a new tree and
cannot see a backend edit. Busting the whole graph would duplicate singletons: two
database clients, two session maps, one process.

There is therefore no third button, and the screen says why. A "remount the API"
control would look like it reloaded the backend and would not.

The restart answers BEFORE it schedules itself, because the process serving that
request is the one being replaced. It refuses outright when PM2 does not know this
process — in development the platform runs under `bun --watch`, where the restart
would fail into a promise nobody awaits while the button reported success.

Verified: `POST /rebuild-frontend` → 200, 3.0s, 3 outputs; anonymous → 401. The
restart button is deliberately untested from here — pressing it would end the session
this was written in, which is the same reason it needs a label that says so.

tsgo clean, frontend builds, 914 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:23:33 +00:00
pastilhasandClaude Opus 5 27aa72d923 finish the offscale sweep: two real remnants and eleven stale names
Asked for a thorough check that nothing of offscale is left in the platform. The
extraction was sound — no router, no permission entry, no app-store entry, no
screen, no panel app, no env var anything reads. Two things were genuinely wrong,
and both were in setup rather than in the server.

── The secret store minted a key for a plugin ──

`officer-setup` bootstrapped two purposes: `jwt` and `headscale`. The second was a
key for a PLUGIN, under a name the rename had already replaced, on a machine where
the plugin is not installed and nothing would ever ask for it. offscale encrypts
under `offscale`, and `getKey` creates on demand — which is precisely what lets
core know nothing about plugins. Now core bootstraps `jwt` alone.

── `.env.example` documented variables for a deleted router ──

HEADSCALE_URL, HEADSCALE_API_KEY and HEADSCALE_USER were described as driving
`/api/vpn`. That router was deleted on 2026-08-14 and nothing has read any of them
since. Left as a note saying where the tailnet's credentials live now, because
somebody with an older `.env` will otherwise wonder why setting them does nothing.

── Names that outlived the thing ──

Eleven comments referred to the plugin's route, panels, console or server registry
as "headscale": three sidecar config files describing their own shape by pointing
at it, two screens citing `/headscale` as the example of a Workspace/Panel route,
the terminal's note about the Console, and the photos types. Also `catalogue.ts`,
which still claimed headscale "is CORE now", and `working-on-officer.md`, which
told the reader a core install runs six processes including `officer-headscale`.

Upstream references are deliberately untouched: "Headscale's API", "the Headscale
admin credential", "is this a Headscale server?" all name the thing the plugin
drives, and renaming those would make the sentences false.

`docs/secret-store.md` argued headscale must be core because the tailnet is the
perimeter. Marked superseded rather than deleted, with the distinction that
settles it: the perimeter is the tailnet EXISTING, which machine-setup still
arranges on a core install — not the platform shipping the screen that
administers it.

Verified by counting: live code, setup provisioning, env references, permission
entries, app-store entries, hono mounts, dock defaults, screens and panel apps all
zero. What is left is generated files (Plugins.gen, plugin-schemas.gen), the
bundled catalogue entry, test fixtures using "offscale" as an example app name,
and history.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:24:23 +00:00
pastilhasandClaude Opus 5 1b65b83f1f the plugins screen becomes /marketplace
`/plugins` redirects rather than 404s: the word is in the dock, in the docs and in
whatever anyone bookmarked, and a route that worked yesterday returning nothing
reads as the feature having been removed. The API keeps its name — only the screen
moved — so the permission entry lists both paths, because a client-side redirect
is still a route that has to resolve before it can send anyone anywhere.

Also: the fetch stubs in marketplace.test.ts needed `as unknown as typeof fetch`.
Bun's `typeof fetch` carries `preconnect`, so a bare stub is not assignable —
tests passed while tsgo did not, which is the wrong way round to find out.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:10:23 +00:00
pastilhasandClaude Opus 5 29170dd922 installing a plugin pins its dock tile, with no reload
Installing Music left the dock unchanged. The tile existed server-side the
moment the plugin was enabled, and the only way to see it was Profile → Dock,
by hand, after a page refresh. Installing something is already the decision to
use it; saying so twice is the bug.

Three separate causes, which is why it looked like one stubborn one:

1. `dock_configs.paths` is a COMPLETE ordered list, not a diff — useDock renders
   the saved list rather than everything available, so anything not named in it
   is invisible. Nothing in the plugin system had ever written to that table.
   installPlugin now appends the new route, for the caller (threaded through the
   route) rather than a guessed owner id.

   Only for a user who HAS a row. A user without one is left alone deliberately:
   there is nothing to append to, and writing one would freeze today's defaults
   into their account permanently. That case is (2).

2. The fallback for a user who never customised was DEFAULT_DOCK_PATHS, which is
   core-only — so a plugin tile was hidden there too. `defaultDockPaths(plugins)`
   is now that list plus a tile per installed plugin, and the dock and its
   settings screen both call it so they cannot disagree about what "default"
   means.

3. useDock holds ['DOCK'] at staleTime: Infinity and the plugin verbs invalidated
   only ['plugins'] and ['self-permissions']. So even once the server wrote the
   path, nothing asked again — that is the part that made a reload necessary.

Also fixes a guard I broke reaching for this: typing VERBS as
Record<string, VerbFn> widens keyof to `string`, which makes `isVerb` prove
nothing and lets an unknown verb index the map. `satisfies` instead, and there is
now a check that Verb is still the four-member union.

Uninstall deliberately does not remove the path. A stale path with no item behind
it is dropped by useDock, and keeping it means disable/enable and reinstall put
the tile back where the user had it rather than at the end.

tsgo clean, frontend builds. 787 pass / 7 fail — 6 identical to before; the 7th
is plugins/music/cliamp, which install pulled into test discovery and which the
manifest documents as parked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:08:05 +00:00
pastilhas 9c2d6a97f7 a tested migration script for the capabilities→permissions rename
Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:

  neither table        nothing to do; db:push will create it
  already renamed      no-op, prints the grant count
  BOTH tables present  stops and says a human must decide
  old table only       migrates, counts before and after, prints every grant

Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.

That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.

Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.

Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
2026-08-15 16:40:18 +00:00
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00
pastilhas 9b1c0a75b2 a dock tile can be bare, so artwork is not framed in a swatch
The tile background exists so a white lucide glyph has something to sit on —
on nothing it is invisible. Artwork does not need it, and a logo framed in an
arbitrary coloured square reads as a mistake.

  badge  rounded square filled with `color`, icon inset   (what a glyph needs)
  bare   no background, artwork fills the tile            (what a mark wants)

DEFAULTED from whether the plugin ships assets/icon.png — artwork gets bare, a
glyph gets badge — with `tile` on the manifest to override either way. Presence
is the declaration, as everywhere else here; the field exists only for the
plugin whose artwork genuinely wants a backdrop. Music sets nothing and gets
bare.

`color` is still required either way. It does four jobs and only one of them is
the square: the active glow, the indicator dot and the plugins-list tint all
read it too.

The icon was also smaller than it looked. The source carried ~8% transparent
margin on every side and the artwork is 233x221 inside a square frame, so with
the old `h-7 w-7` inset inside a `w-10` box the duck occupied roughly 39% of
the tile's area. Cropped to its alpha bounding box, padded back to square to
keep aspect, and rescaled: it now fills 95% of the frame instead of 79%, and
`bare` gives it the whole tile instead of half.

Measured rather than eyeballed — the bounding box came from extracting the
alpha plane and scanning it, then the crop was applied to the 1254px original
so nothing was resampled twice.

tsgo clean, 797 tests, 787 pass, same 7. Verified live: music reports
tile=bare, example and offscale still badge.
2026-08-15 15:21:25 +00:00
pastilhas f78abbe05a a plugin ships its own dock icon as a file, not a lucide name
The manifest's `icon` was a lucide NAME, resolved by `resolveIcon` — which
knows 106 glyphs out of lucide's ~1,500. A plugin naming one outside that set
silently rendered a neutral box, and a plugin from a marketplace had no way to
see the ceiling coming.

So the icon is a FILE now: `plugins/<name>/assets/icon.png`, discovered by
presence like everything else here. `manifest.icon` stays as an optional
fallback for a plugin with no artwork — example and offscale still use it — and
the file wins when both exist.

No new mechanism was needed. The app store already published sidecar assets:
`<dir>/assets/` → `public/plugins/<id>/`, served by a dynamic `/plugins/*`
route, with `DockItem.image` rendering an <img>. `pluginDockManifests()` simply
never emitted `image`. Install now publishes and uninstall unpublishes — the
one thing uninstall is allowed to delete, because these are copies whose
originals are still in the plugin's source.

Base64 in the manifest was considered and dropped. It would ride in every
/api/user/capabilities response for every user on every page load, can't be
cached separately, and puts a 5KB string literal in a source file — against the
rule this manifest keeps: what a directory listing can say, it says. It also
needs no support: `image` goes straight into <img src>, so a data: URL already
works for anyone who wants one.

Music ships OffMusic.png, resized 1254² → 256² (1.6MB → 108KB) with alpha
intact, sized for 3× DPI at the dock's 32px render. It also drops `icon` — the
artwork is a voxel duck in headphones, not a glyph.

The plugins page showed a generic Puzzle for every plugin; the list and detail
header now show the plugin's own icon when it has one.

Two bugs found while testing.

Dock.tsx imported 26 lucide icons and used 14. The twelve dead ones — Music,
Bitcoin, Receipt, Images, CalendarDays, Contact, Clapperboard, Mail, Network,
ArrowDownUp, FileText, FolderKanban, MonitorSmartphone — were residue from when
every feature had a hardcoded tile. Deleted.

And uninstalling a plugin left its icon URL answering 500, not 404. server.tsx
globs ./public at BOOT into one exact route per file, each holding a Bun.file
handle, spread into the route table AHEAD of the /plugins/* wildcard. So an
icon present at boot got an exact route that outlived the file, returning
ENOENT on every dock render with the error logged each time — exactly what the
wildcard's own comment says it exists to prevent. The comment covered the ADD
case; this is its mirror. `plugins/` is now excluded from the boot glob, so the
wildcard owns that prefix alone. Verified: 200 installed, 404 uninstalled, 200
reinstalled.

[open] A plugin's icon cannot be seen BEFORE installing it, which is the one
place an app store most wants to — assets are published at install by design,
and an authenticated icon route is no use to an <img>.
2026-08-15 15:10:17 +00:00
pastilhas 0a55964db5 the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar,
favourites, lyrics toggle and the library vocabulary — ten files. The barrel
stops exporting a player it no longer has, and DashboardLayout stops rendering
one.

The reasoning that kept it was removed rather than refuted. It stayed because
the dashboard widget imported useMusicPlayer from officerdev and the platform
cannot import from a plugin, so the state had to stay whatever was decided
about the UI. The owner moved the widget into the plugin in the previous
commit, and the constraint went with it: the whole remaining dependency became
one line, DashboardLayout.tsx:66.

MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until
you notice it already returned null on /music — the mini bar is the transport
there, and the host existed purely to own the GaplessEngine. In the panel it
does exactly that, and the bar code stays intact for whenever there is a slot.

[phase 2] Leaving /music unmounts the host and playback stops. Deferred on the
owner's call; the bar was "navigating away must not break the application", and
that holds: seekPlayer is optional-chained so a call with no host registered is
a no-op, registerPlayerSeek clears only its own registration, the host's
cleanup destroys the engine and nulls its ref, and the queue is global state so
returning to /music remounts and reloads. Solving it properly needs either a
shell slot a plugin can contribute to — which reopens "there is no way to
export a component" — or the engine hoisted to module scope, which keeps the
rule and loses only the off-route controls.

Also: the parked widget now imports the player as a sibling rather than through
officerdev, and shared.ts stopped being a re-export shim now that the real file
is in the plugin.

Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts
/example /music /offscale, / and /music both 200, and the player is in the
built bundle (music.volume, music:lyrics, now-playing?device=web all present —
GaplessEngine is a class name and the production build is minified, so grepping
for it proves nothing).

Not verified by me: what it looks like in a browser. That needs your eyes.
2026-08-15 14:42:13 +00:00