49 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 18c4ebd0b4 the per-user model is not tonight's work either
personal, the four user-scoped tables, and what a member's grant means all stay
exactly as they are. it gets built inside the plugin later, which is the entire
reason the platform's answer is a uniform read/write and nothing more.

leaving it alone is safe rather than lazy: the web frontend does not use those
routes at all — favourites, playlists and now-playing are used only by the phone
and tablet apps — so nothing visible in a browser can regress by carrying them
across verbatim. and /queue, which is in that list, has no route anywhere. dead
or aspirational; carried as-is, not investigated.

the one unavoidable consequence stays named: registry.test.ts tests the personal
mechanism through the music entry, so removing it breaks those tests. re-anchor
on another entry that has personal. a test fix, not a redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:24:09 +00:00
pastilhasandClaude Opus 5 7d65732f77 the widget is out of scope too, leaving one open call
widgets/MusicPlayer stays in its third workspace package, registered where it
is. plugins cannot contribute widgets and are not going to learn how tonight.

with cliamp and the widget both cut, exactly one judgement is left in the music
extraction: whether the global MusicPlayerHost overlay gets a contribution slot
in the shell or stays in DashboardLayout gated as it already is. either is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:21:54 +00:00
pastilhasandClaude Opus 5 b5db3c47e1 cliamp is out of scope, which unblocks the music extraction
the two websocket providers exist for one thing: running the cliamp TUI on the
server and piping its terminal and audio to the browser, via a pulseaudio null
sink tapped by parec. a second, separate playback path — and the least important
part of music. wanted eventually, not tonight.

that removes the hardest of the three homeless parts entirely. no plugin can own
a socket yet, and now none needs to: both sockets are already inert, and
cliamp-ws.ts, pulse-audio.ts, asoundrc, the relay and the two providers all stay
exactly where they are. if the relay's import is the only thing keeping
api/music/router.ts alive, that stays too — a small documented seam.

what music actually is: the /music screen, the library, and the phone and tablet
apps that stream from it. that is what has to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:18 +00:00
pastilhasandClaude Opus 5 8545b427dd the runbook tells the next agent to decide, not to ask
it previously said 'decide before moving a file, and tell me the decision',
which turns an overnight run into a blocked one. every open call here has two
defensible answers, a corrected decision is cheap, and a stalled extraction is
not.

adds a default for each of the three homeless parts — close the platform gap
when there is runway, because every later plugin needs it too, but a working
music with cliamp left in the platform beats a perfect design that did not land.
the bar at the end is unchanged: install, enable, disable, uninstall cleanly. a
piece left in the platform is a documented seam; a piece left dangling is a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:13:49 +00:00
pastilhasandClaude Opus 5 965ced52a6 a runbook for the next extraction, and a warning about music
plugins/EXTRACTING-A-PLUGIN.md: the rules that are not preferences, the order
that worked, the verification cycle the owner actually ran, and the traps that
each cost real time.

the music section is the important half. music is NOT a bigger offscale — three
of its parts have nowhere to go: two websocket providers (and no plugin can own
a socket), a global UI overlay rendered by DashboardLayout on every route, and a
dashboard widget in a third workspace package. its relay is platform code that
imports the music router, so deleting that router breaks the platform. it also
needs six external binaries a manifest cannot declare, ships a non-TS asset, and
is the worked example the capability tests are written through.

recorded before starting rather than discovered halfway, because a half-extracted
music is worse than an unextracted one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:11:14 +00:00
pastilhasandClaude Opus 5 4a9f23c759 the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about
the plugin system generally rather than about offscale, which is exactly why it
belongs with the worked example — the reasoning is most useful beside the code
it produced, and the platform's docs should not carry the history of something
it no longer knows exists.

every reference updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:07:48 +00:00
pastilhasandClaude Opus 5 b4dab16d2a the schema barrel stops describing a world that ended
three things wrong in one paragraph: it named ecosystem.light.config.cjs, which
no longer exists; it called officer-headscale core, which it stopped being on
2026-08-14; and it said installing a plugin 'will have to uncomment its line and
push, and building that is still ahead of us', which was built yesterday.

now it says what is true — a plugin owns its schema at plugins/<name>/db and
install generates the barrel and pushes — and keeps the commented lines for the
sidecars still waiting their turn, which is what they are actually for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:52:37 +00:00
pastilhasandClaude Opus 5 2c89281bfc drop a comment describing an export that left with offscale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:49:32 +00:00
pastilhasandClaude Opus 5 585c046a64 the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway,
which is worse than either — the whole reason for the rename was that the word
already means three things here.

renamed what was mine: setPluginCapabilities -> setPluginPermissions,
pluginCapabilities -> pluginPermissions, and the prose throughout.

what remains is the platform's own vocabulary, not the plugin system's: the
 type it imports, and the  field on a dock manifest,
which is the shape the shell already renders. renaming those is a separate
change to a separate system and is the owner's to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:47:18 +00:00
pastilhasandClaude Opus 5 8cc51cfb40 every plugin permission is grantable, and there is no field to say otherwise
offscale was missing from the permissions page because it declared ownerOnly,
which mapped to kind admin, and admin capabilities are never offered for
granting. correct by the old rule and wrong by the standard: every plugin
follows the same platform-level permission model, appearing on the same page
with the same read/write/none per role.

so the field is gone rather than flipped. a plugin has no way to say owner-only,
which makes the standard structural instead of remembered — the same move as the
workspace rule. core, execution, confined and admin stay the platform's to
assign and a plugin cannot name any of them, so the escalation question is
removed rather than answered.

this is the second draft of this decision to be deleted: first a full
CapabilityKind with three of five values forbidden, then an ownerOnly boolean,
now nothing. the doc records all three so the reasoning is visible rather than
just the conclusion.

finer visibility stays the plugin's job. offscale is the worked example of the
gap that leaves and its manifest says so: it is grantable now, and its queries
still scope by the caller, so a granted member would see their own empty server
list rather than the owner's. closing that is a change inside the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:42:35 +00:00
pastilhasandClaude Opus 5 8b6cb34ae0 plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a
plugin installed through the plugin system was reachable only by typing its url
or following the link on /plugins.

built at runtime rather than baked into the generated bundle, deliberately —
WHO sees a tile is a permission question, and a grant takes effect on the next
request rather than the next build. presentation comes from the manifest and the
route from mountPrefix, so there is one source for both, and  is the
plugin's first permission so the endpoint can filter a tile out for an account
that cannot reach the screen.

two sources for tiles today, because the app store still has its own catalogue.
one when it is rebuilt on this.

the doc now records the system as complete rather than half-stubbed, including
the three bugs the extraction found — the sidecar-before-mount ordering, the
missing tailwind, and the build that could delete its own shell — and what is
genuinely still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:31:54 +00:00
pastilhasandClaude Opus 5 8587ae20b7 a plugin creates its own tables, and uninstalling never drops them
the last unwired step. install now generates a drizzle barrel of plugin schemas
and runs db:push, so a plugin with db/schema.ts brings its tables with it.

the barrel follows the plugin DIRECTORIES on disk, not the install table, and
that difference is the entire safety property. push drops what it cannot see, so
a barrel tracking installs would delete a plugin's tables the moment it was
uninstalled — turning "stop running this" into "delete my data", which is the
one thing the install model refuses to do. following the directory means:

  directory present, not installed   in the barrel, tables exist unused
  installed                          in the barrel, tables in use
  uninstalled                        STILL in the barrel, every row survives
  directory deleted                  out of the barrel, a push may drop them

so reinstall is a restore, and losing data requires deliberately deleting a
plugin's source.

proved end to end rather than argued. with offscale uninstalled and its entry
removed from the barrel, db:push DROPPED headscale_servers. installing it
recreated the table in 1882ms — columns, both unique indexes including the
partial one that enforces a single active server, and the fk. a canary row then
survived an uninstall AND a subsequent manual db:push, which reported "No
changes detected".

it shells out to the same `bun db:push` a human runs rather than driving
drizzle-kit in-process: one definition of applying the schema instead of two
that can disagree, and an owner can reproduce exactly what an install did.
--force because the barrel only ever gains entries unless source is deleted, and
a prompt with no terminal would hang an install rather than fail it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:21:11 +00:00
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00
pastilhasandClaude Opus 5 0e24aa3d52 a way into the plugin from its detail panel
installing something and then having to guess its url is a small thing that
makes the whole flow feel unfinished. the detail panel now links to the plugin's
screen.

shown only while installed AND enabled, and only when the plugin has a frontend
at all. a link to an unmounted route lands on the home page, because the shell
redirects an unknown path — which reads as a broken link rather than a plugin
that is switched off. a backend-only plugin has no screen to open and gets no
link rather than a dead one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:02:09 +00:00
pastilhasandClaude Opus 5 543e88a9a6 every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:47:48 +00:00
pastilhasandClaude Opus 5 2e3c935da6 the built SPA had no tailwind, and the build could destroy itself
three fixes to last night's build switch, all found by using it.

THE CSS. bunfig.toml declares the tailwind plugin under [serve.static], which
applies to bun's static SERVING — the html-import path the app used until
yesterday — and not to a programmatic Bun.build(). so the first build emitted
the xterm css and no tailwind at all: layout intact, every utility class
missing. a plugin list is not inherited from bunfig; it has to be passed. css
goes 110KB to 278KB, 1127 --tw- variables, .flex present, --color-duck present.

THE BUILD DIRECTORY. clearing it before building was meant to stop 20MB of
content-hashed chunks accumulating per install, and instead meant a FAILED build
left nothing — the exact opposite of the promise in the comment directly above
it. it was also a race: two builds overlapping had one process's rm delete the
other's shell, leaving js and css with no html and a 503 that read as a build
failure when the build had succeeded.

now it builds into build.next/ and swaps only a complete, successful build into
place, and refuses to swap one that produced no shell at all — a build can
report success and emit no html, and serving that is worse than serving the
previous one.

AND IT SAYS WHICH PATH IS SERVING. "is it serving the build I just made, or the
one bundled at import?" was answerable only by hiding the shell and watching for
a 503, which is how it got answered once. the distinction matters precisely
where it is hardest to see: the html import is fixed when the module graph
loads, so an install would rebuild build/ and serve something else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:26:28 +00:00
pastilhasandClaude Opus 5 7b4137ccca a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.

a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.

App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.

serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.

verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.

three things worth recording because they were found rather than reasoned:

the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.

App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.

seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.

756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:07:18 +00:00
pastilhasandClaude Opus 5 a00116b2c0 a plugin's permissions become real capabilities, and survive a restart
two gaps between "a plugin can mount routes" and "a plugin is part of the
platform". both closed.

FIRST: nothing registered a plugin's declared permissions, so the capability
gate could not resolve a plugin path at all. it resolved to null, and null is
denied — the owner never noticed because isSuperAdmin short-circuits every
check, which is exactly the shape of bug that reaches a member first.

the registry is now rebuildable the same way the hono app is: CORE_REGISTRY
holds the platform's own, CAPABILITIES is core plus whatever the installed
plugins declare, and setPluginCapabilities replaces the plugin half wholesale
rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_-
CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can
never put itself in the fresh-install baseline and can never become `core`
(every account, undeniable). a key colliding with a core one is refused and
logged, because a plugin able to redefine `chat` could widen it.

ownerOnly maps to admin, everything else to app. those are the only kinds a
manifest can express, and it has no field for a kind at all.

capabilities are registered BEFORE routes are mounted: the gate runs ahead of
every router, so mounting a route whose permission is not yet registered would
403 the freshly installed plugin until something else happened to refresh.

SECOND: nothing mounted plugins at boot. honoServer is built with none at
import, because discovery reads disk and database and neither can be awaited at
module scope, and every install verb rebuilt — so it tested perfectly and would
have silently unmounted everything on the first restart.

server.tsx now refreshes before serve(), so there is no window where an
installed plugin 404s, and a plugin that will not load is logged rather than
fatal.

verified: after a restart, [plugins] mounted /example, the row survived,
officer-example came back online from its ecosystem entry, capabilityForApiPath
resolves /api/example/ping to the example capability at kind=app, and it appears
in the owner's grantable list. 756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:43:49 +00:00
pastilhasandClaude Opus 5 62ee0d1e60 the beat between steps is feedback, not decoration
the comment called it cosmetic and worth being honest about, which reads like an
apology and invites the next reader to delete it as a pointless sleep.

the real reason is better. some of this work is genuinely slow — pm2 start
measures ~770ms — and some is effectively instant. without a pause the fast
steps land in one frame, the log jumps from empty to finished, and you cannot
tell 'it worked' from 'nothing happened'. the interval is what makes a step
something you saw happen rather than something you found already done.

only applied when something is listening, so the json path still runs flat out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:33:53 +00:00
pastilhasandClaude Opus 5 4d4606d4a2 close the owner's dotfiles once somebody else has a shell
ubuntu's default umask is 002 with user-private groups, so everything the owner
creates lands 775/664. alone on a machine that is harmless. it stops being
harmless the moment a member has a login — and member homes are NESTED inside
the owner's, so the owner's home must stay traversable AND readable (the
ancestor-read requirement bun exposed today) and every dotfile in it is legible
by default.

measured as green before writing this: ~/.pm2/logs (all 12 files, every log the
platform has written), ~/.pm2/dump.pm2, ~/.claude/projects (names every
directory the owner works in), ~/.config, ~/.local, ~/.cache, ~/.npm, ~/.bun,
~/.opencode — all listable. assertSecretsClosed was already holding the line
that matters: .env, .ssh, .zsh_history, .claude.json and the credentials are
denied, and dump.pm2 turned out to hold no secret values because bun loads .env
at runtime rather than through pm2.

so this is the tier below fatal: not tokens, but logs and the shape of the
owner's work.

it runs from PROVISIONING, not from setup, and that is the point. ~/.claude does
not exist until the agent has run once; a chmod at install time finds half the
list missing and silently does nothing — the same failure mode as the ACL mask
earlier today. every member's arrival re-closes whatever appeared since.

two directories are left open on purpose, and both are the same latent bug:

    /usr/local/bin/bun -> /home/pastilhas/.bun/bin/bun
    /usr/local/bin/gh  -> /home/pastilhas/.local/bin/gh

system-wide tools installed into one user's home, so every member resolves them
through it. i found this by closing them and breaking bun and gh for green.
~/.local/share and ~/.local/state ARE closed; only the bin directory is
reachable. the honest fix is installing them outside the owner's home.

verified both directions on this host: green is denied .claude, .pm2, .config,
.local/share, .local/state, .cache — and still has working bun, gh, psql, their
own claude, and their own project tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:59:45 +00:00
pastilhasandClaude Opus 5 a220342b22 stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and
the detail panel renders them arriving.

POST rather than GET, so EventSource is unavailable — it sends no Authorization
header and these routes are owner-only. The client reads the body and parses
frames by hand, which is what useCompanionLogStream already does for the
headscale container logs; the parser only has to understand what our own
endpoint emits.

the runner does not know whether anyone is listening. it takes an optional
onStep and calls it, so the non-streaming path is the same code with no callback
rather than a second implementation of the same four verbs.

there is a 220ms beat between steps and it is cosmetic — worth saying out loud.
pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the
router do not, and four lines landing in one frame look like a stall followed by
a jump. small enough not to matter to a script, long enough to follow.

writing to a closed stream is caught rather than fatal: navigating away
mid-install must not abort the install, because by then it is the server's work
and half an install is the one outcome the ordering was designed to avoid.

verified over the wire with timestamps — frames arrive incrementally, the
sidecar step showing its real duration rather than the beat. afterwards pm2
holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:50:50 +00:00
pastilhasandClaude Opus 5 02e049cae8 terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not
natural".

── the replay typed into the shell ──

the pty buffer was stored raw and replayed verbatim on every re-attach. anything
in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP,
the OSC colour queries — got asked again, and xterm answered correctly by writing
the reply to its input. the pty receives that as a keystroke nobody typed.

stripped on the way IN, since the buffer is the thing that gets replayed and a
live client already answered them once when they were legitimately asked. only
questions are removed; everything that draws is untouched. where a control shares
its final byte with one that draws, the parameter is enumerated rather than
wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and
stripping the second would change what a replay renders. 36 tests, both
directions, because both fail silently.

── two sockets on one session ──

handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus
whenever readyState was CLOSED — which is exactly what a pending timer leaves.
both ran. every keystroke went twice, two replay frames fought over the screen,
and only one socket was ever cleaned up because __terminalCleanup is overwritten
by whichever connect ran last. connect() is now the single guard, and a stale
socket's close no longer speaks for the session.

── the keyboard ──

alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow
sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D
(verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing
bound it. so it broke on a dependency bump, with no shell config changed.

bound in zsh rather than translated in the browser, deliberately: tmux.conf
claims M-Left/M-Right for pane switching, and a client-side rewrite would send
^[b to tmux and break it. the real sequence lets tmux handle it inside a session
and zsh outside.

ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the
OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is
assembled in machine-setup and never got them. the owner had a strictly worse
shell than the accounts they provision. confirmed with `zsh -i -c bindkey`
before and after.

also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and
every Escape, which is most of what "not natural" felt like.

applied to this host by hand — setup only runs at install. cmd+arrow is left
alone: xterm emits nothing for it, so there is no sequence to bind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:27 +00:00
pastilhasandClaude Opus 5 2634df7a04 the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.

verified against a running server with a sidecar in the tree:

  install    ecosystem added · sidecar started · recorded · mounted /example
             route 200, pm2 online
  disable    sidecar stopped · unmounted
             route 404, pm2 stopped
  enable     sidecar started · mounted
             route 200, pm2 online
  uninstall  record removed · unmounted · sidecar stopped, deleted, entry gone
             route 404, not in pm2, tables untouched

afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.

the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.

order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.

each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.

the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.

720 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:21 +00:00
pastilhasandClaude Opus 5 ed195e0904 record what got built tonight
the plugin system works end to end for a plugin with an api/router.ts, at
runtime, with no restart. what is wired, what is not (schema push, the sidecar's
pm2 entry, websocket providers, totality across plugin routes), and what was
deliberately left: offscale is not extracted, because moving it deletes working
code across ~50 files and that wants someone watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:28:18 +00:00
pastilhasandClaude Opus 5 98c400bf33 a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either
side of a selection that lives in ?selected= and is read by both independently,
so neither can be telling the other something stale — rows are real Links, not
buttons holding the name in a closure.

the detail panel shows what the tree declared (api, schema, sidecar, web),
because "installed and nothing happened" is otherwise a mystery, and it names
what uninstall does NOT do: neither disable nor uninstall deletes anything the
plugin stored, and the screen says so rather than leaving someone to guess
whether a button destroys their data.

a directory whose manifest will not parse is listed with its error rather than
skipped. a malformed plugin that simply does not appear is indistinguishable
from one nobody wrote.

`outdated` is surfaced as an Update button: the version on disk moving after an
install is the normal state on a developer's machine, and it should be visible
rather than inferred.

the four mutations are written out rather than generated in a loop — useMutation
is a hook, and a hook called from inside a helper is a rules-of-hooks violation
even when the call order happens to be stable. caught before it shipped.

verified against a running server: the spa builds (19.8 MB bundle containing the
new screen), / serves 200, /api/plugins answers authenticated and 401s without a
token. full suite 719 pass, same 10 pre-existing failures. live server and
plugin_installs left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:27:58 +00:00
pastilhasandClaude Opus 5 282a64a637 plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:22:18 +00:00
pastilhasandClaude Opus 5 0701aba902 brotli in the core package set
installed on this host already; verified round-tripping from a member shell.
same package name on apt, pacman and dnf. on brew it is there because macOS
ships the library but not the CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:56 +00:00
pastilhasandClaude Opus 5 2e6c263751 the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.

hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.

so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.

buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.

alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.

discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.

a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.

verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:11 +00:00
pastilhasandClaude Opus 5 b2349b5480 install the psql client, matching the server it talks to
there was no psql on this machine. the server runs in a container, so nothing
ever put a client on the host, and `docker exec officer-postgres psql` is the
owner's tool — a member has their own Postgres role and no access to the owner's
Docker socket.

the version is derived from PG_IMAGE rather than typed again, because the
pairing is load-bearing: pg_dump refuses a server newer than itself, and Ubuntu
24.04 ships client 16 against this 18 server. so the archive package is not
merely old, it is unusable for dumps. that is also why this sits beside the
server definition instead of in machine-setup's package list — one constant, one
place to bump.

PGDG added the same way docker.sh adds Docker's: key in its own file, one
sources.list.d entry, no add-apt-repository. non-fatal, and the exit status is
not the gate — apt can succeed while holding an older client back, so the check
is that psql is present AND is the major we asked for.

installed by hand on this host already: psql/pg_dump 18.6, verified as green
connecting with their own role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:13:09 +00:00
pastilhasandClaude Opus 5 0ae0a5dc58 music is where the richer permission model gets designed
offscale is deliberately the simple case — one shared resource, read or write.
music is the next extraction and the right place to build the in-plugin
visibility system, because it has real per-user data (favourites, playlists,
now-playing) on top of a real shared one (a single global library index). so
'whose is this row' has a non-uniform answer there, where offscale's is just
'the owner's'.

not designed yet and deliberately not designed here. recorded so the intent
survives the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:06:55 +00:00
pastilhasandClaude Opus 5 acd51c969c the platform grants read or write; richer rules belong to the plugin
the platform's contract is what it already has: a role holds read or write on a
capability, stored in role_capabilities and enforced by the gate. anything
beyond — who sees whose rows, per-user isolation, record ownership, visibility
of any kind — is the plugin author's job, inside the plugin. the platform should
not grow machinery for it. a plugin knows what its data means; the platform only
knows whether this account got through the door.

offscale v1 uses that exactly. one shared resource: read sees what the owner
sees, write can change it including deleting a server the owner registered. that
is dangerous on purpose — the stored credential is a headscale admin key with no
read-only equivalent, so write is close to full control of the tailnet, and that
is the owner's call. expected use is read for most roles.

two consequences, both inside the plugin. the queries stop scoping by the caller
and resolve to the owner's id, leaving the per-user shape in the table unused as
the seam if isolation is ever wanted. and two POSTs are really reads —
/ssh-test probes and /policy/assist explicitly never saves — so they need
readOnlyWrites, or a read-level account finds a broken feature where a withheld
permission should be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:04:18 +00:00
pastilhasandClaude Opus 5 4c3682dae6 let a member read the two directories above their own home
`bun run` from anywhere inside a member's home died with

    error loading current directory
    error: An internal error occurred (CouldntReadCurrentDirectory)

before it looked at package.json, bun.lock or .git — all of which were present.

it is not walking up looking for a workspace root. it primes its resolver cache
by walking DOWN from / and opening every component of the cwd for READING:

    openat("/home/pastilhas/officerdev/")              = 6
    openat(".../officerdev/data/")                     = -1 EACCES
    openat(".../officerdev/data/<email>/")             = -1 EACCES

those two are 711 — traversable, not listable — which is enough to cd into a
home and not enough for a program that reads its ancestors. `getcwd` succeeds;
the ancestor read is what fails. `O_PATH` would need only `x`, so this is
arguably bun's bug, but it presents as a member's project being mysteriously
unbuildable and nothing here can fix it from the other side.

so DATA_PATH and the account dir now carry a named ACL entry per member. that
gives up the property the old comment named — a member can now `ls` DATA_PATH
and learn the other accounts' email addresses — and keeps everything that
matters: every home is still 700 and owned by its member, every platform
sibling still 700 and owned by the service user. verified as green: the account
list is visible, and email_accounts, another home, the repo .env, the owner's
ssh key, .pgpass and ~/.claude/.credentials.json are all still denied.

the mask is set explicitly to rx alongside the entry. chmod recomputes the mask
from the group bits, which for 711 is --x, so without that the next member's
provisioning would silently clamp every earlier member back to traverse-only.

applied by hand to the one existing account; provisioning covers new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:00:29 +00:00
pastilhasandClaude Opus 5 56bb383c6d a plugin installs with no questions unless it says otherwise
offscale needs none of the install fields the catalogue carries — no modes, no
existingFields, no configFields, no composeTemplate, no members. nothing to
provision, nothing to point at. install is put the code there, push the schema,
start the sidecar, swap the routes, and it is available.

configuration happens afterwards inside the app, which is already how headscale
works: a server is registered at runtime and lands in offscale_servers.

so no-questions is the default rather than offscale's special case, and the
prompting machinery gets designed against the first extracted plugin that
actually needs docker or a remote instance. part of why this was the right
pilot — it exercises mounting, schema and sidecar without install being a
variable at the same time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:15 +00:00
pastilhasandClaude Opus 5 327783532e reach a member's transcripts by listing them, not just by reading them
yesterday's fix routed transcript CONTENT through the member's identity and
stopped there, on the strength of a comment in ChatIdentity saying enumeration
never needed it — "their directories are 775 and the platform holds an ACL
entry, so readdirSync and statSync have always worked".

there are no 775 directories on this path. claude creates ~/.claude/projects/
and every project group at mode 700, and a 700 directory clamps the ACL mask to
--- exactly as a 600 file does:

    user:officer:rwx    #effective:---
    mask::---

measured against a real member home:

    existsSync(projects)        -> true     (stat only needs traverse on .claude)
    readdirSync(projects)       -> EACCES
    existsSync(projects/<slug>) -> false
    statSync(<transcript>)      -> EACCES

existsSync answering false rather than throwing is why this was invisible: every
caller read it as "no such session". one root cause, three reported symptoms —
an empty conversation list, no title on a new chat, and a /chat/<id> deep link
that never restored the conversation. a fourth nobody had reported yet: delete
removed nothing and still answered ok, because unlink needs w+x on the group
directory too.

so enumeration goes through the same door as content, as ONE call rather than a
spawn per entry: listTranscriptsAs runs a single `find` as the member and
returns every transcript with its mtime, which readdir+stat could not do without
dozens of setpriv forks per request and a matching pile of auth.log lines. the
owner keeps a fork-free path — that process already IS the owner. removeAs does
the same for unlink, and readTailAs no longer stats a file it cannot stat.

summarizeTranscript now takes the mtime it is given instead of stat'ing again,
which is both the fix and one less syscall per file.

verified against jg@pertento.ai on this machine: 6 conversations listed with
titles from their first prompts, and a deep link by id alone loads 71 messages.
owner path re-checked and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:06 +00:00
pastilhasandClaude Opus 5 9f903479ce websocket routes reload too, so nothing needs a restart
the last gap. six ws providers live in bun's route table rather than hono's, so
the app swap does not reach them — but server.reload({routes}) does, and in both
directions: refused before, connected after install, refused again after
uninstall, with core routes untouched throughout.

so a plugin can own a socket from the start, and no part of an install needs the
process restarted.

still untested: whether connections already open across a reload survive it.
that matters before an install is allowed to interrupt somebody's terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:46:07 +00:00
pastilhasandClaude Opus 5 6ab838c77f mounting at runtime after all: rebuild the app and swap it
this went round twice — runtime dynamic, then generated-plus-restart on the
belief that hono could not mount after serving, then back once that was actually
tested. the doc keeps the route rather than just the destination.

tested: SmartRouter (hono's default) and RegExpRouter both throw 'Can not add a
route since the matcher is already built'. TrieRouter and PatternRouter accept
it. so runtime adding is possible but costs the fast matcher, and hono has no
remove-route api at all, which uninstall needs.

what solves both is not adding routes but rebuilding: construct a fresh app from
the current plugin set and reassign the variable. the fetch closure reads it per
request, so the reassignment is the swap — atomic, no dropped connections, no
server.reload, and the default SmartRouter is kept. verified 404 before install,
200 after, 404 again after uninstall, with core routes unaffected throughout.

the mechanical cost is one line: server.tsx:322 is '/api/*': honoServer.fetch, a
bound method evaluated once at serve(), and has to become a closure or the swap
does nothing.

websockets stay open: six providers live in bun's route table rather than
hono's, so a plugin owning a socket needs server.reload({routes}), untested.
offscale has none.

and totality stops being a boot check — buildApp() is now the single place
routes are mounted, so it is where the assertion belongs, refusing the swap
rather than refusing the boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:45:22 +00:00
pastilhasandClaude Opus 5 13437e0e48 mounting: generated then restart, reversing the call for runtime dynamic
this reverses the earlier decision for C (mount and unmount at runtime) and says
so rather than quietly overwriting it.

the requirement behind C was that the platform must not need to know a plugin in
advance. that is met either way: what it reads is a generated file listing the
installed routers, analogous to Plugins.tsx on the frontend — nothing hardcoded,
nothing read from a table at boot, the imports made concrete at install. C would
have bought only the absence of a restart.

and a restart is close to free here, because sidecars are pm2 peers rather than
children — a property that was fought for, since officer used to spawn the agent
and pm2's tree-kill took the owner's chat down on every restart. what a restart
costs is websockets, which reconnect, and in-memory session records, which
claude:list already recovers.

the happy consequence is that assertCapabilityTotality stays a boot check
instead of becoming a per-mount transaction. it does need to be fed the route
table rather than Object.keys(handlers) first — generated mounts widen that gap
rather than closing it, so that is a prerequisite and not a tidy-up beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:35:55 +00:00
pastilhasandClaude Opus 5 7befaf032a the manifest holds only what the tree cannot say
lean it to identity facts and human choices: publisher, version, platform range,
the four presentation fields, and permissions. everything structural becomes
convention, where presence is the declaration — sidecar/, api/router.ts,
db/schema.ts, web/Router.tsx, web/panels.ts. appName comes from the directory
name, so the id cannot disagree with where the code sits.

the dock tile and page title needed no fields at all: the tile is label + icon +
color + mountPrefix, and the title is label. writing them again was duplication
that could only drift.

runtime is the file extension. index.mjs is node, index.ts is bun — implicit,
but already the rule here, since officer-pty runs under node for node-pty's abi
and everything else is bun. better than a field that can contradict the file.

dependsOn is gone; nothing read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:32:07 +00:00
pastilhasandClaude Opus 5 7ebc4d0ccd note the totality/route-table drift for later
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 1292a5c5ab opencode is owner-only until it carries an identity
a turn on the opencode harness ran as the owner, in the owner's home, whoever
asked. handleOpenCodeChat resolves its cwd against getOwnerHomeDir(email), which
discards the email it is given, and the sidecar runs one shared `opencode serve`
as the service user — sendOpenCodeStreaming accepts userId/email/username and
forwards none of them. it carried a comment calling itself owner-only; nothing
enforced it.

reachable by any account with the `chat` grant, which every role holds by
default (DEFAULT_ROLE_CAPABILITIES), and isClaudeModel is a startsWith, so a
typo'd model string landed there too. the model is client-supplied and never
checked against the catalogue.

the same gap on the read side: opencode's session store has no per-user scoping
at all, so loadOpenCodeSession/delete/rename take an id and no identity, and the
list and live routes returned other people's conversations.

so: ChatIdentity carries isOwner as its own fact (not inferred from
osUser === null, which holds only while resolveHomeDir refuses a member without
one), and every opencode door in chat.ts checks it — list, load, live, delete,
rename — plus a refusal on the execution path in handleChat. /chat/models hides
opencode from non-owners as a courtesy; the socket refuses regardless.

a stopgap, not a design. the fix is to thread identity through the opencode
sidecar the way spawnClaudeAsMember does, and TODO.md has been saying so.

not fixed here, and worth knowing: a member's session list is still empty and
/chat/pwds still 500s, because readdirSync on their ~/.claude/projects is EACCES
— claude creates it at mode 700, which zeroes the ACL mask. visible in
officer-error.log right now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 4dc7cd90c2 a plugin declares permissions, not capabilities, and has no kind
'capabilities' already means three things in this codebase — the permission
registry, the officer-items store, and the sidecar's routing keys. a fourth
would be one too many, and the field is really just permissions. the name is
free: the old permissions table went in 044aacf4.

and the kind enum is gone with it. the first draft handed a plugin the
platform's own five-value CapabilityKind and then forbade three of them. those
five exist because the platform has five sorts of surface; a plugin has two —
grantable to members, or owner-only. a boolean says it, and says it without
needing a prohibition: a plugin cannot claim core if core is not a word it can
say.

offscale is ownerOnly: true, which is what kind: 'admin' meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:25:54 +00:00
pastilhasandClaude Opus 5 b18601530f a manifest for offscale, and the rule it immediately broke
written against the real plugin rather than invented as a field list, on the
theory that an abstract one includes what nothing needs and misses what is
awkward. that paid off on the first field that mattered.

the rule here said a plugin may declare `app` and nothing else. offscale's
capability is `admin` — owner only — and should stay that way, so the rule was
wrong. the distinction is direction, not privilege: `core` means every account
and not deniable, so claiming it grants yourself to everyone; `admin` means
owner only, which is a plugin restricting itself. corrected table in the doc.
core, execution and confined stay the platform's to assign.

`publisher` is the only input to the mount prefix, through one function, so
first-party and third-party cannot drift into two code paths.

sidecar.runtime is a field because officer-pty needs node for node-pty's abi
while everything else is bun — one plugin already needs it, so not speculative.

dependsOn is informational and unenforced. code dependencies need no declaration
now that a plugin builds inside the workspace, and service dependencies already
degrade; this exists so the store can say the console section wants the terminal
plugin, rather than the section silently doing nothing.

health is marked deferred rather than open, with the reasoning, so it does not
get re-raised. migrations likewise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:05:15 +00:00
pastilhasandClaude Opus 5 f6b2905cc7 how the frontend ships, and what plugins may depend on
everything moves to the plugin, frontend included, so federation stopped being
a later problem and had to be answered. it is answered by not needing it: bun
builds the spa into build/ at start and rebuilds it on install, serving from
that directory instead of compiling through the html import. Bun.build is a
runtime call, so an install needs no restart — just a refresh. same origin
throughout, which is why there is no cors work and no rewrite of useClient.

App.tsx keeps core routes and gains one map over `plugins`, each mounted at a
wildcard delegating to the plugin's own router. that list comes from a generated
Plugins.tsx, because a bundler cannot follow import(runtimeString) — the
specifier has to be concrete before the build. the six places the shell
currently hardcodes headscale collapse into that one file, dock included; the
runtime dockItemsFromPlugins path follows rather than competing with it.
presentation moves to build time, permission stays runtime.

dependencies turned out to be two different problems wearing one word. a service
dependency (assist → anthropic-proxy) is a wire call and already degrades. a
code dependency (ConsoleView → TerminalView) is in the bundle and cannot. rule:
may depend, must degrade. service calls go through the api carrying the user's
token, with the user's own permissions, which also deletes the state-file read
claude-proxy uses today to lift the proxy's secret.

no per-plugin permission list: a plugin is part of the app and bounded by the
account calling it. that makes marketplace review a security boundary rather
than a naming one, which is worth knowing rather than discovering.

and the developer environment is a platform checkout — clone it, run dev, build
the plugin inside. the 13 workspace packages resolve by name because bun links
them, so `import { useClient } from 'hooks/useClient'` just works with no
registry and no versioning. dev-time and build-time become the same mechanism.

also writes down the headscale inventory now that it has been read end to end,
including that assist.ts travels unwired as a marker and must not be tidied away
as dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:58:22 +00:00
pastilhasandClaude Opus 5 7f26f0b4b8 offscale is headscale plus the companion, not a rename
the name looks like branding on someone else's project, which is exactly how it
gets 'corrected' back later. it is not: offscale is the stock headscale server
plus the companion that ships beside it, and the invite flow is the first thing
that only exists there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:44:28 +00:00
pastilhasandClaude Opus 5 01a20fff4e the invite flow replaced device enrolment; it is not a gap
closes the one open item left by deleting /api/vpn. removing the vpn capability
leaves no member-grantable headscale surface and that is correct: the owner
mints an invite from the headscale app, the companion turns it into the redirect
the phone claims, and the device joins. no per-member permission on officer is
involved at any step.

recorded as decided rather than open so nobody reintroduces a member-facing
enrolment route believing something was lost. nothing was — /api/vpn/enroll was
the design the invite flow replaced, and it never had a UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:43:42 +00:00
pastilhasandClaude Opus 5 88a44ec4a7 delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:40:44 +00:00
pastilhasandClaude Opus 5 bbc60b34ac headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now,
and the catalogue.test CORE[] mirror follows it — not optional, since that list
asserts "the catalogue must not offer a core process" and would have blocked
adding offscale to the catalogue later.

the local generated ecosystem file lost its entry too, and officer-headscale was
stopped and deleted from pm2 by hand. the platform still mounts /api/headscale
and still declares the headscale and vpn capabilities, so the feature is
present-but-unavailable rather than gone.

docs/offscale-plugin.md is a live document for the rest of it. what it records
that nothing else does: core is now `officer` alone and everything else is a
plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for
third parties, derived by one function so the two can never become two systems;
tables stay in public with an app-name prefix; mounting becomes genuinely
dynamic, which retires the "every route stays mounted" premise and relocates
assertCapabilityTotality from a boot check to a per-mount transaction.

it also records a rejected experiment with evidence — a postgres schema per
plugin works completely, including cross-schema FK, idempotent push and
DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8
needs schemaFilter naming every schema, contradicting its own docs, and without
it push reports "No changes detected" and creates nothing. a plugin install that
reports success and makes no tables is the exact failure shape we have hit three
times this week.

and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no
grants in the database. offscale is permanently standalone, so it never comes
back. the invite flow is unaffected — the phone claims from the Companion, not
from officer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:37:55 +00:00
pastilhasandClaude Opus 5 d000cedf2f let anyone toggle hidden files again
the show/hide dotfiles button was dead for members. they open the browser at
their own home, that home is `/`, and the toggle is disabled at `/`.

it was never meant to apply to them. 74894b0c wrote it as

    user?.role === 'Super Admin' && currentPath === '/'

to keep the OWNER's home root readable — it is all .bashrc and .ssh and
.claude. then 044aacf4 removed the multi-user surface, dropped users.role, and
noted in its own message that "every role === 'Super Admin' check was
permanently true". so it folded the conjunct away and left `currentPath === '/'`.

correct on a single-user server. multi-user came back on 2026-08-07 and this
line did not come back with it, so a rule about one account's home quietly
became a rule about everyone's — and members feel it constantly, because
members are always at their root.

removed rather than restored to owner-only: the point was a tidy default, not a
prohibition, and `files/showHidden` already defaults to false. so dotfiles stay
hidden until asked for, everywhere, for everyone — and the asking now works.

the server never filtered dotfiles; readdir returns them and always did. this
was only ever the client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:11:05 +00:00
pastilhasandClaude Opus 5 336e718463 read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.

nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.

confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:

    user:officer:rwx    #effective:---
    mask::---

claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.

no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.

so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.

the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.

also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.

verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:58:36 +00:00
193 changed files with 5596 additions and 1041 deletions
+7
View File
@@ -63,3 +63,10 @@ scripts/setup/officer-setup/.setup-progress
# the repository has no ecosystem file at all any more, and the next machine
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
ecosystem.config.cjs
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
# rewritten on every install. See servers/plugins/generate.ts.
build/
build.next/
src/apps/officer-web/Plugins.gen.tsx
src/databases/officer_db/src/plugin-schemas.gen.ts
+10
View File
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
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.
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
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.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal.
+226
View File
@@ -0,0 +1,226 @@
# Extracting a feature into a plugin
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
**Read first, in this order:**
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
3. `plugins/offscale/` — the worked example, all four parts
4. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
---
## The rules. These are not preferences
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
of any sort. The platform's answer is uniform; what a grant *means* — whose rows a member sees, whether a
resource is shared or per-user — is the plugin's own job, in its own queries.
**Say `permissions`, never the other word.** It already means three things in this codebase.
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
structural is convention — presence is the declaration:
```
manifest.ts required
api/router.ts a backend router, mounted at mountPrefix()
db/schema.ts tables, prefixed <app-name>_
sidecar/index.ts a process (.mjs instead means node)
web/panels.ts panels — REQUIRED with web/
web/layout.ts layout — REQUIRED with web/
```
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
anywhere else means two systems, and only one gets tested.
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
on uninstall. Only deleting a plugin's source can lose its data.
---
## The order that worked
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
2. **Move the backend**: `sidecar/``plugins/<name>/sidecar/`, `api/<name>/router.ts`
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*`
`plugins/<name>/db/`.
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema``users.id` is the one
reference a plugin may make.
4. **Write `manifest.ts`.**
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
and `helpers/clipboard` stay as they are.
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
**both** database barrels (`index.ts` and `schema.ts`).
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
8. **Verify on the live server** — see below.
9. **Commit and push.** Message says what moved, what it found, and what is still open.
---
## Verification — run all of it
```
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
pm2 restart officer
```
Then through `/plugins`, watching PM2 and the browser at each step:
| Step | Expect |
| --- | --- |
| install | streamed log; schema applied; sidecar online; route mounted |
| the plugin's API | answers |
| the plugin's screen | renders as a Workspace |
| dock | tile appears |
| permissions page | its permission is listed, read/write/none |
| disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** |
| `bun db:push` while uninstalled | `No changes detected` — data survives |
| install again | identical to the first install |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
---
## Traps, all of which cost real time once
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
`install.ts`; do not reorder it.
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
pending for the whole protocol.
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
contract change. Regenerate rather than hand-edit.
---
## Music specifically — READ THIS BEFORE STARTING
**Music is bigger than offscale, and one part of it has nowhere to go.** Two others did, and the owner cut
both from tonight's scope — see below.
Mapped 2026-08-15.
**Decide these yourself. Do not stop to ask.** Every one of them has two defensible answers, the owner has
said either is fine, and a corrected decision is cheap where a stalled extraction is not. Record what you
chose and why — in the manifest, in a comment, in the commit message — and keep going. The failure mode to
avoid is not a wrong call; it is a tree left half-moved with a question attached.
### What has no home yet
1. ~~**Two websocket providers.**~~ **OUT OF SCOPE — do not touch cliamp.**
`cliamp` and `cliamp-audio` exist for one thing: running the `cliamp` TUI player on the server and
piping its terminal and its audio (a PulseAudio null sink tapped by `parec`) to the browser. It is a
second, separate playback path and the owner's word is that it is the least important part of music —
fun, wanted eventually, not tonight.
**This is the whole reason the websocket gap does not block this extraction.** Both sockets are already
inert (routes upgrade into commented-out handlers). Leave `sidecar/music/cliamp-ws.ts`,
`sidecar/music/pulse-audio.ts`, `sidecar/music/asoundrc`, `src/servers/api/cliamp/relay.ts` and the two
providers in `server.tsx` exactly where they are, untouched. If the relay's import of
`getMusicServerWsUrl` is the only thing keeping `api/music/router.ts` alive, leave that file too and
say so — a small documented seam is fine.
What music actually is: the `/music` screen, the library, and the phone and tablet apps that stream
from it. That is what has to work.
2. **A global UI overlay.** `MusicPlayerHost` is rendered by `DashboardLayout` on **every route**, not
inside a panel, and gates itself on `can('music')`. A plugin contributes panels and a layout; there is
no "render this everywhere" slot, and inventing one is a platform change.
3. ~~**A dashboard widget.**~~ **OUT OF SCOPE — leave it where it is.**
`src/workspaces/widgets/MusicPlayer/` sits in a third workspace package and is wired through
`WidgetRegistry`. Plugins cannot contribute widgets and are not going to learn how tonight. Leave the
directory, the registration and the barrel export alone; note the seam in the manifest.
### What else differs
- **External binaries**: with cliamp out of scope, what the plugin still needs is `ffmpeg` and `ffprobe`,
for tag reading, cover compression and poster frames. A manifest has no way to declare a host
dependency; note it and move on. (`cliamp`, `parec`, `pulseaudio`, `pactl` and `asoundrc` stay behind
with cliamp.)
- **Range requests**: `stream-audio.ts` does 206 / `Content-Range` / 416 and a custom `X-Audio-Duration`;
the proxy runs at `timeoutSeconds: 1800` for reindexes. Verify `Range` survives the hop.
- **Cross-capability**: `MusicBrowser` calls `/file-browser/ls`, not the music sidecar.
- **A CLI tool**: `scripts/reindex-music.ts` reads `DATA_PATH/music/.server`.
- **Music is the platform's worked example.** `registry.ts` explains `personal` through it and
`registry.test.ts` tests the mechanism *through the music capability*. Those tests must be rewritten
against something else, not deleted.
- **Already half-disabled**: `hono.ts` mount and `schema.ts` export are commented out since 2026-08-13;
`officer_db/src/index.ts:49` is still live. So the tree is mid-migration already.
### The per-user model is NOT tonight's work
`personal: ['/favorites', '/now-playing', '/playlists', '/queue']` on music's registry entry, the four
user-scoped tables, and the question of what a member's grant *means* — **all of it stays exactly as it
is. Do not design it, do not improve it, do not think about it.** It will be built inside the plugin
later, which is the whole reason the platform's answer is a uniform read/write and nothing more.
Two facts that make leaving it alone safe rather than lazy:
- **The web frontend does not use those routes at all.** Favourites, playlists and now-playing are used
only by the phone and tablet apps. Nothing you can see in a browser depends on them, so nothing in this
extraction can regress by carrying them across untouched.
- `/queue` is in that list and **no such route exists**, in the sidecar or anywhere else. Dead or
aspirational. Carry it as-is; do not investigate.
Move the routes, the tables and the queries verbatim. `userId` keeps arriving from `X-Officer-User` and
the queries keep scoping by it, exactly as today.
**The one mechanical consequence you cannot avoid:** `registry.test.ts` tests the `personal` mechanism
*through* the music entry, and `registry.ts` uses music as its worked example in prose. Removing the
entry breaks those tests. Re-anchor them on another entry that has `personal` — the smallest edit that
makes them green and still test the mechanism. That is a test fix, not a redesign.
### How to decide the three, if you want a default
Each of these is "close the platform gap" or "leave the piece behind". **Closing the gap is better when
you have the runway**, because every later plugin needs it too — but a music that works with cliamp left
in the platform is worth far more than a perfect design that did not land.
| Part | Close the gap | Leave it behind |
| --- | --- | --- |
| ~~websockets~~ | — | **out of scope. Do not spend a minute on cliamp.** |
| ~~widget~~ | — | **out of scope. Leave `widgets/MusicPlayer` exactly as it is.** |
| global overlay | add one slot the shell renders from installed plugins | leave `MusicPlayerHost` in `DashboardLayout`, gated as it already is |
**One open call, then.** The player overlay is the only judgement left, and either answer is fine.
Whatever you pick, the plugin must **install, enable, disable and uninstall cleanly** at the end. A piece
left in the platform is a documented seam; a piece left dangling is a bug.
`assertCapabilityTotality` is **not** part of this. It is worth fixing eventually and the cliamp routes
are the live example of the drift it cannot see, but both are someone else's evening.
---
## Still open, platform-wide. Do not rediscover these
- **Websocket providers** — `server.reload({ routes })` proven, never called
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
owner's. Its own job, not the platform's.
+10
View File
@@ -0,0 +1,10 @@
import { createRouter } from '@@/create-router';
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
//
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
export const router = createRouter();
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
+35
View File
@@ -0,0 +1,35 @@
import type { PluginManifest } from '@@/plugins/manifest';
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
// the smallest thing that is still a real one: a manifest and one route.
//
// Everything structural is convention, so this directory IS the documentation:
//
// manifest.ts you are here — only what a directory listing cannot say
// api/router.ts exports `router`; mounted at /api/example
// db/schema.ts tables, if it had any (every name prefixed `example_`)
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
// web/Router.tsx a frontend, if it had one
//
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
// code sits.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Example',
summary: 'The reference plugin — one route, nothing else',
icon: 'Puzzle',
color: '#94a3b8',
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
// the interesting case, because it is the one the permission gate actually has to resolve.
permissions: [
{
key: 'example',
label: 'Example',
description: 'The reference plugin',
},
],
};
+24
View File
@@ -0,0 +1,24 @@
// The reference sidecar: a long-lived process PM2 supervises.
//
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
// it, and it is the property that makes install-without-restart possible on the platform side too.
//
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
// installer, and adding a socket here would test Bun rather than that.
const name = 'officer-example';
console.log(`[${name}] started (pid ${process.pid})`);
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
const shutdown = (signal: string) => {
console.log(`[${name}] ${signal} — exiting`);
clearInterval(beat);
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+23
View File
@@ -0,0 +1,23 @@
import { useParams } from 'react-router';
// The second panel, reading the URL rather than being told by its sibling.
//
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
// independently; nothing is passed between them, so they cannot disagree.
export const ExampleDetail = () => {
const { section } = useParams();
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Section from the URL: <code>{section ?? '(none)'}</code>
</p>
<p className="mt-3 text-xs text-duck-dark/40">
Try <code>/example/anything</code> this panel reads it from <code>useParams</code>, with no state passed from
the panel beside it.
</p>
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
//
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
export const ExampleOverview = () => {
const client = useClient();
const { data, isLoading } = useQuery({
queryKey: ['example', 'ping'],
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
});
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
<p className="mt-1 text-sm text-duck-dark/60">
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
</p>
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
{isLoading ? <span className="text-duck-dark/40"></span> : <span>{JSON.stringify(data)}</span>}
</div>
</div>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
//
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
// mismatch falls back rather than rendering another plugin's panel inside this screen.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'example-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
],
};
+16
View File
@@ -0,0 +1,16 @@
import { Puzzle, ListTree } from 'lucide-react';
import type { AppRegistryMeta } from 'officerdev';
import { ExampleOverview } from './ExampleOverview';
import { ExampleDetail } from './ExampleDetail';
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
//
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
// a rule someone has to remember.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
];
+732
View File
@@ -0,0 +1,732 @@
# Offscale — the first real plugin
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
history of something it no longer knows exists.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
stock. The distinct name marks a distinct product, not a badge on someone else's.
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
---
## The reframe
**Core is `officer` and nothing else. Everything else is a plugin**`officer-pty`, `officer-opencode`,
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
intuition is that it is one plugin requiring two sidecars.
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
written on runs five.
### Two words, because "core" was doing two jobs
- **baseline** — what a fresh install actually runs
- **first-party** — what Officer Dev publishes
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
"is X core?" a question with two answers.
---
## What a plugin is made of
Combined per plugin as needed. **Only `meta` and the ID are always required.**
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
- an **ID** (see below)
- a **sidecar**
- a **backend router** and its routes
- a **db schema**
- **default permissions per user group**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **capabilities added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
mount, whether its sidecar is in the ecosystem file, and so on.
### What offscale needs
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
---
## Identity and routing
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
across two namespaces means whichever is weaker becomes the real attack surface.
**Uniqueness comes from two mechanisms**, because one is not enough:
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
generic as `notes` gets refused: it is a name Officer Dev may want later.
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
Needed because a private plugin never asks the marketplace anything.
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
restrictions; publishing is what invites review.
### Mount prefixes
```
first-party /api/<app-name> e.g. /api/offscale
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
```
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
special case in the router, a bypassed check, a different install branch — first-party and third-party
become two systems, and only one of them gets tested.
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
adding core routes forever without breaking installs.
---
## The database
**Tables live in `public`, prefixed with the app-name**`offscale_servers`, exactly as the codebase
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
### A Postgres schema per plugin was tested and rejected
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
earns. Recorded so nobody re-runs the experiment:
| Property | Result |
| ----------------------------------------------------------------- | ------------------------- |
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
| Cross-schema FK to `public.users` | works |
| Partial unique index preserved | works |
| Push is idempotent, no spurious re-creation | works |
| Cascade delete across the schema boundary | works |
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
naming the schema in `schemaFilter` made the identical push work.
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
costume of a normal result.
---
## Mounting — rebuild and swap, at runtime
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
### What was actually tested
| Router | `app.route()` after serving has begun |
| -------------------------------- | --------------------------------------------------------------------- |
| `SmartRouter` _(Hono's default)_ | **throws**`Can not add a route since the matcher is already built` |
| `RegExpRouter` | **throws**, same reason |
| `TrieRouter` | works |
| `PatternRouter` | works |
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
remove a route**, which uninstall needs.
### The approach that solves both
Rebuild the whole app from the current plugin set and **reassign the variable**:
```ts
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
// install: app = buildApp([...installed, 'offscale'])
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
```
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
```
no plugins /offscale/x -> 404 | /core -> 200
installed /offscale/x -> 200 | /core -> 200
uninstalled /offscale/x -> 404 | /core -> 200
```
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
### The one line that has to change
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
This is the whole mechanical cost.
### Websockets are a separate table, and they reload
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
```
before reload /api/offscale/ws -> refused | /core -> 200
after reload /api/offscale/ws -> CONNECTED | /core -> 200
after remove /api/offscale/ws -> refused | /core -> 200
```
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
before a plugin install can interrupt somebody's terminal.
The two tables remain two lists, which is the same seam as the totality bug below.
### What this means for `assertCapabilityTotality`
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
where mounting actually happens instead of once at start-up.
Two things it must survive, both live today:
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
uninstalled plugin's routes are not mounted, so nothing can reach them.
- The check is currently **fed the wrong list**`Object.keys(handlers)` from `server.tsx`, while Bun
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
still needs pointing at reality.
---
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
platform's to assign.
### The platform grants read or write. Everything richer is the plugin's own job
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
the surface; `write` permits everything.
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
means; the platform only knows whether this account got through the door.
### Offscale v1 uses that model exactly, with nothing added
One shared resource, role-gated:
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
- **write** — can change them, including deleting a server the owner registered
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
use is read for most roles. Say Developers get `read` and nobody gets `write`.
Two implementation consequences, both inside the plugin:
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
- `POST /ssh-test` — a reachability probe that mutates nothing
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
delete, policy `PUT` — is a genuine write.
### Music is where the richer model gets designed
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Three different things are called "capability" here
A manifest needs three names, not one:
1. `capabilities/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `capabilities: ['music']`**routing keys** for `sendCommand`
Offscale needs (1) and (3), and not (2).
---
## Secrets
Two stores, and a plugin author will reach for the wrong one unless told:
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
parties touch it.
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
the host:
```ts
import { db } from '../db'; // the connection
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
```
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
the pilot rather than being invented.
---
## `/api/vpn` is being deleted
Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
| surface | one route, `POST /enroll` | the whole admin API |
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
anywhere.** Verified against the mobile monorepo:
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
2. `enroll()` is reached only via `if (embedded) await enroll()`
3. `embedded` is optional and defaults to `false`
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
does the Officer web app. The live database holds no `vpn` grants.
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
### Everything collapses to one namespace
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
contract has no counterparty.
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
- **phone → Companion** — untouched by anything here
- **web admin → Officer → sidecar** — ours to rename freely
### There are THREE components, not two
Easy to miss, and worth stating because two of them contain the word "enroll":
| Component | Repo | Enrolment surface |
| ---------------- | ---------------------------- | ------------------------------------------------ |
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
in the invite (`https://<domain>/officer-api`).
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
`POST /_officer/enroll` handler below it is dead.
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
The exemption must be declared with a reason or the boot check refuses. Not needed today.
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
is correct. The invite flow supersedes it completely:
1. the Officer headscale app holds an admin API key for the Headscale server
2. from it the owner mints an **invite** — a URL pointing at the Companion
3. the Companion turns that into the redirect the phone app claims
4. the device joins
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
---
## What headscale actually is — the inventory
Read end to end on 2026-08-14. This is what has to move.
### Backend — 2,406 lines
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
Three things worth knowing before touching it:
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
object back. One file containing all of it is the model for a plugin's client layer, not something to
undo.
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
branches on whether the body parses.
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
### Frontend — 29 files, 27 endpoints
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
Diagnostics · Console.
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
The whole frontend↔host coupling, which becomes the plugin API:
| Import | Why it matters |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `hooks/useClient``useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
| `helpers/clipboard``copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
| `AppRegistryMeta` | the panel-contribution contract |
| `officerdev``WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
| `../Terminal/Terminal``TerminalView` | **the awkward one** — a code dependency on another panel app |
### `assist.ts` travels, but stays unwired
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
later. Do not tidy it away as unused code.
---
## The manifest — proposal
Written against offscale rather than invented in the abstract, on the principle that a field list designed
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
is the floor, not the ceiling.
```ts
// plugins/offscale/manifest.ts
export const manifest = {
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
publisher: 'officerdev',
/** The plugin's own semver. Updates compare against this. */
version: '1.0.0',
/** Which platforms this build is good for. Refused at install when it does not match. */
platform: '>=1.0.0 <2.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes and ACLs',
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
ownerOnly: true,
},
],
} as const;
```
### THE RULE: every plugin route renders a Workspace with at least one panel
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
```
web/panels.ts exports appRegistryMetas — at least one panel
web/layout.ts exports defaultLayout — how they are arranged
```
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
name and with the reason:
```
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
```
There is deliberately no way to export a component. A plugin that could would be free to render a bare
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
is nowhere to put a screen.
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
than rendering another plugin's panel inside this one.
### Everything the tree can say, the tree says
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
a human chose. Everything structural is convention, and **presence is the declaration**:
| Path | Means |
| -------------------- | --------------------------------------------------------------------------------------------- |
| _the directory name_ | `appName``plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
duplication that could only ever drift.
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
field that can contradict the file it describes.
### Install asks nothing, and that is the default
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
and no external service to point at.
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
how it works today.
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
### Dropped from the first draft
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
when something consumes it.
- **`kind`** — see below.
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
means a tile. Fine for offscale; add a flag the first time something needs it.
### `admin` has to be allowed, and the pilot proved it immediately
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
nothing is gained by forbidding it.
Corrected rule:
| Kind | May a plugin declare it? | Why |
| ----------- | ------------------------ | ---------------------------------------------------------- |
| `app` | yes | the ordinary grantable surface |
| `admin` | yes | self-restriction, never an escalation |
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
| `confined` | **no** | implies a Linux identity the platform provisions |
### One function decides the prefix
`publisher` is the only input, so first-party and third-party cannot become two code paths:
```ts
const mountPrefix = (m: Manifest) =>
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
```
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
### Notes on the fields
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
everything else is bun. One plugin already needs it, so it is not speculative generality.
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
1.0 is expected before anyone outside Officer Dev writes a plugin.
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
degrade. This is for the human reading the store.
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
- **No `migrations`.** Deferred; a field can be added without redesign.
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
---
## What is built — complete, as of 2026-08-15
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
time.
| Piece | Where |
| --------------------------------------- | ---------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Capability registration | `capabilities/registry.ts``setPluginCapabilities` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
whole thing survives a restart because boot regenerates and mounts before `serve()`.
### Three bugs the extraction found
Worth recording because none were visible from reading:
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
has no listener to announce. Install and enable now mount first.
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
applies to Bun's static serving and not to a programmatic `Bun.build()`.
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
### Still open
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
still the hardcoded providers. No plugin owns a socket yet.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
`buildHonoApp`, which is now the single place routes are mounted.
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
plugin system. One when the app store is rebuilt on this.
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
OWNER's rows rather than the caller's, which is a change inside the plugin.
---
## The state of the app store, as found
It **is** the plugin system, roughly 90% built, with one structural hole.
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
> and it is not built."_
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
written, assets publish, the dock tile appears — and the sidecar never starts.
Also found:
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
`bun db:push`.
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
that the directory exists.
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
means editing two files and rebuilding.
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
renders against it. Not to be confused with any of the above.
---
## Where the code lives
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
used in addition rather than instead — they span orgs, which matters because browser extensions under
`extensions/` may become plugins later.
---
## Open questions
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
platform checkout, so dev-time and build-time are the same mechanism.
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
something has to apply schema changes over time. Cheap now, miserable to retrofit.
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
future, not before.
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
`TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
Console section does not travel with the plugin.
5. **`service_connections.service` namespacing** before third parties touch it.
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
@@ -1,4 +1,4 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
// this file must never grow app logic.
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
const proxy = createSidecarProxy({
name: 'headscale',
prefix: '/api/headscale',
prefix: '/api/offscale',
});
export const headscaleRouter = proxy.router;
export const router = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getHeadscaleServerUrl = proxy.getHttpUrl;
@@ -1,7 +1,7 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { db } from 'officerdb/db';
import { headscaleServers } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
import { users } from 'officerdb/auth/schema';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
+47
View File
@@ -0,0 +1,47 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Offscale — Headscale, plus the Companion that ships beside it.
//
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
// distinct product rather than a badge on someone else's.
//
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
//
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
// sidecar/ the whole Headscale contract, holding the admin API keys
// db/ offscale_servers, and the only table this plugin owns
// web/ panels and a layout; the shell renders the Workspace
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
icon: 'Network',
color: '#818cf8',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
// That is a change inside these queries, not a flag on the manifest.
//
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
// full control of the tailnet, which is the owner's decision to make deliberately.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes, keys and ACLs',
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
// Without declaring them a read-level account meets a broken feature where a withheld permission
// should be. Inert while ownerOnly, and correct the moment that changes.
readOnlyWrites: ['/ssh-test', '/policy/assist'],
},
],
};
@@ -1,4 +1,4 @@
import { getActiveHeadscaleCredentials } from 'officerdb';
import { getActiveHeadscaleCredentials } from '../db/queries';
import { createClient, type HeadscaleClient } from './client';
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
import { DATA_PATH } from '@@/data-path';
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
@@ -1,4 +1,4 @@
import type { HeadscaleServerCredentials } from 'officerdb';
import type { HeadscaleServerCredentials } from '../db/queries';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
@@ -1,4 +1,4 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
@@ -1,6 +1,6 @@
import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize';
import { getActiveHeadscaleCredentials } from 'officerdb';
import { getActiveHeadscaleCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { createClient, type HeadscaleClient } from './client';
import { arrayField, toUser } from './normalize';
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
@@ -1,8 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '../../officer-url.mjs';
import { API_URL } from '@@/officer-url.mjs';
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
// DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server
// has more than one user; reached via /api/vpn/enroll.
// has more than one user.
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
// which is deleted. Kept because it is the handler a route under
// /api/offscale would reuse, and because `/enroll/invites` — which
// IS live — dispatches through the same function.
// anything else 404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
@@ -1,4 +1,4 @@
import type { HeadscaleServerCredentials } from 'officerdb';
import type { HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
@@ -7,7 +7,7 @@ import {
deleteHeadscaleServer,
getHeadscaleCredentials,
recordHeadscaleProbe,
} from 'officerdb';
} from '../db/queries';
import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
import { TerminalView } from '../Terminal/Terminal';
import { TerminalView } from 'officerdev';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'headscale-root',
id: 'offscale-root',
direction: 'horizontal',
children: [
{
@@ -1,4 +1,4 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import type { AppRegistryMeta } from 'officerdev';
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
import { HeadscaleNav } from './HeadscaleNav';
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
@@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
// decryptable only there. The browser never sees it and never talks to the companion directly.
const BASE = '/headscale/_officer/companion';
const BASE = '/offscale/_officer/companion';
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
/**
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
const query = useQuery({
queryKey: NODES_KEY,
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
refetchInterval: 20_000,
staleTime: 10_000,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
onSuccess: invalidate,
});
const setTags = useMutation({
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
onSuccess: invalidate,
});
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
const moveToUser = useMutation({
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
onSuccess: invalidate,
});
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
// because Headscale's approve_routes replaces the whole set.
const toggleRoute = useMutation({
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
onSuccess: invalidate,
});
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
const query = useQuery({
queryKey: USERS_KEY,
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
post('/headscale/_officer/users', input),
post('/offscale/_officer/users', input),
onSuccess: invalidate,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
post(`/headscale/_officer/users/${id}/rename`, { name }),
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
onSuccess: invalidate,
});
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
const query = useQuery({
queryKey: KEYS_KEY,
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
staleTime: 30_000,
});
@@ -141,17 +138,17 @@ export function useHeadscaleKeys() {
// (not merged into the list cache) so the view can show it once and deliberately drop it.
const create = useMutation({
mutationFn: (input: CreateKeyInput) =>
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
onSuccess: invalidate,
});
@@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
// drops it. The list is refetched instead, which returns the same invite without its token.
const BASE = '/headscale/_officer/enroll/invites';
const BASE = '/offscale/_officer/enroll/invites';
const INVITES_KEY = ['headscale', 'invites'] as const;
const EMPTY: InvitesListResult = { available: true, invites: [] };
@@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
const POLICY_KEY = ['headscale', 'policy'] as const;
const PATH = '/headscale/_officer/policy';
const PATH = '/offscale/_officer/policy';
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
@@ -12,7 +12,7 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
const SERVERS_KEY = ['headscale', 'servers'] as const;
const EMPTY: HeadscaleServer[] = [];
const BASE = '/headscale/_officer/servers';
const BASE = '/offscale/_officer/servers';
/**
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
@@ -89,7 +89,7 @@ export function useHeadscaleServers() {
export function useHeadscaleSshTest() {
const { post } = useClient();
return useMutation({
mutationFn: (host: string) => post<HeadscaleSshTest>('/headscale/_officer/ssh-test', { host }),
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
});
}
+5 -4
View File
@@ -96,23 +96,24 @@ pkgs_core() {
# separate decision from removing the tool that wanted it.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
echo gnupg git jq wget btop htop tree ripgrep fd eza
# brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
;;
esac
}
@@ -2311,6 +2311,41 @@ EOF
ok "~/.local/bin and ~/.opencode/bin added to PATH"
fi
# ── Keys ──
#
# This block existed only in `src/servers/shell-skel/zshrc`, the file the platform seeds into
# PROVISIONED MEMBER accounts. The owner's .zshrc is assembled here instead, and never got it — so
# the owner had a strictly worse shell than the members they provision: no ctrl-arrow, no
# history-prefix search, no Home/End. Confirmed on this machine before writing it, with
# `zsh -i -c bindkey`: the owner had `^[b`/`^[f` and nothing else.
#
# The two files are still separate — one is a template the platform copies, the other is an
# idempotent append — but the KEYS have to agree, because a member and the owner sit at the same
# web terminal and neither should have to learn which account they are on.
if append_once "$ZSHRC" keybindings <<'EOF'
bindkey -e
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search
bindkey '^[[B' down-line-or-beginning-search
bindkey '^[[1;5C' forward-word
bindkey '^[[1;5D' backward-word
bindkey '^[[1;3C' forward-word
bindkey '^[[1;3D' backward-word
bindkey '^[[3~' delete-char
bindkey '^[[H' beginning-of-line
bindkey '^[[F' end-of-line
bindkey '^[[1~' beginning-of-line
bindkey '^[[4~' end-of-line
bindkey '^H' backward-kill-word
bindkey '^[^?' backward-kill-word
bindkey '^[[3;5~' kill-word
EOF
then
ok "shell keybindings added (ctrl/alt-arrow, history search, Home/End)"
fi
# The eza aliases are GUARDED and the rest are not, for one reason: these
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
# failed to install leaves the owner with no working `ls` at all, in every new
+5
View File
@@ -446,6 +446,11 @@ if ! skip; then
echo " network: ${OFFICER_NETWORK} (already there)"
fi
# The client goes on the HOST, before any of the container work, because it is the half
# that is not in the container. A member has their own Postgres role and no access to the
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
@@ -39,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
PG_PORT="${PG_PORT:-5432}"
# ── The CLIENT, on the host, matching the server in the container ──
#
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
# owner's Docker socket.
#
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
# archive package is not merely old, it is unusable for dumps. That is also why this lives
# beside the server definition rather than in machine-setup's package list — one constant,
# one place to bump.
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
pg_client_installed() {
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
}
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
# just a more annoying one to operate.
install_pg_client() {
local major codename
major="$(pg_client_major)"
[[ -n "$major" ]] || {
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
return 1
}
if pg_client_installed; then
ok "psql ${major} already installed"
return 0
fi
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
[[ -n "$codename" ]] || {
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
return 1
}
install -d -m 0755 /usr/share/postgresql-common/pgdg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
warn "could not fetch the PostgreSQL signing key"
return 1
}
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
>/etc/apt/sources.list.d/pgdg.list
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
warn "postgresql-client-${major} did not install"
return 1
}
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
# matters is whether the binary is there AND is the version we asked for, because apt can
# succeed while holding an older client back.
pg_client_installed || {
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
return 1
}
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
}
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
ensure_docker_network() {
docker_network_exists && return 1
@@ -44,7 +44,6 @@ CORE_PROCESSES=(
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
)
write_ecosystem() {
+6
View File
@@ -24,6 +24,12 @@ bind - split-window -v
unbind r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
# in one TCP frame, which over a websocket relay it always does.
set -sg escape-time 10
# switch panes using Alt-arrow without prefix
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
+19 -2
View File
@@ -5,6 +5,10 @@ import { useAuth } from 'hooks/useAuth';
import { useServerSettings } from 'state/useServerSettings';
import { useServerEnvironment } from 'state/useServerEnvironment';
import { useInitialData } from '@/state/useInitialData';
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
import { plugins as installedPlugins } from './Plugins.gen';
export function App() {
const { isLoading, isAuthenticated } = useAuth();
@@ -59,11 +63,24 @@ export function App() {
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
{installedPlugins.flatMap((plugin) => [
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
// The section pair, exactly as the core screens do it (`/headscale/:section`): the plugin's
// panels read `useParams` themselves, so which section is open is the URL rather than state
// passed between them.
<Route
key={`${plugin.appName}-section`}
path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])}
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
@@ -28,7 +28,7 @@ export function ResetPassword() {
</Card>
)}
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
<div className="text-duck-dark/60">Enter your new password</div>
@@ -105,4 +105,3 @@ const validateForm = (state: Partial<LoginFormState>) => {
if (!email || !password) return false;
return true;
};
@@ -10,9 +10,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
<section className="relative h-dvh snap-start overflow-hidden">
<Background />
<div className="absolute inset-0 z-20">
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
{children}
</div>
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">{children}</div>
</div>
</section>
</div>
@@ -1,5 +1,5 @@
import { DuckAvatar } from "./DuckAvatar";
import { PixelGrid } from "@/components/PixelGrid";
import { DuckAvatar } from './DuckAvatar';
import { PixelGrid } from '@/components/PixelGrid';
import { useGlobal } from 'hooks/useGlobal';
const landscapebg = '/landscape1.webp';
@@ -19,4 +19,4 @@ export function Background() {
{!duckHidden && <DuckAvatar showDebug={false} fullControlMode={false} currentSection={0} />}
</div>
);
};
}
@@ -14,4 +14,3 @@ export const SignoutScreen = () => {
return null;
};
@@ -27,16 +27,33 @@ export const ActivityScreen = () => {
// Poll the registry (harness task files + announced detached jobs).
useEffect(() => {
let alive = true;
const tick = () => get<Registry>('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {});
const tick = () =>
get<Registry>('/activity/tasks')
.then((r) => {
if (alive) {
setReg(r);
setRegLoaded(true);
}
})
.catch(() => {});
tick();
const iv = setInterval(tick, POLL_MS);
return () => { alive = false; clearInterval(iv); };
return () => {
alive = false;
clearInterval(iv);
};
}, []);
// The row backing the open id, and the stream query it implies. A string rather than the row object,
// so the 3s registry poll — which replaces every row — does not tear down and re-open the stream.
const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined;
const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`;
const row = selectedId
? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId))
: undefined;
const query = !row
? null
: row.source === 'harness'
? `task=${encodeURIComponent(row.id)}`
: `path=${encodeURIComponent(row.path)}`;
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
useEffect(() => {
@@ -50,13 +67,18 @@ export const ActivityScreen = () => {
try {
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
} catch { /* ignore */ }
else if (d.kind === 'line' && typeof d.text === 'string')
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
} catch {
/* ignore */
}
};
return () => es.close();
}, [query, token]);
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [lines]);
const rowCls = (active: boolean) =>
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
@@ -68,20 +90,36 @@ export const ActivityScreen = () => {
<ActivityIcon size={16} className="text-primary" /> Activity
</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Background tasks
</div>
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
{reg.tasks.map((t) => (
<Link key={t.id} to={`/activity/${encodeURIComponent(t.id)}`} className={rowCls(selectedId === t.id)} title={t.cwd}>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
<Link
key={t.id}
to={`/activity/${encodeURIComponent(t.id)}`}
className={rowCls(selectedId === t.id)}
title={t.cwd}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
/>
<span className="truncate font-mono text-xs">{t.id}</span>
</Link>
))}
{reg.detached.length > 0 && (
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detached
</div>
)}
{reg.detached.map((d) => (
<Link key={d.id} to={`/activity/${encodeURIComponent(d.id)}`} className={rowCls(selectedId === d.id)} title={d.path}>
<Link
key={d.id}
to={`/activity/${encodeURIComponent(d.id)}`}
className={rowCls(selectedId === d.id)}
title={d.path}
>
<FileText size={13} className="shrink-0" />
<span className="truncate">{d.id}</span>
</Link>
@@ -103,33 +141,54 @@ export const ActivityScreen = () => {
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
{progress.status ? ` (${progress.status})` : ''}
</span>
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
<span className="shrink-0 pl-2">
{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
</span>
</div>
{typeof progress.pct === 'number' && (
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }}
/>
</div>
)}
</div>
)}
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80"
>
{lines.length === 0 ? (
<span className="text-muted-foreground">
{query ? 'waiting for output…' : regLoaded ? (
{query ? (
'waiting for output…'
) : regLoaded ? (
<>
no run called <span className="font-mono">{selectedId}</span> is in the registry it finished, or it never started.{' '}
<Link to="/activity" className="underline">Back to the list</Link>
no run called <span className="font-mono">{selectedId}</span> is in the registry it finished, or
it never started.{' '}
<Link to="/activity" className="underline">
Back to the list
</Link>
</>
) : 'loading…'}
) : (
'loading…'
)}
</span>
) : (
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-words">
{l}
</div>
))
)}
</div>
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a task to follow its live output
</div>
)}
</main>
</div>
@@ -87,7 +87,13 @@ export const TabPreview = () => {
<div className="flex h-full flex-col overflow-hidden">
{/* URL bar */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => void refetch()} disabled={isFetching}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => void refetch()}
disabled={isFetching}
>
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
<form
@@ -104,7 +110,13 @@ export const TabPreview = () => {
placeholder="Navigate to URL..."
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
/>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" type="submit" disabled={isNavigating || !navUrl.trim()}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
type="submit"
disabled={isNavigating || !navUrl.trim()}
>
<Send className="h-3.5 w-3.5" />
</Button>
</form>
@@ -143,7 +155,13 @@ export const TabPreview = () => {
placeholder="Evaluate JavaScript..."
className="flex-1 bg-transparent text-sm outline-none font-mono"
/>
<Button variant="ghost" size="sm" className="h-6 px-2 shrink-0" type="submit" disabled={isEvaluating || !evalExpr.trim()}>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
type="submit"
disabled={isEvaluating || !evalExpr.trim()}
>
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
</Button>
</form>
@@ -14,7 +14,17 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
type Contact = { address: string; name: string };
// A recipient field with contact autocomplete on the last comma-separated segment.
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
const RecipientInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
const client = useClient();
const [suggestions, setSuggestions] = useState<Contact[]>([]);
const [open, setOpen] = useState(false);
@@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
return;
}
const t = setTimeout(() => {
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
client
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
.then(setSuggestions)
.catch(() => {});
}, 180);
return () => clearTimeout(t);
}, [seg]);
@@ -122,14 +135,16 @@ export const ComposeModal = () => {
inlineMap.current.clear();
nextImgId.current = 0;
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
if (editorRef.current)
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
refreshEmpty();
}
if (!draft) seeded.current = null;
}, [draft]);
// Clipboard images often come nameless — give them a sensible filename.
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
const named = (f: File) =>
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => {
@@ -248,7 +263,8 @@ export const ComposeModal = () => {
}
};
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
const fmtSize = (n: number) =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
if (!draft) return null;
@@ -315,7 +331,9 @@ export const ComposeModal = () => {
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/>
<div className="relative flex-1 overflow-hidden">
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message</div>}
{bodyEmpty && (
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message</div>
)}
<div
ref={editorRef}
contentEditable
@@ -362,7 +380,10 @@ export const ComposeModal = () => {
>
<Paperclip className="h-4 w-4" />
</button>
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
<button
onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
</button>
<button
@@ -382,12 +403,21 @@ export const ComposeModal = () => {
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
// is a follow-up: store the Message-Id header on ingest.)
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
export const replyDraft = (m: {
from: string;
subject: string;
date: string;
text?: string;
snippet?: string;
}): ComposeDraft => {
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
const original = (m.text || m.snippet || '').trim();
const quoted = original
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
.split('\n')
.map((l) => `> ${l}`)
.join('\n')}`
: '';
return { to: addr, subject, body: quoted };
};
@@ -63,8 +63,13 @@ type MessagePanelProps = {
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
if (!open) {
return (
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
<button
onClick={onToggle}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
>
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
{senderName(message.from)}
</span>
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
@@ -114,7 +119,11 @@ const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: Me
</div>
)}
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
{message.html ? (
<HtmlBody html={message.html} />
) : (
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
)}
</div>
);
};
@@ -209,7 +218,11 @@ export const EmailReader = () => {
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && (
<FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
<FileViewerProvider
filePath={openAttachment.filePath}
fileName={openAttachment.fileName}
root={openAttachment.root}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader />
</div>
@@ -1,61 +0,0 @@
import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /headscale uses the Workspace/Panel system (like /soulseek and /music): the server picker
// (headscale-servers) above the section nav (headscale-nav) on the left, and the section view
// (headscale-view) on the right. All three talk to the officer-headscale sidecar through the /api/headscale
// auth proxy, which holds no Headscale credentials of its own — the registered servers and their keys live
// in the sidecar.
//
// The open section is :section in the URL, so every panel reads it with useParams instead of passing it
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
// single place that decides what an absent or bogus section means.
function hasAppType(node: LayoutNode, appType: string): boolean {
if (node.type === 'panel') return node.appType === appType;
return node.children.some((c) => hasAppType(c.node, appType));
}
export const HeadscaleScreen = () => {
const { section } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
// cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is
// the part the framework can't do, because it is about a panel that is *missing* rather than wrong.
const workspace = useMemo(() => {
if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace;
return { ...rawWorkspace, value: defaultLayout };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
// default while the address bar says something else — the nav highlight is derived from the URL, so a URL
// that names nothing would leave nothing highlighted.
if (!isHeadscaleSection(section)) {
return <Navigate to={headscaleSectionPath(DEFAULT_HEADSCALE_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
fallback: 'headscale-view',
}}
/>
</div>
);
};
@@ -1 +0,0 @@
export * from './HeadscaleScreen';
@@ -1,8 +1,16 @@
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
import { useParams, Link } from 'react-router';
import {
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square,
ChevronRight, Wrench,
ArrowLeft,
CheckCircle2,
AlertCircle,
Loader2,
StopCircle,
AlertTriangle,
Clock,
Square,
ChevronRight,
Wrench,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
@@ -53,21 +61,58 @@ type JobData = {
// Output entries for the right panel
type OutputEntry =
| { id: string; type: 'text'; text: string }
| { id: string; type: 'tool'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; output?: string; isError?: boolean };
| {
id: string;
type: 'tool';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
output?: string;
isError?: boolean;
};
type ServerMessage =
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| {
jobId: string;
type: 'step:start';
stepIndex: number;
taskName: string;
iteration?: { current: number; total: number; label: string };
}
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: Cost }
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
| {
jobId: string;
type: 'step:parallel';
stepIndex: number;
taskName: string;
iterations: string[];
concurrency: number;
}
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost }
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
| {
jobId: string;
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
stepIndex: number;
iterationLabel?: string;
}
| {
jobId: string;
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
stepIndex: number;
iterationLabel?: string;
}
| { jobId: string; type: 'pipeline:complete'; totalCost: Cost }
| { jobId: string; type: 'error'; message: string }
| { jobId: string; type: 'stopped' }
@@ -86,7 +131,7 @@ const formatElapsed = (seconds: number) => {
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
const formatTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
/** Build a unique key for grouping output by step/iteration */
const outputKey = (stepIndex: number, iterationLabel?: string) =>
@@ -128,15 +173,12 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
<Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
{entry.output !== undefined && (
<StatusIcon
status={entry.isError ? 'error' : 'complete'}
className="h-3 w-3 shrink-0 ml-auto"
/>
<StatusIcon status={entry.isError ? 'error' : 'complete'} className="h-3 w-3 shrink-0 ml-auto" />
)}
{entry.output === undefined && (
<Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />
)}
<ChevronRight className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
{entry.output === undefined && <Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />}
<ChevronRight
className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
</button>
{expanded && (
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
@@ -149,7 +191,9 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
{entry.output !== undefined && (
<div>
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
<pre className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}>
<pre
className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}
>
{entry.output}
</pre>
</div>
@@ -194,9 +238,18 @@ const DEFAULT_LAYOUT: LayoutNode = {
const StepsPanel = () => {
const ctx = useJobPanel();
const {
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
skippedItems, outputMap,
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
jobStatus,
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
} = ctx;
return (
@@ -261,16 +314,16 @@ const StepsPanel = () => {
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" />
<span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
{it.cost && (
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">{formatCost(it.cost.totalUSD)}</span>
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">
{formatCost(it.cost.totalUSD)}
</span>
)}
{itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
</button>
);
})}
{skippedItems.length > 0 && (
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">
{skippedItems.length} skipped
</div>
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">{skippedItems.length} skipped</div>
)}
</div>
)}
@@ -294,7 +347,9 @@ const OutputPanel = () => {
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
{selectedKey && (
<span className="text-xs text-duck-dark/40 truncate">{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}</span>
<span className="text-xs text-duck-dark/40 truncate">
{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}
</span>
)}
</div>
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
@@ -316,9 +371,7 @@ const OutputPanel = () => {
</div>
);
}
return (
<ToolCallEntry key={entry.id} entry={entry} />
);
return <ToolCallEntry key={entry.id} entry={entry} />;
})}
{selectedStreaming && (
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
@@ -365,7 +418,10 @@ export const PipelineJobDetail = () => {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const addCost = useCallback((cost: Cost) => {
@@ -390,9 +446,10 @@ export const PipelineJobDetail = () => {
const arr = prev.get(key);
if (!arr) return prev;
const next = new Map(prev);
next.set(key, arr.map((e) =>
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e,
));
next.set(
key,
arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)),
);
return next;
});
}, []);
@@ -461,181 +518,205 @@ export const PipelineJobDetail = () => {
};
}, [id, job?.status]);
const handleEvent = useCallback((msg: ServerMessage) => {
switch (msg.type) {
case 'job:state':
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
const handleEvent = useCallback(
(msg: ServerMessage) => {
switch (msg.type) {
case 'job:state':
if (
msg.status === 'completed' ||
msg.status === 'failed' ||
msg.status === 'stopped' ||
msg.status === 'interrupted'
) {
setLiveStatus('done');
if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
stopTimer();
if (id) {
client
.get<JobData>(`/pipeline-jobs/${id}`)
.then(setJob)
.catch(() => {});
}
}
if (msg.progress) {
const p = msg.progress as ProgressData;
if (p?.steps) setSteps(p.steps);
}
break;
case 'pipeline:init':
setIsLive(true);
setSteps(msg.steps);
break;
case 'step:start': {
setParallelStep(null);
setActiveStepIndex(msg.stepIndex);
const key = msg.iteration ? outputKey(msg.stepIndex, msg.iteration.label) : outputKey(msg.stepIndex);
if (autoFollowRef.current) setSelectedKey(key);
break;
}
case 'step:complete':
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
setParallelStep(null);
setActiveStepIndex(-1);
if (msg.cost) addCost(msg.cost);
// Flush any remaining stream buffer for this step
flushStreamBuffer(outputKey(msg.stepIndex));
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:parallel':
setActiveStepIndex(msg.stepIndex);
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
// Auto-select first iteration
if (autoFollowRef.current && msg.iterations.length > 0) {
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
}
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) => (it.label === msg.label ? { ...it, status: 'running' } : it)),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'assistant:delta': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const buf = streamBuffers.current;
buf.set(key, (buf.get(key) ?? '') + msg.text);
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
break;
}
case 'assistant:text': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
}
streamBuffers.current.delete(key);
setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
break;
}
case 'tool:start': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
id: randomId(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
});
break;
}
case 'tool:result': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
break;
}
case 'pipeline:complete':
setTotalCost(msg.totalCost);
setLiveStatus('done');
if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
setCompletedSteps((prev) => {
const next = new Set(prev);
setSteps((s) => {
s.forEach((_, i) => next.add(i));
return s;
});
return next;
});
setActiveStepIndex(-1);
setParallelStep(null);
stopTimer();
if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
client
.get<JobData>(`/pipeline-jobs/${id}`)
.then(setJob)
.catch(() => {});
}
}
if (msg.progress) {
const p = msg.progress as ProgressData;
if (p?.steps) setSteps(p.steps);
}
break;
break;
case 'pipeline:init':
setIsLive(true);
setSteps(msg.steps);
break;
case 'error':
setHasError(true);
setLiveStatus('done');
stopTimer();
break;
case 'step:start': {
setParallelStep(null);
setActiveStepIndex(msg.stepIndex);
const key = msg.iteration
? outputKey(msg.stepIndex, msg.iteration.label)
: outputKey(msg.stepIndex);
if (autoFollowRef.current) setSelectedKey(key);
break;
case 'stopped':
setLiveStatus('done');
stopTimer();
break;
}
},
[id, stopTimer, addCost, appendOutput, updateToolOutput],
);
case 'step:complete':
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
setParallelStep(null);
setActiveStepIndex(-1);
if (msg.cost) addCost(msg.cost);
// Flush any remaining stream buffer for this step
flushStreamBuffer(outputKey(msg.stepIndex));
break;
case 'step:skip':
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
break;
case 'step:parallel':
setActiveStepIndex(msg.stepIndex);
setParallelStep({
stepIndex: msg.stepIndex,
taskName: msg.taskName,
concurrency: msg.concurrency,
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
});
// Auto-select first iteration
if (autoFollowRef.current && msg.iterations.length > 0) {
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
}
break;
case 'iteration:start':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'running' } : it,
),
};
});
break;
case 'iteration:complete':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
),
};
});
if (msg.cost) addCost(msg.cost);
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'iteration:error':
setParallelStep((prev) => {
if (!prev) return prev;
return {
...prev,
iterations: prev.iterations.map((it) =>
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
),
};
});
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
break;
case 'assistant:delta': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const buf = streamBuffers.current;
buf.set(key, (buf.get(key) ?? '') + msg.text);
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
break;
}
case 'assistant:text': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
}
const flushStreamBuffer = useCallback(
(key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
break;
}
case 'tool:start': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
id: randomId(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
break;
}
case 'tool:result': {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
break;
}
case 'pipeline:complete':
setTotalCost(msg.totalCost);
setLiveStatus('done');
setCompletedSteps((prev) => {
const next = new Set(prev);
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; });
return next;
});
setActiveStepIndex(-1);
setParallelStep(null);
stopTimer();
if (id) {
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
}
break;
case 'error':
setHasError(true);
setLiveStatus('done');
stopTimer();
break;
case 'stopped':
setLiveStatus('done');
stopTimer();
break;
}
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]);
const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
}
}, [appendOutput]);
},
[appendOutput],
);
const handleStop = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
@@ -648,10 +729,13 @@ export const PipelineJobDetail = () => {
setSelectedKey(key);
}, []);
const panelComponents: PanelComponents = useMemo(() => ({
steps: StepsPanel,
output: OutputPanel,
}), []);
const panelComponents: PanelComponents = useMemo(
() => ({
steps: StepsPanel,
output: OutputPanel,
}),
[],
);
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
const isRunning = displayStatus === 'running';
@@ -659,37 +743,65 @@ export const PipelineJobDetail = () => {
const jobDone = !isRunning && !isLive;
const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? {
stepIndex: progressStepIndex,
taskName: job.progress.parallel.taskName,
concurrency: job.progress.parallel.concurrency,
iterations: job.progress.parallel.iterations.map((it) => ({
label: it.label,
status: it.status as IterationStatus['status'],
})),
} : null);
const displayParallel: ParallelStep | null =
parallelStep ??
(jobDone && job?.progress?.parallel
? {
stepIndex: progressStepIndex,
taskName: job.progress.parallel.taskName,
concurrency: job.progress.parallel.concurrency,
iterations: job.progress.parallel.iterations.map((it) => ({
label: it.label,
status: it.status as IterationStatus['status'],
})),
}
: null);
const panelCtx = useMemo<JobPanelContext>(() => ({
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput,
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef,
}), [
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, job?.status, selectedKey, selectOutput,
displayParallel, skippedItems, outputMap, streamingMap,
]);
const panelCtx = useMemo<JobPanelContext>(
() => ({
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
jobStatus: job?.status ?? 'pending',
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
streamingMap,
outputPanelRef,
}),
[
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
job?.status,
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
streamingMap,
],
);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
);
return <div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>;
}
if (!job) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
<span>Job not found</span>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">Back to jobs</Link>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
Back to jobs
</Link>
</div>
);
}
@@ -699,7 +811,9 @@ export const PipelineJobDetail = () => {
? elapsed
: job.startedAt && job.completedAt
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
: elapsed > 0 ? elapsed : null;
: elapsed > 0
? elapsed
: null;
return (
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
@@ -758,7 +872,9 @@ export const PipelineJobDetail = () => {
<Card className="px-4 py-3 shrink-0 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-950/20">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<span className="text-sm text-red-700 dark:text-red-300">{job.error ?? 'An error occurred during execution'}</span>
<span className="text-sm text-red-700 dark:text-red-300">
{job.error ?? 'An error occurred during execution'}
</span>
</div>
</Card>
)}
@@ -772,4 +888,3 @@ export const PipelineJobDetail = () => {
</div>
);
};
@@ -1,4 +1,4 @@
import { PixelGrid } from "@/components/PixelGrid";
import { PixelGrid } from '@/components/PixelGrid';
const landscapebg = '/landscape1.webp';
export function Background() {
@@ -14,4 +14,4 @@ export function Background() {
<PixelGrid />
</div>
);
};
}
@@ -13,7 +13,14 @@ type BugReportDialogProps = {
onSubmit: (description: string) => void;
};
export const BugReportDialog = ({ open, capturing, submitting, screenshot, onClose, onSubmit }: BugReportDialogProps) => {
export const BugReportDialog = ({
open,
capturing,
submitting,
screenshot,
onClose,
onSubmit,
}: BugReportDialogProps) => {
const [description, setDescription] = useState('');
const previewUrl = useMemo(() => (screenshot ? URL.createObjectURL(screenshot) : null), [screenshot]);
@@ -149,6 +149,7 @@ import {
Clapperboard,
GitBranch,
Store,
Puzzle,
} from 'lucide-react';
/**
@@ -177,10 +178,11 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
];
/**
@@ -0,0 +1,38 @@
import type { LayoutNode, AppRegistryMeta } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
// The screen every plugin route renders. THE PLUGIN DOES NOT RENDER A SCREEN.
//
// ── The rule, and why it is shape rather than policy ──
//
// Every plugin route renders a Workspace with at least one panel. A plugin that exported a component
// could render anything at all — a bare div, a full-page form, its own navigation — and the platform
// would be a shell hosting strangers' layouts rather than one application. So a plugin does not get to
// render the screen: it contributes panels and says how they are arranged, and this renders the
// Workspace around them.
//
// Non-compliance is therefore not refused, it is unrepresentable. There is nowhere to put a screen.
//
// `locked`, like every core screen: a plugin's layout is its author's design, not a workspace the user
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
// something else falls back rather than rendering another plugin's panel inside this one.
export function PluginScreen({
appName,
panels,
layout,
}: {
appName: string;
panels: AppRegistryMeta[];
layout: LayoutNode;
}) {
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
const workspace = useDashboardState<LayoutNode>(`screens/plugin/${appName}`, layout);
const allowed = panels.map((panel) => panel.key);
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked appTypes={{ allowed, fallback: allowed[0] ?? '' }} />
</div>
);
}
@@ -0,0 +1,25 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
//
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
// handler. This screen is the courtesy half of that.
//
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
export const PluginsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
/>
</div>
);
};
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
// and destroy the side-by-side. See docs/navigation-audit.md.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'plugins-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
],
};
@@ -0,0 +1 @@
export * from './PluginsScreen';
@@ -51,7 +51,9 @@ export const ApifyConfig = () => {
<div className="grid gap-5">
{status && (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
<div
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
/>
<span className="text-sm text-duck-dark dark:text-foreground">
{status.configured ? 'API token configured' : 'Not configured'}
</span>
@@ -70,7 +72,12 @@ export const ApifyConfig = () => {
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Get your token at{' '}
<a href="https://console.apify.com/account/integrations" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.apify.com/account/integrations"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
console.apify.com/account/integrations
</a>
</span>
@@ -69,7 +69,9 @@ export const BrowserRelay = () => {
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
{' — '}
<a href="/browser" className="text-duck-teal underline">view tabs</a>
<a href="/browser" className="text-duck-teal underline">
view tabs
</a>
</p>
</div>
</div>
@@ -96,7 +98,9 @@ export const BrowserRelay = () => {
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<li>
Open{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
chrome://extensions
</code>{' '}
in Chrome
</li>
<li>
@@ -139,12 +143,7 @@ export const BrowserRelay = () => {
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Regenerate
</Button>
@@ -166,8 +165,8 @@ export const BrowserRelay = () => {
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
the tab is connected. Then go to{' '}
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
means the tab is connected. Then go to{' '}
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
/browser <ExternalLink className="h-3 w-3" />
</a>{' '}
@@ -189,10 +188,11 @@ type CredentialRowProps = {
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
<code className="flex-1 text-xs truncate">
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
</code>
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
<button
onClick={onCopy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
</button>
</div>
@@ -196,10 +196,7 @@ export const EmailAccounts = () => {
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
return (
<div
key={account.id}
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
>
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="flex items-center gap-3">
<ProviderIcon provider={account.provider} />
<div className="min-w-0 flex-1">
@@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/projectcreate"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
@@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/apis/library"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
API Library
</a>
. Search for and enable each of these:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Gmail API</strong></li>
<li><strong>Google Calendar API</strong></li>
<li>
<strong>Gmail API</strong>
</li>
<li>
<strong>Google Calendar API</strong>
</li>
</ul>
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/branding"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Set <strong>App name</strong> to your organization name or &quot;Officer&quot;</li>
<li>Set <strong>User support email</strong> to your admin email</li>
<li>Add your admin email under <strong>Developer contact information</strong></li>
<li>Click <strong>Save</strong></li>
<li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
</li>
<li>
Set <strong>User support email</strong> to your admin email
</li>
<li>
Add your admin email under <strong>Developer contact information</strong>
</li>
<li>
Click <strong>Save</strong>
</li>
</ul>
</li>
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
If your team uses Google Workspace, select <strong>Internal</strong> no verification needed
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
(required while the app is unverified; limit of 100 test users)
</li>
</ul>
</li>
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/scopes"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
@@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.scope}
</code>{' '}
— {s.description}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.description}
</li>
))}
</ul>
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
<p className="mt-1">
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li>
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/clients"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Application type: <strong>Web application</strong></li>
<li>
Application type: <strong>Web application</strong>
</li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>
Authorized redirect URIs: add{' '}
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
{redirectUri}
</code>
</li>
<li>Click <strong>Create</strong></li>
<li>
Click <strong>Create</strong>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
</li>
</ol>
@@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
</span>
</div>
);
@@ -87,19 +87,25 @@ export const AIModels = () => {
<div className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen
</p>
{renderModelSelect(chatModel, setChatModel, 'System default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard
</p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser
</p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label>
@@ -1,7 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
@@ -96,8 +104,16 @@ export const VoicePreference = () => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onended = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
audio.onerror = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
await audio.play();
setListening('playing');
} catch {
@@ -120,7 +136,9 @@ export const VoicePreference = () => {
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
<div className="flex items-center gap-2">
@@ -129,20 +147,27 @@ export const VoicePreference = () => {
<SelectValue placeholder="Server default" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
<SelectItem value={SERVER_DEFAULT}>
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
</SelectItem>
{groups.length > 0
? groups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
{g.label}
</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
<SelectItem key={v} value={v}>
{prettify(v)}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
<button
@@ -161,7 +186,9 @@ export const VoicePreference = () => {
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
</Label>
</div>
);
@@ -466,7 +466,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null);
@@ -496,7 +498,8 @@ export const AIHarnessesSection = () => {
{editingProvider &&
(() => {
const provider = CHAT_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId)) return null;
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
return null;
return (
<div key={provider.providerId} className="flex items-center gap-2">
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
@@ -507,7 +510,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1"
placeholder="Enter API key"
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null);
@@ -81,9 +81,7 @@ export const SMTPSection = () => {
provider,
fromName,
fromEmail,
...(provider === 'resend'
? { apiKey }
: { host, port: parseInt(port) || 587, username, password, secure }),
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
});
const handleSave = async () => {
@@ -109,7 +107,11 @@ export const SMTPSection = () => {
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTestingConnection(false);
@@ -120,7 +122,10 @@ export const SMTPSection = () => {
if (isTesting || !testEmail) return;
setIsTesting(true);
try {
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail });
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', {
...buildConfig(),
to: testEmail,
});
if (result.error) {
toast.error(result.error);
} else {
@@ -129,7 +134,11 @@ export const SMTPSection = () => {
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Failed to send test email';
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTesting(false);
@@ -5,7 +5,15 @@ import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
type Provider = 'openai' | 'elevenlabs';
@@ -47,7 +55,11 @@ export const TTSSection = () => {
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
setVoicesLoading(true);
try {
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
const res = await client.post<{
voices?: string[];
groups?: { label: string; voices: string[] }[];
error?: string;
}>('/server-settings/tts/voices', {
provider: p,
url: u,
apiKey: key || undefined,
@@ -167,7 +179,9 @@ export const TTSSection = () => {
)}
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key {provider === 'openai' ? '(optional)' : ''}</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
API Key {provider === 'openai' ? '(optional)' : ''}
</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -197,7 +211,9 @@ export const TTSSection = () => {
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
/>
</button>
</div>
{voices.length > 0 ? (
@@ -209,16 +225,21 @@ export const TTSSection = () => {
{voiceGroups.length > 0
? voiceGroups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
{g.label}
</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
<SelectItem key={v} value={v}>
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
@@ -37,13 +37,17 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
isActive
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<section.icon
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
@@ -53,7 +57,14 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
</NavLink>
);
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
export const SettingsSidebar = ({
basePath,
icon: Icon,
label,
sections,
groups,
hideHeader,
}: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const query = search.toLowerCase();
@@ -71,7 +82,12 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div>
)}
<div className="px-3 pb-2">
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
<Input
placeholder="Search..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
{groups
@@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div>
);
})
: sections.filter(matchesSearch).map((s) => (
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
: sections
.filter(matchesSearch)
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
</div>
</div>
);
@@ -155,10 +171,22 @@ type CreateSettingsPanelParams = {
groups?: SettingsSectionGroup[];
};
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
export const createSettingsPanelComponents = ({
basePath,
sidebarIcon,
sidebarLabel,
sections = [],
groups,
}: CreateSettingsPanelParams) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const Sidebar: ComponentType = () => (
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
<SettingsSidebar
basePath={basePath}
icon={sidebarIcon}
label={sidebarLabel}
sections={allSections}
groups={groups}
/>
);
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
return { Sidebar, Content, allSections };
@@ -1,4 +1,6 @@
export * from './AppStore';
export * from './Plugins';
export * from './PluginScreen';
export * from './Layout';
export * from './Home';
export * from './PasskeyGate';
@@ -13,7 +15,6 @@ export * from './Calendar';
export * from './Contacts';
export * from './Music';
export * from './Soulseek';
export * from './Headscale';
export * from './Photos';
export * from './Jellyfin';
export * from './Transmission';
+6 -1
View File
@@ -3,6 +3,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { seedAppRegistry, seedWidgetRegistry } from 'officerdev';
import { plugins } from './Plugins.gen';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ColorModeProvider } from '@/components/ui/ThemeProvider';
import { I18nBridge } from '@/lib/I18nBridge';
@@ -20,7 +21,11 @@ const queryClient = new QueryClient({
// Before the first render, not from a component inside it: a panel that renders before its registry is
// populated draws an empty box, and `useGlobal`'s initialData gives no second chance to fill it.
seedAppRegistry(queryClient);
// Panels contributed by installed plugins, from the generated module. See servers/plugins/generate.ts.
seedAppRegistry(
queryClient,
plugins.flatMap((p) => p.panels),
);
seedWidgetRegistry(queryClient);
const elem = document.getElementById('root')!;
+16 -4
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Officer Dev (Alpha)</title>
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
<meta
name="description"
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
/>
<!-- OpenGraph. Crawlers fetch these standalone, so they need absolute URLs. __PUBLIC_URL__ is
substituted from .env by scripts/gen-index.ts into index.gen.html, which is what the server
@@ -12,7 +15,10 @@
<!-- OpenGraph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Officer Dev (Alpha)" />
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
<meta
property="og:description"
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
/>
<meta property="og:image" content="__PUBLIC_URL__/og-image-v3.jpg" />
<meta property="og:image:secure_url" content="__PUBLIC_URL__/og-image-v3.jpg" />
<meta property="og:image:width" content="1200" />
@@ -24,7 +30,10 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Officer Dev (Alpha)" />
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
<meta
name="twitter:description"
content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted."
/>
<meta name="twitter:image" content="__PUBLIC_URL__/og-image-v3.jpg" />
<!-- Favicons & App Icons. These must stay absolute: Bun's HTML bundler treats a root-relative
@@ -38,7 +47,10 @@
<meta name="theme-color" content="#1F2620" />
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init(); eruda._entryBtn.hide();</script>
<script>
eruda.init();
eruda._entryBtn.hide();
</script>
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
+1 -1
View File
@@ -23,10 +23,10 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
+2 -2
View File
@@ -1,2 +1,2 @@
@import "./globals.css";
@import "./prose.css";
@import './globals.css';
@import './prose.css';

Some files were not shown because too many files have changed in this diff Show More