Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 03fbb1189f release 2026.08.29.03 — the todo board leaves, a plugin can claim a file type
no schema change and no dependency change, and the plan says so with the two
commands that prove it rather than the claim alone. so there is no db:push step and
no bun install step, and the plan says neither should be improvised. ten commits,
all code.

THE BEHAVIOUR CHANGE IS NAMED RATHER THAN DISCOVERED: /todos now 404s. the board is
the kanban plugin, in the marketplace, installed nowhere by default — the intended
state. `role_permissions` rows naming `todos` are orphaned and the plan says
explicitly NOT to clean them up: absence denies, a row naming a permission that does
not exist grants nothing, and a data edit nobody asked for does not belong in a
rollout.

the plan adds one verification this release earns: an `ls` call per host, because
the listing route changed to union plugin-claimed file types with core's own. a 500
there is a failed host.

surveyed the fleet while writing it. av-club is installed on fofos ALONE, so fofos
is the only host that gains HEIC — everywhere else the file browser is exactly what
it was, which is the property the extension point was built for and worth stating so
its absence elsewhere is not read as a fault.

edge-pertento-bridge did not answer the survey. not listed as skipped on that
evidence — one quiet moment is not a down host, and the last time this fleet assumed
otherwise it cost four attempts against a machine that was fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:03:26 +01:00
pastilhasandClaude Opus 5 21665ed024 HEIC opens, thumbnails retry, and both views ask for the same picture
three reports, three causes, none of them the one I would have guessed.

"FAILED TO LOAD IMAGE" ON DOUBLE-CLICK. the viewer pointed `<img>` at /raw — the
HEIC bytes — and no browser decodes those. `render` was declared on av-club and
nothing called it. so core gains /file-browser/render, which forwards a CLAIMED
type to the plugin that claimed it, and the viewer uses it for exactly the formats
no browser can show.

that list lives client-side and is a fact about BROWSERS, not about plugins:
chrome and firefox have never decoded HEIC and safari only does on apple's own
platforms. everything else keeps using /raw, which streams, supports ranges and
paints progressively — all of which a rendered copy gives up.

not cached on disk, unlike /thumb: a full-size render is large, looked at once, and
keyed on a width the viewer picks. caching it would fill a disk to make the second
look at one photograph faster.

"SOMETIMES DON'T ARRIVE THE FIRST TIME". two causes and the second one made the
first irreparable.

the client treated any error as final, so one failure meant a file icon until the
page was reloaded. it retries three times over about seven seconds now, backing off,
with the attempt in the `key` so the element actually remounts — changing only the
src leaves the browser's own failed-request cache in play and the retry is not one.

and the SERVER remembered the failure. that is right for sharp and ffmpeg, where the
file is the problem and will fail identically forever — it is what stops one bad
video spawning work on every scroll. it is wrong for a PLUGIN, which fails while it
restarts, while it is busy behind its own limit, and for a few seconds after an
update. those pass. cached, no client retry could ever get past them, because the
answer came from memory without the plugin being asked again. producer failures are
no longer remembered.

"THE THUMBNAIL SHOULD BE THE SAME IN LIST OR GRID". it was not, and that was mine:
the list asked for 128px and the grid for 256, which are different cache keys. for a
jpeg that is milliseconds. for a HEIC it is four seconds per file, so switching views
showed icons where the other view had just shown photographs. one size now — the list
draws at 28px and the grid at 80, so both downscale either way and 256 is sharp on a
retina screen at both.

verified: render 589KB webp from a 1.4MB HEIC, png correctly 415, thumb 12KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 08:00:00 +01:00
pastilhasandClaude Opus 5 111618bd7f a plugin can claim a file type the platform cannot read
the file browser's thumbnailer read what sharp and ffmpeg read, and that list was a
constant in core. HEIC forced the question: sharp's bundled libheif has no HEVC
decoder, and teaching core to shell out to ImageMagick means the PLATFORM acquiring
a system dependency for one format. that is a plugin's job.

so a manifest may declare `fileTypes` — extensions, plus a thumbnail path and a
render path on its own sidecar. `ls` unions those with core's, so a file only
promises a thumbnail when something can actually produce one. `/thumb` forwards a
claimed type to whoever claimed it.

CORE DOES NOT KNOW av-club EXISTS. it reads a claim at runtime from whatever is
installed and enabled, calls a path by name, and falls back to a file icon when
nothing claims the type. no import, no name, no special case.

PROVEN BY DISABLING IT: heic reports thumb=image with av-club enabled, thumb=none
disabled, image again re-enabled. that is the whole requirement — the file browser
is what it was without the plugin — and it is a test rather than a claim.

the plugin's bytes are cached by CORE, under the same key everything else uses, so
a HEIC thumbnail is produced once and then served from disk like any other. that is
why `buildThumbnail` takes a producer rather than there being a second path beside
it: the cache key with its mtime and size, the concurrency cap and the remembered
failures all live in that one function, and a parallel path would be a second place
to forget the negative caching.

TWO THINGS FOUND BY WIRING IT UP. a claimed-but-not-running sidecar is skipped
rather than recorded, or `ls` promises a thumbnail that cannot be fetched while a
plugin restarts. and the sidecar authenticates: it accepts `x-officer-user` from
anything on loopback, by its own design, so core sends the account the request is
FOR — passing anything else would be lying to a plugin that scopes per user.

verified: HEIC 2 MB in, 12 KB out, 5.4s cold and 101ms cached; png and mp4
unchanged; 1665 tests; docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:50:51 +01:00
pastilhasandClaude Opus 5 9cb875ca79 core stops claiming HEIC it cannot decode
the revert above took the ImageMagick path back out of core, where it should never
have gone: decoding HEIC needs ImageMagick and libheif-plugin-libde265, and the
core has no business acquiring a system dependency for one format. that is a
plugin's job.

this removes the other half of the same mistake. `heic` and `heif` were in core's
extension list from the day the thumbnailer was written, which made `ls` promise a
thumbnail sharp cannot produce — so every HEIC in a folder cost a request that
422'd before falling back to the icon, and the promise was the only reason the
request happened.

so without a plugin the file browser is now what it was: a file icon for a HEIC,
and no wasted round trip earning it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:41:17 +01:00
pastilhas c5b95becd2 Revert "HEIC gets a thumbnail, through the decoder that can actually read it"
This reverts commit f4b9596a88.
2026-08-29 07:39:52 +01:00
pastilhasandClaude Opus 5 f4b9596a88 HEIC gets a thumbnail, through the decoder that can actually read it
sharp ships its own libvips and libheif built WITHOUT the HEVC decoder, so a HEIC
parses its header, reports 4032x3024, and then fails on the pixels with "bad seek".
that reads like a corrupt file and is not one — every photograph an iPhone takes is
HEVC-coded HEIF, so it is most of what arrives from a phone.

ImageMagick links the SYSTEM libheif, which has the codec. so a `.heic` goes
through `convert` to a lossless PNG and sharp takes it from there. PNG rather than
JPEG for the intermediate so the single real encode is the final one and the
thumbnail is not a copy of a copy. removed in a `finally` either way — a failed
decode must not leave a stray file a later run mistakes for a finished thumbnail.

this is deliberately the same approach av-club has used for weeks
(sidecar/sharp/decodable.ts), on these exact files. copied rather than shared,
because the platform must not import from a plugin — the dependency only runs the
other way, and fifteen lines of spawn is cheaper than inverting it.

`convert` exits 0 having written nothing when the codec is missing, so the OUTPUT
FILE is the test rather than the exit code. that is not defensive: it is precisely
the failure this exists to handle.

IT NEEDS `libheif-plugin-libde265`, which is not in a default Ubuntu install —
libheif packages its codecs separately and ships the AV1 one, not the HEVC one.
declared in av-club's manifest today, and installed on this host to prove the path.
without it, `convert` fails the same way sharp does and the browser falls back to
the file icon, which is the correct degradation rather than a break.

the in-memory failure cache did exactly what its comment said it would: the HEIC
stayed 422 after the codec was installed, until a restart. "a restart is the
natural moment to give a file another chance after somebody has installed a codec"
— written weeks ago, met today.

verified: a real iPhone HEIC, 2 MB in, 12 KB out, 5.3s cold and 22ms cached, and
the image is right. png, svg and mp4 unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:36:32 +01:00
pastilhasandClaude Opus 5 09518e1e1f the three files fb1cf0f6 was supposed to carry
fb1cf0f6 deleted the todo router, the board and the screen but did NOT remove the
imports of them. so a checkout of that commit has hono.ts importing
`./api/todos/router`, which is not there — and the server cannot start.

fofos hit it within a minute of pulling. it was down until this commit.

WHY IT PASSED EVERY CHECK HERE: the files were modified in my working tree and
never staged, so this machine typechecked, tested and ran against the fix while the
commit shipped without it. every green light I reported was true of the tree in
front of me and false of the thing I pushed.

HOW IT HAPPENED, precisely, because the shape is worth knowing: the `git add` naming
these three also named a path that no longer existed. `git add` ABORTS ON AN
UNMATCHED PATHSPEC — it does not stage the rest and warn. It staged nothing. the
`git status` I printed straight afterwards showed them as ` M`, unstaged, and I read
the output as confirmation rather than as the warning it was.

the lesson is not "look harder at git status". it is that a commit which deletes a
module and a commit which stops importing it must be the same commit, and the only
thing that proves it is a check against the COMMIT rather than the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:19:00 +01:00
pastilhasandClaude Opus 5 500bb925eb the directory picker becomes shared, because two plugins wanted it
it was written inside fofos-admin, and then kanban's project form asked for a path
and got a TEXT FIELD — because a plugin cannot import from a sibling plugin and I
took the cheap way out rather than moving it. that is the wrong trade for a
component whose entire job is not letting somebody type a path wrong.

nothing in it was fofos-specific: it browses the owner's home over the platform's
own /file-browser/ls and /mkdir. so it moves to the officerdev barrel that plugins
already import from, and both use the one.

it takes the platform's `errorText` rather than fofos-admin's `readableError`,
which knew about that plugin's three particular failures — unconfigured,
unreachable, refused. here there are two: cannot read that folder, or not allowed.

copying it instead would have left two of them to drift, and the version that
drifts is always the one somebody is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:09:37 +01:00
pastilhasandClaude Opus 5 a970cb7c5f kanban joins the catalogue
it was on disk and discovered but not in BUNDLED, so the plugins page classified it
as "a developer working on one" rather than something anybody could install. that
is the file's own three-state vocabulary working correctly on an entry I forgot to
write — building the plugin and creating its repository does not put it in a
catalogue that is still a hand-maintained constant.

the entry repeats the manifest because a remote plugin has no directory to read:
everything shown BEFORE installing has to be declared here, including the two things
worth seeing before consenting — the permissions it wants and the binaries it would
make the machine acquire. kanban wants one permission and no binaries.

`schema: true` for one table, and the entry says what is in it: a list of FOLDERS.
the items are markdown on disk and this plugin keeps no copy, so an uninstall with
"delete its data" ticked loses the list and never a line of anybody's work. that is
the thing somebody should be able to read before they install it.

`sidecar: false` — it reads and writes files with node's own fs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:03:02 +01:00
pastilhasandClaude Opus 5 fb1cf0f6f1 the todo board leaves the platform
`/todos` is now the `kanban` plugin. removed here: the router, the screen, the
route, the TodoBoard panel, its AppRegistry entry and the `todos` permission.

WHAT STAYED, and it is a seam rather than an oversight: `todo/` and
`scripts/todo-index.ts`. that folder is the platform's OWN backlog, committed with
the platform's own code, and the CLI regenerates TODO.md from a terminal with no
Officer running. moving either would have made this repository's own documentation
depend on a plugin being installed.

so `todo-tree.ts` stays too, with one caller instead of two, and there are now two
copies of that logic which will drift. recorded in the file itself and in the
plugin's README rather than discovered in six months. the honest fix on the day it
matters is to delete the CLI and let the plugin own the format.

THE TOTALITY CHECK EARNED ITS KEEP. removing the router left `todos` in
permissions/registry.ts and the server REFUSED TO BOOT — exactly what that guard is
for. a stale permission naming a route that no longer exists is the kind of thing
that otherwise sits there for months granting access to nothing.

docs:check then caught two more: the generated platform skill still listed `/todos`,
and AGENTS.md claimed 21 permission entries against a registry now holding 20. both
fixed, and the AGENTS.md note now explains the drop the same way it already explains
caldav's.

verified: server boots, `bun todo:index` still regenerates TODO.md, 1665 tests pass
(up from 1657 — the ported tree tests run in the plugin now), typecheck clean,
docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 03:29:58 +01:00
pastilhas a947df8d9d deploy report for 2026.08.29.02 — 7 of 7, plugin columns everywhere 2026-08-29 03:19:40 +01:00
pastilhasandClaude Opus 5 78bf63c209 release 2026.08.29.02 — a plugin can ship a commander
THIS ONE CHANGES THE DATABASE, and the plan says so with the command that proves
what the change is rather than the claim alone:

  command_hq_sessions
    + plugin         text   null
    + plugin_config  jsonb  null

both nullable, no default, no constraint — so every existing row is valid the
moment they exist and there is nothing to backfill. no rename, nothing dropped, no
create-then-constrain, and therefore no --force needed or permitted.

THE PLAN DEMANDS A QUERY, NOT AN EXIT CODE. drizzle-kit push can fail and still
exit 0, so step 3 is a verification that must print exactly `plugin plugin_config`
and whose OUTPUT goes in the report per host — not "schema ✓". I ran that exact
command here before asking seven hosts to run it.

NO bun install step: package.json and bun.lock are untouched since .01. that also
sidesteps the step-order bug the last rollout found, where the standard
plugins:update runs before a plan's install and imports a package that is not there
yet.

the backup step reads the env variable name rather than assuming it. several hosts
use OFFICERDEV_POSTGRES_URL and not POSTGRES_URL, which is the kind of detail that
turns a backup step into a file containing an error message.

av-club's commander template reaches each host through the standard plugins:update
step rather than through this plan — worth saying, because av-club moving is
expected here rather than a surprise.

gate before tagging: typecheck clean, docs:check green, locale parity green. the
ModelSelector test still fails and is still not mine — same chat code, untouched by
this range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 03:12:45 +01:00
pastilhasandClaude Opus 5 347acf0171 Command HQ shows what plugins offer, and what has not been started
the UI half. a row per plugin that ships commanders, below the owner's own
categories and separated from them — because they are not the same kind of thing.
a category is the owner's filing and they may rename it; a plugin is where
something came from and they may not. mixed into one list, "av-club" reads as a
folder somebody made, and the first thing you do with a folder you made is rename
it.

offers sit ABOVE the grid rather than in it. a commander in the grid runs, has a
transcript and has cost money; an offer is a description on disk. drawn as the same
card with a badge, the two look interchangeable, and the first consequence is
somebody hunting for the transcript of a thing that has never run.

an offer STAYS after it is used. it is a template and there is no reason to have
only one — two folders watched for two formats is two commanders from one offer,
and hiding it would make the second look impossible.

the form is drawn from the template's own `inputs`. nothing in the component knows
what av-club wants: options become buttons, boolean becomes yes/no, everything else
is a field. the button names what is missing rather than marking it red.

TWO REAL BUGS FOUND BY WIRING IT UP, both in the list route:

`?category=plugin:av-club` reached `Number('plugin:av-club')` — NaN — and the query
compared `category_id = NaN`. that matches nothing and looks exactly like "this
plugin has no commanders", which is the worst way for a filter to fail. parsed
before the numeric branch now.

and the grid DROPS every row without a transcript. that was right when the only way
to get a row was to start a conversation; it is wrong now, because a commander from
a template arrives deliberately not running — so the thing somebody just created was
invisible, under a toast saying it worked. a row with a `plugin` and no session is
now projected from the row itself, with the zeroes that are true of it, and sorted
FIRST: it is the one that needs a decision. ordinary rows keep the old behaviour,
since a hand-made row with no transcript is a conversation that failed to start and
the design refuses to draw a tombstone for it.

four locale keys, all six languages, parity test green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 03:10:29 +01:00
pastilhasandClaude Opus 5 766b18b7f9 create a commander from a plugin's template, and refuse to arm a blank one
second half of the backend. GET /command-hq/plugin-commanders lists what the
installed plugins offer; POST /commanders takes {plugin, template, config} and
builds one from it.

the list is SEPARATE from GET /commanders, which returns what exists. merging a
thing that runs with a thing that could be made, distinguished by a flag, means
every consumer has to remember which it is holding.

`watch_dir` doubles as the reference path when a template supplies one. asking for
a reference path AND a watch directory is asking the same question twice and
inviting the two answers to disagree.

THREE FILES, EACH A COPY rather than a reference into the plugin:

  state/config.env   what the owner answered, in the form the watcher sources
  watch.sh           copied, so plugins:update cannot change the script a commander
                     is already running — a watcher whose code moves under it while
                     armed is a thing nobody could debug
  AGENTS.md          the template's prose, appended to the brief already written

config.env is generated with every value SINGLE-QUOTED, and that is not decoration:
WATCH_DIR is a path somebody typed, and this fleet has directories called `ABOUT US`
and `70´S`. unquoted, the first space ends the assignment and the watcher watches
the wrong folder — with `rm` downstream of that. verified by sourcing both that path
and one containing an apostrophe in a real shell.

THE ARM GUARD LIVES IN armCommandHqWatcher, not in a route. arming is not a button:
it happens when the agent starts a tracked background job and the platform notices.
a check anywhere else is one the agent routes around by simply starting its loop,
which is exactly what it does. a row with no template is unaffected.

verified end to end on the running server: the template listed, a commander created
from it, watch.sh + config.env (mode 600) + the brief on disk, config.env sourcing
cleanly, and the watcher refusing to start when config.env is absent — which is the
configure-before-running rule enforcing itself at the last line of defence.

still to come: the Command HQ section that shows the offers and draws the form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 02:58:04 +01:00
pastilhasandClaude Opus 5 e4f5e03105 a plugin can offer a commander, and offering is not starting
first half: the discovery and the two columns. no routes and no UI yet.

PRESENCE IS THE DECLARATION. a plugin with commanders/<name>/COMMANDER.md offers
one, and manifest.ts says nothing — same as tasks/ and skills/, and for the same
reason: the directory already says it, and a second place to say it is a second
place for the two to disagree. read in place through capabilitySources, so only an
installed AND ENABLED plugin offers anything and plugins:update changes a template
without republishing.

A TEMPLATE IS NOT A COMMANDER. what a plugin ships is an OFFER: nothing runs,
nothing is scheduled, no directory exists until an owner creates one from it. that
is the shape asked for — a plugin may propose recurring work, only a person may
start it.

`plugin` IS A COLUMN, not a reserved category name. provenance should be
structural: a category called "av-club" is a string an owner can rename, merge or
delete, and the day they do, a plugin's commanders quietly become ordinary ones
with nothing on disk saying otherwise. it also survives an uninstall, which is the
point — the commander is still there and the UI can say where it came from.

`plugin_config` holds the owner's answers because the form has to be RE-OPENABLE.
somebody changing which directory is watched should see the one it is watching, not
an empty form. the watcher script is generated from this, so this is the source of
truth rather than the script.

inputs reuse TASK.md's vocabulary exactly, so the existing form renderer draws them
and the existing validation applies. "which folder, which extensions, what output"
is the same question a task asks.

`missingInputs` is the configure-before-running rule, and it is checked at ARM time
rather than at creation. a half-configured commander that exists and is visible is
useful; an ARMED one is what would run somebody's disk against a blank setting.

columns verified live after db:push: plugin text, plugin_config jsonb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 02:45:08 +01:00
pastilhasandClaude Opus 5 3b0083aec2 deploy report for 2026.08.29.01 — 7 of 7, sharp loads everywhere
Every target host ended at the tag with libvips 8.18.6 and ffmpeg present;
macbruno gained ffmpeg via the plan's per-host brew step. Schema unchanged and
verified as such, so no db:push ran anywhere.

One deviation: plugins:update runs before bun install in the standard steps, and
it imports the server graph, which now reaches thumbnails.ts -> sharp. That fails
on any host where sharp is genuinely new. Ran install first instead — same steps,
other order — and recorded it as a finding for the next plan rather than editing
the prompt mid-rollout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 01:48:43 +01:00
pastilhasandClaude Opus 5 58f50a5240 release 2026.08.29.01 — sharp becomes infrastructure
every other deploy this month was code. this one changes what a machine must have,
so the plan has two verification steps no previous plan needed and one host gets a
command none of the others do.

SCHEMA UNCHANGED, and the plan says so with the command that proves it rather than
the claim on its own:

  git diff --name-only deploy/2026.08.28.01..HEAD | grep -iE 'schema|/db/|databases'

returns nothing. no db:push step exists and none should be improvised. the last
time a plan of mine asserted this without checking, `dav_app_passwords` had moved
to a plugin and a push would have dropped it on six hosts.

BUN INSTALL IS REQUIRED, not optional — package.json and bun.lock both moved. and
it gets a SEPARATE verification step, because sharp does not compile, it downloads
a prebuilt libvips for this exact platform and arch: the install can succeed and
the module still be unusable, and the symptom is not an error anywhere. it is a
file browser drawing generic icons over a folder of photographs.

FFMPEG IS CHECKED because it was never declared. it has been a hard dependency of
the file browser since long before thumbnails — subtitles, audio extraction, audio
tracks — and worked because every machine was set up by hand. surveyed all seven
hosts today: present everywhere except macbruno, which is the one per-host command
in the plan.

fofos already runs this code and was the test host before the tag. it found a real
bug that only a deploy could: `.ts` was in the video extension list, so every
TypeScript file in every repository drew a play badge and spawned an ffmpeg.

known and NOT fixed here: one test fails, ModelSelector's default selection. it is
in chat code this release does not touch, the file is unchanged across every commit
in this range, and it last moved in fe7fe49c. tagging over somebody else's red test
rather than editing their work mid-release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:38:49 +01:00
pastilhasandClaude Opus 5 43a4463978 the platform skill generator emits an identifier, not a label
c24859c fixed every skill's `name` to be lowercase-hyphenated and equal to its
directory, with the human name moving to `title`. it fixed the FILES. this
generator writes one of those files and was missed.

so the file on disk was correct and running `bun run skills:platform` put the old
shape back — which made `docs:check` fail on a line count that could not be fixed
by regenerating, the worst possible place for a drift check to point. found while
cutting a release, because the procedure says leave docs:check green and it was
not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:35:29 +01:00
pastilhasandClaude Opus 5 de425d9d68 .ts is TypeScript here, not a transport stream
caught by deploying: fofos reported `test-setup.ts` as a video. `.ts` is a real
video extension — MPEG transport stream — and it was in the list for that reason,
but on any machine Officer runs on it is TypeScript approximately always. every
source file in every repository was drawing a play badge and spawning an ffmpeg
that could only fail.

the two ways of being wrong are not the same size. excluded, a genuine transport
stream shows a file icon; included, browsing a source tree runs a decoder per file
and negative-caches the lot. nobody keeps broadcast captures in a folder they
browse from here.

m2ts and mts added while in there — the container people actually have, from a
camera rather than a tuner, and neither collides with anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:34:09 +01:00
pastilhasandClaude Opus 5 22a4c65776 sharp is a declared dependency, and setup checks both it and ffmpeg
sharp does the images now; ffmpeg keeps the video. each does the job it is for —
sharp is libvips in-process, no process to spawn and no pipe, which is an order of
magnitude less work for the common case of a folder of photographs. a video has no
frame until something decodes one, and that is ffmpeg's and nobody else's.

added through the procedure bunfig.toml describes, since there is no
--no-frozen-lockfile flag in bun 1.3.14: package.json edited, frozenLockfile
commented out for ONE command, lockfile diff read, setting put back, and a frozen
install confirmed clean afterwards.

WHAT THE LOCKFILE DIFF ACTUALLY SAID, because the friction is only worth anything
if somebody reads it. everything is inside sharp's tree: sharp 0.33.5 -> 0.35.4 at
top level, @img/colour added, and the per-platform libvips binaries moved 1.2.4 ->
1.3.3. two SHARED packages moved with it — semver 7.7.4 -> 7.8.5 and
@emnapi/runtime 1.8.1 -> 1.11.3 — both forced by sharp's own ranges, and both
previous consumers (next, editorconfig) kept pinned copies of what they had. next
keeps its own sharp at 0.33.5 entirely. nothing outside that moved.

^0.35.3 to match what av-club declares. 0.x caret pins the minor, so ^0.33 and
^0.35 do not overlap — the plugin intersection would have resolved two native
libvips trees onto one machine for no reason.

THE IMAGE LIST GREW: libvips reads HEIC and SVG and ffmpeg does not, and neither is
exotic — HEIC is what every recent iPhone writes, and a folder of them drawing
generic file icons was the most visible gap this had.

`failOn: 'none'` and `limitInputPixels: false` are both deliberate. libvips rejects
a file with any warning in it, and a truncated JPEG — an interrupted copy, a phone
that died mid-write — is exactly that; refusing it would make this browser stricter
than the viewer somebody opens the file with. the pixel ceiling is a DoS guard for
an upload endpoint, and these are the owner's own files already on the owner's own
disk: a panorama is not an attack.

SETUP, both halves:

ffmpeg was never in the package list at all, on any of the four package managers,
despite this router shelling out to it for subtitles, audio extraction and audio
tracks long before thumbnails existed. it worked because every machine set up by
hand already had it. now declared.

sharp gets a LOAD CHECK beside node-pty's build check, and reported separately from
it. same failure shape — an install that succeeds while the native module does not
— but the opposite cause: node-pty compiles and can fail to, sharp downloads a
libvips binary for this exact platform and arch and can fail to resolve one. a
combined "native modules" verdict would send somebody to rebuild the wrong thing.
loading it is the only test worth running; a directory proves nothing, and
`versions.vips` proves libvips is bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:31:18 +01:00
pastilhasandClaude Opus 5 0d310ab7b0 the file browser draws pictures, in both views
images and videos get a thumbnail in grid and list. one <Thumbnail>, two sizes —
two implementations would have drifted the first time one learned something the
other did not, and the fallback is the part that would have drifted.

FFMPEG FOR BOTH, and no new dependency. a video needs a frame extracted, which
only ffmpeg does; an image only needs resizing, which sharp does faster and with
better format coverage. sharp is nevertheless not used: it sits in node_modules as
somebody else's transitive dependency and is absent from package.json, so importing
it is an undeclared dependency — the thing the frozen lockfile exists to prevent —
and declaring it is a deliberate --no-frozen-lockfile that belongs to whoever owns
the dependency list. ffmpeg is already a hard requirement of this router, so this
adds nothing to what the machine must have. the cost is HEIC and a few exotics,
which fall back to the icon.

THE CACHE KEY CONTAINS MTIME AND SIZE. a path alone would be wrong: replace a photo
under the same name and every screen keeps the old picture with nothing to clear it
but somebody who knows the cache exists. keyed on content, a changed file is a
different key — the old entry is orphaned rather than stale, and the response can be
`immutable` honestly rather than as a bet.

FAILURES ARE REMEMBERED. the browser retries a broken <img> every time it re-enters
the viewport, so without this one corrupt video runs ffmpeg on every scroll, forever.
in memory, so a restart gives it another chance after somebody installs a codec.

`ls` DECIDES what can be thumbnailed, not the client. the browser's own
NATIVE_VIDEO_EXTS is deliberately only what a <video> tag can play, which is a
different question from what ffmpeg can take a frame out of — every .mkv would have
gone without a picture for that reason alone. the entry has just been stat'd, so the
answer is a lookup on its name.

FOUND WHILE TESTING, and the reason there is a test folder: ffmpeg EXITS 0 HAVING
WRITTEN A ZERO-BYTE FILE when the seek lands past the end of the input. a 0.4s clip
gave exit 0 and 0 bytes with `-ss 1`, and exit 0 and a real JPEG without it. the
retry keyed on the exit code and so never fired, reporting every short video as
having no thumbnail. it keys on the output file now — there is one question worth
asking an image encoder and it is whether there is an image.

four ffmpeg at a time. the grid is virtualised so only what is on screen is asked
for, but scrolling fast through a folder of video queues far more than is visible,
and unbounded decoding turned a 1.9GB box into a swap storm once already.

size is snapped to 64px steps so a panel resizing by a pixel cannot mint a hundred
entries of one image. verified: 240 and 256 share a cache entry, 200 gets its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:25:11 +01:00
pastilhasandClaude Opus 5 16fa869260 the dock rounds its tiles; now it clips them too
reported as a plugin whose dock tile had square corners while every other one was
rounded, and the expectation was right: the rounding IS dock infrastructure and
has been there all along. it was simply never reaching the artwork.

the box is `rounded-xl` and the image inside it is not clipped. a `badge` tile
insets its glyph to h-7, so the corners are empty and nothing looked wrong for as
long as every plugin used one. `bare` fills the box edge to edge — so the first
plugin to ship a logo on an OPAQUE background drew its own square corners over a
rounded box, and became the one tile in the row that had not been told the shape.

`overflow-hidden`, in the dock and in spotlight, which had the identical bug a few
pixels away. it fixes every bare plugin rather than this one, and it is a no-op for
badge tiles, which is why it belongs on the box and not on the image.

the plugins list drew the same artwork with no rounding at all while the grid
beside it used `rounded-xl`. rounded to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 00:12:31 +01:00
pastilhasandClaude Opus 5 3d936309a5 the list refreshes before the message, and the tab follows the result
reported as "the list does not update without a refresh". the counts were in fact
correct, which is the more interesting half: fofos-admin was ALREADY INSTALLED on
that host, so the fetch put it in Installed while the confirmation said "click it
to install" and pointed at an empty Available tab. right numbers, wrong story.

three fixes, and only one of them is the refresh.

AWAIT THE REFETCH. `invalidateQueries` is fire-and-forget, so the confirmation
appeared while the grid was still rendering the previous answer and the plugin
turned up a moment later. that reads as "nothing happened" and sends somebody
looking for a refresh button — which is exactly what happened.

SAY WHICH THING HAPPENED. a directory and an install row are separate facts:
deleting a plugin's source leaves the row, so a re-fetch can land on something the
platform still considers installed. the route now reports it, and the message is
either "added — click it to install" or "added — already installed".

FOLLOW IT TO ITS TAB. if it came back installed, the overview switches to
Installed rather than leaving somebody staring at the tab it is not in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 00:01:48 +01:00
pastilhasandClaude Opus 5 9a70089d6b a Marketplace section, and the git field lives where its result appears
a new row above the tags, and where the store now opens. the tags say what a
plugin is FOR; this says what state the machine is in, which is the question
somebody opening a store asks first — narrowing by kind is what you do after.

THREE TABS, because there are genuinely three states and the old grid had two.
`Installed` is what runs. the other two are both "not installed" and are not the
same thing:

  Official   the catalogue — somewhere else, a download away.
  Available  already ON THIS MACHINE, not installed. where a plugin fetched from
             a git URL lands, and where one deleted-but-not-purged sits.

collapsing those hid the entire point of fetching from a repository: you add
something and it is indistinguishable from twenty things you have not downloaded.

it opens on Installed rather than the catalogue. a store that opens on what it
can sell you is selling; this is mostly where somebody checks on something they
already have.

THE GIT FIELD MOVED INTO THE AVAILABLE TAB, which is the tab its result appears
in — and only shows there. floating above a filtered grid it was an action whose
outcome landed somewhere off screen, which is how the first version installed a
plugin with no feedback at all. same mistake, one layer up.

the tag grid is untouched and still what a filter shows. two views because they
answer different questions: "what have I got" wants installed-versus-not, "what
is there for my calendar" wants everything of that kind whatever its state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:56:27 +01:00
pastilhasandClaude Opus 5 e2d03394e1 the marketplace nav, in the house section style
taken from fofos-admin/web/NavPanel.tsx, which is the pattern from here on.
three changes, each doing a job rather than being a preference:

SEPARATION. `border-b border-border` on every row, replacing gapped pills. a
gapped list leaves the boundary between one item and the next to guesswork; a
ruled edge says where a row ends.

THE LABEL IS NO LONGER MUTED. it was `text-duck-dark/70` — every row whispering,
with nothing for the eye to land on. the muting moves down to the second line,
where a count genuinely is secondary.

THE COUNT MOVES OUT OF THE RIGHT-HAND COLUMN. it was a number at the far edge at
60% opacity, which is where the eye goes last. as the row's second line — "3
plugins" — it is read WITH the label rather than hunted for, and it gives every
row the same two-line shape the fofos panel has.

active is `bg-muted`, a filled row, rather than a tinted label. colour on the text
was carrying both "this is a link" and "this one is selected", which is why
nothing looked selected.

the footer moved to the same border and the same `muted-foreground` token rather
than the duck palette, so it sits under the rows instead of looking like another
kind of thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:50:37 +01:00
pastilhasandClaude Opus 5 cac58ea044 the git-repo button FETCHES, and the card does the installing
it installed, and using it showed why that was wrong: the button is on the grid,
the progress log is in the detail panel — so a URL install ran to completion with
nothing on screen and the plugin simply appeared, already installed. no feedback,
and no moment to change your mind about running somebody's code.

cloning alone is enough to make a plugin real. `/api/plugins` lists what is ON
DISK, so a fetched plugin shows up with `installed: false` — Available, exactly
like a catalogue entry — and the existing card, its Install button and its
streamed progress do the rest. one install path, and it is the one people have
already used.

so `install-from-url` and its streaming twin are gone, replaced by
`fetch-from-url`, which clones and stops. the streaming route existed only to
carry progress into a view that could not render it.

the outcome is REPORTED, since that was the complaint: "fofos-admin added — click
it to install", naming what the URL resolved to. the name is derived rather than
typed — appName IS the directory, deciding the route, the table prefix and the
process name — so being told what it became is the only way to know which card is
yours. it stays on screen until the next attempt rather than being a toast that
is gone before you look up.

discovery is re-run after the clone: fetchPlugin checks the manifest EXISTS,
discovery checks it parses. a directory failing that still renders in the grid as
broken, which is honest, but saying so at fetch time reaches the person while
they are still looking at the field they pasted into.

verified: fetch 200 with the derived name, the plugin listed installed=false, a
second fetch refused with "already on disk" rather than clobbering it, and the
icon present in the clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:44:43 +01:00
pastilhasandClaude Opus 5 5c6c5b31f8 "Install from Git repository", in the marketplace
a button beside the plugin count that becomes a URL field. everything after it is
the machinery that was already there: same installPlugin, same step and log
frames, same progress panel, same result.

STREAMED, because that is what "works exactly like it already does" means here.
an install writes an ecosystem entry, starts a process and rebuilds the router;
answering once at the end is a spinner that stops, with no way to tell "started
the sidecar" from "could not". the frame reader in usePlugins was split out
rather than copied — it never cared which URL it read from, and two copies of an
incomplete-frame parser is two places to get it subtly wrong.

A TWIN ROUTE, not a case inside the verb map. every other verb names an
INSTALLED plugin in its path; this one names something the machine has never
seen, and its subject arrives in the body. folding it in would mean a route whose
`:appName` is ignored for exactly one value of `:verb`.

NO NAME FIELD, deliberately. appName IS the directory a plugin sits in — never
declared in a manifest — so it decides the route segment, the table prefix and
the pm2 process name. a box for it would invite a name that disagrees with the
address. it is derived server-side and reported as the FIRST streamed step, which
is the moment it can still be cancelled.

collapsed until asked for: a permanent URL box at the top of a catalogue reads as
the primary way in, and it is the exception — for somebody who already has an
address.

verified end to end against fofos/fofos-admin: 30 frames, every step from clone
to mount, `assets: published to /plugins/fofos-admin/` now that it carries an
icon, and the icon served at 256x256 with alpha intact. purged afterwards —
officer-dev is not that website's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:27:16 +01:00
pastilhasandClaude Opus 5 d273dc6a14 install a plugin from a git URL, with no registry involved
the catalogue was the only way a remote plugin could arrive, so "build one and
give it to a friend" had no route. the first real case is a plugin that must
never be listed: fofos/fofos-admin is one website's admin panel, useful to
exactly one person.

MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the
URL, refused to overwrite an existing directory, checked for a manifest and
removed a failed clone. exactly one line tied it to the marketplace — where the
string came from. so this is a source parameter threaded through, and a route.

THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is
never declared in a manifest — the id cannot disagree with where the code sits.
that decision is inherited here rather than remade: the repo name becomes the
directory, the route segment, the table prefix and the sidecar suffix. so it is
checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone
under an unusable name reads as a broken plugin rather than an absent one.

the URL is validated identically whether it came from the catalogue or from a
person, because the catalogue is not a trust boundary — it is just where a string
came from, and the same string reaches the same argv. what differs is who may
ask: this route is owner-only, and the owner is choosing what code runs rather
than choosing from a list somebody curated. validation is not review, and nothing
here reads what it cloned.

caught by testing: the source reached installPlugin and stopped there — the
fetchPlugin call still had the old two arguments, so a URL install fell back to
the catalogue and failed with "no catalogue entry". the guard tests are what
surfaced it, because the error named the wrong cause.

verified end to end against fofos-admin — cloned, schema applied, mounted, dock
pinned — then purged, since officer-dev is not that website's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:12:54 +01:00
pastilhasandClaude Opus 5 4e0a065f0e a host can drop a core sidecar it does not use
`OFFICER_DISABLED_SIDECARS` in .env, comma separated, filtered out of the
generated ecosystem file.

core processes are a fixed array because the platform's own features depend on
them. that is still true — what is not true is that every host runs every
feature. fofos is a 1.9 GB machine serving a website, and officer-opencode sat on
it costing 456 MB for a harness nobody there uses.

it froze hard twice today, and the tell is what is MISSING from the journal: no
OOM kill, no panic, no shutdown — the log simply stops mid-session and resumes at
the manual reboot. that is memory exhaustion with no headroom left to kill
anything cleanly, on a box with 1.9 GB and a 2 GB swapfile that was already 440
MB into swap one minute after boot.

.env rather than a database setting, and that is the load-bearing part: this file
is regenerated during a plugin install, so a host that dropped a process needs it
to stay dropped across one. per-host is also exactly the scope of the decision.

not validated. naming `officer` or `officer-pty` will break that host, and which
processes are safe to drop depends on what the host is for — which this script
cannot know and should not guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 23:02:52 +01:00
pastilhasandClaude Opus 5 f4685eeafe one plugins directory, because the second one was symlinks
`officer-dev/plugins/` is gone. it was created to hold every repo in the org side
by side, and by today it held 22 symlinks into platform/plugins, one duplicate
clone of a plugin that is installed here, and a README describing a state that
had already been fixed.

it could not have been more than that. a plugin cannot live outside the platform:
bun resolves a symlink to its real path, so from there `@@/sidecar/create-proxy`
and every relative import fail. tested both, plus `--preserve-symlinks`, which
bun 1.3.14 accepts and ignores.

WHICH MATTERS BEYOND TIDYING, because marketplace-sync leaned on the distinction.
its comment said the second directory was "where a release is actually made", so
publishing from it would advertise released versions rather than installed ones.
that was never true — symlinks meant both paths read the same working copy. the
bug it describes (21 plugins tagged, the store still showing 1.0.0) was never
addressed by the thing built to address it.

the honest fix is in the comment now: each plugin directory IS a git repo with
its own tags, so the released manifest can be read out of the tag —
`git show <tag>:manifest.ts` — without a second checkout. not written yet, and
said plainly rather than left implied.

finances is deleted, at the owner's request: not installed, no pm2 entry, no
ecosystem entry, no plugin_installs row, no tables. both clones were clean and
pushed. the gitea repo is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:23:59 +01:00
pastilhasandClaude Opus 5 87d1740caf deploy/2026.08.28.01 — seven hosts, one process each, and a probe that had to wait
First release under the new watcher loop. 7 of 7 target hosts to 9c8af9d4;
officer-dev skipped as the host running the rollout.

No db:push and no plugins:update, per the plan — so officer was the only
process that moved anywhere. Route probe returned 401 on all seven; three
answered 000 first because pm2 returns before the server binds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 22:09:59 +01:00
pastilhasandClaude Opus 5 9c8af9d424 deploy/2026.08.28.01 — sign in as a user
two commits, one feature, and a plan that leaves things out for stated reasons.

NO SCHEMA, NO DEPENDENCY, NO PLUGIN CHANGE — checked with git rather than
asserted, because asserting it is exactly what sank 2026.08.27.01.

db:push is deliberately not a step. yesterday's report established that it prints
"Changes applied" when it applies nothing, so its output is not evidence, and on
this fleet it re-applies a default on command_hq_sessions.reference_paths every
time plus churns pk_music_now_playing on two hosts. running it here would be
noise against a database this release does not touch.

so the rollout is a merge and one restart. the verification step is an
unauthenticated POST to the new route: 401 means mounted, 404 means the bundle
did not move — a check that works without a session, on an owner-only feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:03:44 +01:00
pastilhasandClaude Opus 5 fb0cc880ac sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for
me". reproducing that with a password reset means locking somebody out of their
own account in order to look at it, which is worse than the bug.

THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and
reaches every handler, because user-middleware puts the whole payload on the
context. without it an impersonated action is indistinguishable from that
person's own — in a log, in a transcript, in anything keyed on a user id — and a
support tool that can forge activity as somebody else is a different and much
worse thing than a support tool.

ONE HOUR, not thirty days. a session lasts a month because a person uses their
own account daily; impersonation is a task you finish. an hour is long enough to
reproduce a problem and short enough that a forgotten tab stops being a live
credential for somebody else's account by lunchtime.

IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any
token carrying `impersonatedBy`, so impersonating another Super Admin does NOT
hand user management back — which would have made this a way to launder owner
actions through somebody else's id. stated twice, in the gate and in the handler,
because one file away is too far for this one.

THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an
owner session — a far more dangerous thing to own than this whole feature. the
cost is that clearing browser storage strands you as the other person until the
hour expires, and signing in again fixes that.

the banner is loud and sits ABOVE the header, outside everything a fullscreen
panel can hide. the failure it prevents is not a hole, it is forgetting: reading
a mailbox or deleting something while believing it is yours. it asks the server
who it is rather than decoding the token, so it cannot keep showing a name after
the hour has run out.

verified end to end against a throwaway account, since this host has one user:
impersonate 200 with the target's id and impersonatedBy naming the owner, 60
minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining
403, user management while impersonating 403; one [SECURITY] line per start. the
probe account was deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 21:51:20 +01:00
pastilhasandClaude Opus 5 02ad4d3bdf deploy/2026.08.27.02 — seven hosts, and a push that lies about applying changes
Every target host is at the tag. The one destructive step ran on six of them and
was guarded by a count each time; alpha kept its table and its 8 rows.

The finding is that `bun db:push` prints "Changes applied" unconditionally — it
said so on an unchanged database, twice in a row. `--verbose` shows the truth:
what it re-applies everywhere is pre-existing composite-key drift, and nothing
touches dav_app_passwords on any host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 20:38:15 +01:00
pastilhasandClaude Opus 5 91147b403b deploy/2026.08.27.02 — a plan with commands, and a schema claim that is checked
2026.08.27.01 was REFUSED before a single host was contacted, and the commander
was right twice over. its plan described the release instead of instructing the
rollout — no step list at all, where deploy/README.md defines a plan as "what to
run, in order, on which hosts" and steps 4 and 5 of the procedure are plan-driven.
and it asserted "nothing here changes a schema" on the day dav/schema.ts was
deleted out of core.

that second one was not a formatting mistake. dav_app_passwords now lives in
plugins/caldav, and gen-plugin-schemas follows plugin DIRECTORIES — so on a host
without that directory the table is in the database and not in the schema, and
`db:push` proposes dropping it. on alpha that is eight device credentials.

so this plan answers it from a survey rather than a guess. alpha has the plugin
and 8 rows; the other six have no plugin and an empty leftover table; zero hosts
run officer-marketplace, which retires the `tags` column question entirely. the
orphan table is dropped EXPLICITLY, guarded by a count, and skipped on alpha —
never as a db:push side effect.

step 6 is new and would have been missed: officer-opencode has to restart or none
of the skills, agent-identity or credential work in this release does anything.

ALSO THE SIGN-OUT. a single 401 from any endpoint wiped the token from both
storages and reloaded — which signs the owner out of every tab and keeps them
there, since nothing is left to recover from. that is the right response to a
revoked session and far too violent a response to one endpoint answering 401 for
its own reasons. it now confirms against /auth/me first, and treats an unreachable
server as emphatically not a revocation: rebooting the box must not end the
session. reported mid-demo, with nothing in the logs and a freshly signed token
working against every route the app loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 20:27:11 +01:00
pastilhasandClaude Opus 5 ec6c793143 deploy/2026.08.27.01 — the plan, and the version to match it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:06:10 +01:00
pastilhasandClaude Opus 5 421e742590 a dock tile for /bots, which had a screen and no way in
the route, the screen and the list have existed since bots did — `/bots` and
`/bots/:sessionId` are both in App.tsx. what was missing was any way to GET
there: no entry in CORE_DOCK_ITEMS, so it could not even be added to a dock, and
no `nav.bots` label in any of the six locales.

placed beside Command HQ, which is the neighbouring idea: a commander runs a job,
a bot watches one thing. left as "Bots" in all six locales — a borrowed technical
noun, and inventing translations for it would be worse than not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 17:05:50 +01:00
pastilhasandClaude Opus 5 6e805c3607 the new wordmark in the header, following the theme
two marks, same 1062x220 crop: white "Off" for a dark background, dark "Off" for
a light one, yellow "icer" untouched in both. the dark variant is the light one
with only its DESATURATED pixels inverted, so the yellow is bit-identical and the
alpha and dimensions never move.

CLASSIFIED BY LUMINANCE, not by the mode comment. only 22 of the 227 theme files
declare one, and the cost of guessing wrong is a white wordmark on a white bar —
so each theme's own `--background` is read and thresholded. 203 dark, 24 light.

the signed-out screen is deliberately untouched and still uses the inline SVG.

AND THE BUG THIS UNCOVERED, which was not about logos at all. `useThemeAsset`
read the computed style inside an effect, and react runs CHILD effects before
parent ones — every consumer is a descendant of `ThemeProvider`, which sets
`data-theme` in ITS effect. so the read always saw the PREVIOUS theme's value:
switch themes and the logo was one theme behind, switch again and behind again.
it reads as a race and is deterministic, which is why it reproduced every time
somebody watched for it.

the read moved into requestAnimationFrame — past every effect in that commit and
past style recalculation — plus a MutationObserver on data-theme/class for the
case a frame cannot cover: a theme installed at runtime arrives as a <link> that
loads whenever it loads. the wallpaper uses the same hook for `--page-bg-src` and
had the same lag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 17:05:50 +01:00
pastilhasandClaude Opus 5 bf8bfa6859 start a commander from a plugin chat, without knowing where anything lives
pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.

every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.

THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.

it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.

caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.

verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 14:32:49 +00:00
pastilhas 9ec60d6d75 caldav 2026.08.27.03 — filters on calendar and contacts 2026-08-27 14:20:52 +00:00
pastilhasandClaude Opus 5 15392ae6c2 preview a commander's files, the way /files does
the commander file panel could list a directory and not open anything in it. it
already mounts the same `FileBrowserApp` as /files — what it did not mount is the
VIEWER, which on /files is a separate ephemeral panel and not part of the browser
at all.

THE PARAM COULD NOT BE READ, which is why this is more than mounting a panel. on
/files the folder is in the PATHNAME, so `?view=notes.md` is a name inside it and
that is all it needs to be. a browser mounted anywhere else keeps its folder in
panel-local state, so the same name is unresolvable by anything except the
component that wrote it — the commander panel was writing `?view=notes.md` with
no reader able to tell which of its folders that meant.

so off the files route the param carries the whole path, and `viewRoot` with it:
a panel that overrides the root is exactly the case where "home" is the wrong
guess. `viewRoot` joins VIEWER_PARAMS so closing an overlay clears it — a stale
root outliving its view would send the next file to the wrong tree.

the split is VERTICAL where /files is horizontal. this panel is already the narrow
half of a screen whose other half is a transcript; splitting it sideways again
leaves two columns too thin to read anything in.

closing the file clears the overlay params by NAME rather than resetting the
query, because `files=1` — the thing keeping this whole panel open — lives in the
same string, and the obvious version closes the browser along with the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 14:13:40 +00:00
pastilhasandClaude Opus 5 02f20d419d an opencode chat you can reopen: it has a name, its messages, and its model
three complaints, one root cause and two small ones.

THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message`
and this build answers that with `[]` — 200, well-formed, and wrong. the same
session returns 4 messages on `/api/session/<id>/message`. an empty transcript is
indistinguishable from a new conversation, so reopening a chat said "send a
message to start" and every message the owner had sent looked deleted.

that also explains the third complaint. the model was read from the assistant
turns, so no messages meant no model, so the detail reported a bare `opencode`,
which the composer cannot match — and it fell back to claude code and sonnet. it
now also reads the model off the session record, which is populated before any
assistant turn lands.

the message shape moved with the endpoint: `type` where it was `info.role`,
`content` where it was `parts`, and a user message's text sits on the message
rather than in a part. both shapes are accepted rather than pinned to today's
build — this reader is the only thing between a working transcript and a chat
that looks erased, and a host on a different opencode should lose features, never
its history.

AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing
downstream re-sorted, so the answer sat above the question that produced it.
sorted here, because this is the only place that still has timestamps.

NAMING: opencode is meant to title a session from its contents and does not, on
this surface — sessions stay at `New session - <timestamp>` through a finished
turn and past it, which is why the list was a column of "(untitled)".
`isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI.
so the sidecar names a NEW session from the first line of its first prompt, which
is what the claude side shows. a resumed session keeps what it has, and if
opencode ever does title one it writes after us and wins.

verified live on the session that prompted this: 4 messages, in order, model
opencode/kimi-k3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 13:56:37 +00:00
pastilhasandClaude Opus 5 3b1d4cea84 permissions off wholesale, by every documented mechanism, plus a backstop
read the documentation instead of the binary, which is what should have happened
hours ago. it says three things this code was getting wrong.

  - "permission": "allow" — a BARE STRING, not the object form. that is the
    documented way to say "no prompting at all".
  - most permissions already default to allow. the exceptions are
    `external_directory` and `doom_loop`, plus `read` on `.env`. those are what
    was actually stopping turns.
  - an agent's own rules take precedence over the global config, and an agent
    that declares none inherits the built-in defaults — so a global allow does
    not reach it.

so: `permission: "allow"` in the serve config, AND the agent moved to the
markdown FILE form with `permission: allow` in its frontmatter. the file form is
what the docs recommend "for anything non-trivial", and it is the only place an
agent's permissions are honoured — the inline definition ignores both `permission`
and `prompt`, while accepting `description` and `mode`, which is the worst kind of
partial.

WHAT I COULD NOT MAKE TRUE. with all of that applied, the serve still reports
`external_directory: ask`, `doom_loop: ask` and `read *.env: ask` on the resolved
agent. i cannot get that list to come back clean, and i am not going to guess at
it again.

which is why the event-stream approver stays and is the thing actually doing the
work: whatever opencode asks, the platform answers `always`. that is the analogue
of the claude harness's `bypassPermissions` and it does not depend on predicting
the shape of the next gate. the config above is the belt; that is the braces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 13:37:28 +00:00
pastilhasandClaude Opus 5 3082949fac answer opencode's permission prompts, because nothing else can
the spinner had a cause and it was none of the four things fixed before it. the
serve was holding a permission request:

  {"action":"skill","resources":["calendar-and-contacts"],"source":{"type":"tool"}}

opencode asks before loading a skill. in its own terminal a person answers. here
it goes to a UI that has never heard of it, and the turn does not fail — it SITS
THERE. no error, no log line, nothing in the transcript. three models were blamed
for it, and a paid provider, and the skill itself.

replying `always` by hand released it instantly: the skill loaded and the agent
went straight to the curl the skill describes. so the whole chain was correct and
one unanswered question was holding all of it.

CONFIG IS NOT ENOUGH, and this is the part worth keeping. the serve's config
carries `permission: {skill: {"*": "allow"}}`, the agent's own ruleset reports
`skill * allow`, and it asked anyway. answering is the only approach that does not
depend on guessing the shape of the next gate correctly — and there will be a next
gate, because `external_directory` was the last one.

not a widening: the claude harness runs `bypassPermissions` deliberately, because
CLAUDE.md is explicit that an unsandboxed agent on the owner's own machine is the
point. this is the same stance for the other harness. `always` rather than `once`
so a repeated tool does not re-ask every turn.

when opencode reaches members this becomes a real decision. it is owner-only today
for larger and unrelated reasons, and it goes on the list with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 13:31:18 +00:00
pastilhasandClaude Opus 5 c226e8a46d copy the skills, do not link them — a link is what made it hang
the agent called `skill calendar-and-contacts` and sat there. not an error, not a
refusal: a spinner, on three different models.

opencode grants `external_directory` for the paths it discovered — literally
`~/.config/opencode/skills/<name>/*` — and a symlink RESOLVES to the plugin
directory, which is not in that list. so reading the skill body fell through to
`external_directory: "*": "ask"`, and a server session has nobody to ask. the
request waits forever. the permission list the serve reports says it plainly:
every skill path is allowed at its LINK location and nothing allows the target.

copying makes the real path the allowed path. the cost is drift — a skill edited
in its plugin is not live until this sidecar restarts — which is why the copy is
rewritten from scratch on every boot, and it is a good trade against a hang with
no message. copies are marked so a skill somebody wrote there by hand is never
clobbered and ours are replaced rather than merged, so a file deleted upstream
cannot survive in the copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 13:24:10 +00:00
pastilhasandClaude Opus 5 f0801808c4 read the documentation: four things wrong, none of them the model
an opencode agent could not look at a calendar all afternoon. i treated it as a
framing problem, then a regression, then a free-vs-paid model problem, and it was
none of those. the documentation says all four, plainly.

1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match
   ^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a
   human title there — "Calendar and contacts" — so every one was LISTED and none
   could be LOADED. the agent found the skill, called
   skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title`
   is now a separate field and keeps the prose.

2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config
   example and not in the docs, and the difference is exactly this: skills
   configured that way are offered to the agent and cannot be opened. skills load
   from fixed discovery paths only, so each directory is now SYMLINKED into
   ~/.config/opencode/skills. links, not copies — one source of truth, and a skill
   edited in its plugin is live immediately.

3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed
   without complaint and never applied. this is what made it look like a model
   problem: the agent existed, the prompt was on its record, and the model had
   never seen a word of it.

4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to
   ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}.

also: the platform cached opencode's model list for the lifetime of `officer`,
so signing into a provider and restarting the SIDECAR left the old catalogue in
the OTHER process — six free models, no paid ones, nothing saying why. the cache
is dropped when the sidecar reports a new port, which is the one event that
always accompanies a new serve.

verified: the prompt reaches the model — asked who it is, it answered "Officer,
running on your home server at officer-dev under your user account". 10 skills
discovered through the linked path with loadable names. NOT verified end to end:
a turn that opens the calendar skill and answers from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 13:11:19 +00:00
pastilhasandClaude Opus 5 a873690e27 opencode gets an identity, not just a coding agent's
three turns in a row could not look at a calendar while holding the calendar
skill, and each failure had a different cause.

FIRST: opencode's built-in `build` agent opens with "You are an AI coding agent.
Help the user accomplish software engineering tasks." asked about a calendar it
reasoned, in its own words, "this isn't a coding task, and I don't have access to
any calendar" and never called the skill tool. the index was in its context the
whole time. the plumbing was right and the identity was wrong, which is the
harder of the two to see.

so officer defines its own primary agent — mode primary, `default_agent`, and
named explicitly on session create because a default is a preference and this is
a requirement. only NEW sessions: a resumed one keeps the agent it was made with.

SECOND: setting a custom `prompt` traded one problem for the other. with the
built-in agent the index was there and the framing was wrong; with ours the
framing was right and the index was gone, so the turn went looking for .ics files
in the home directory. `prompt` replaces the assembled system text rather than
adding to it. the index is now compiled INTO the prompt by `skillsBlock()` — the
same function a claude session uses — so both halves arrive together. ours also
carries the PATH to each SKILL.md, which opencode's own block does not: a path
can be opened with the file tools every harness already has.

WHAT IS NOT FIXED, AND IS NOT THIS FILE'S FAULT. a third attempt still failed,
and a diagnostic turn reported system instructions beginning "Tool infrastructure
You can call" — not ours — and named a skill (`frontend-design`) that exists
nowhere in this install. the only provider this machine can reach is opencode's
own hosted gateway on free models; there is no auth.json here. so either the
gateway supplies its own system prompt, or a free model is confabulating about
its instructions. those are different problems and neither can be told apart
without a model whose credential we own.

verified: the agent is registered and its prompt is 4,717 characters containing
"Calendar and contacts" and the SKILL.md paths; the serve's credential works (a
tool process fetched the owner's collections); 10 skills discovered. NOT verified:
that any of it reaches the model on this box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 12:44:34 +00:00
pastilhasandClaude Opus 5 e867fe72af notify one named account — and unshadow /broadcast, which never worked
"can you send a notification to George" had no answer. an agent key reaches its
own holder and nothing else, and the only route that spoke about somebody else
sent to EVERYONE. so the choice was the whole household or nobody.

POST /api/notify/user, ownerOnly, same authority and same reasoning as
/broadcast: everything else under this permission acts on the account asking, and
reaching another person's phone is administration however few phones it is. it
returns the target's email so the caller can see who was reached rather than
trusting an id they typed, and 404s an unknown account rather than letting the
sidecar's silence read as delivery.

AND THE PART THAT MATTERS MORE. /broadcast has answered 404 since the day it
landed. `createSidecarProxy` mounts `router.all('/*')` when it is CREATED, and
hono matches in registration order — so every route added to the proxy's own
router afterwards was forwarded to the sidecar, which has never heard of it.
three days, on a route whose entire purpose was to stop people POSTing to the
sidecar's ephemeral port by hand. a shadowed route does not fail like a missing
one; it fails like a sidecar that has not implemented it, so the 404 reads as
somebody else's problem.

caldav's router learned this on 2026-08-21 and wrote it down. this file now does
the same thing in the same words: the platform's own routes first, the catch-all
proxy mounted LAST.

verified: /broadcast now answers 400 to an empty body where it answered 404
before, /user delivers and reports the address, /_officer/devices still forwards.

found while trying to reach around the notify sidecar's producer secret to do
this by hand — which is exactly what that secret was added to stop, and the
comment above it says so. it stopped me. the route is the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 12:15:16 +00:00
pastilhasandClaude Opus 5 e6da5d8c77 opencode gets the skills and a credential, which it had neither of
an opencode agent could not have used the calendar skill written this morning,
and would not have failed usefully either — it had no idea the skill existed and
no key to call the platform with. two gaps, one file each.

SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index
compiled into its system prompt, and `skills.paths` in config taking directories
of <name>/SKILL.md — the same progressive disclosure claude does over the same
layout the platform already stores. so this points it at the SAME FILES rather
than compiling a second index in a second format. skills/load.ts says a skill is
a path because every harness can read a file; this is the better version of that
bet — the harness had the feature all along. verified: `/skill?directory=…` now
returns all nine platform skills, "Calendar and contacts" among them.

THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per
session, so its key is per session and a member's resolves to their own grants.
opencode cannot: ONE serve runs every session, so the only environment there is
is the serve's own, minted once at sidecar start, belonging to the owner.

that is sound today for exactly one reason — opencode is owner-only, enforced at
every door in chat.ts because one serve runs as one user. when that lifts, this
key becomes a privilege escalation. it is the same blocker as the two already
there, not a new one, but it lives in a sidecar where nobody looking at chat.ts
would find it, so chat.ts now names it. all three move together.

verified end to end: a tool process inside an opencode session ran curl with
$OFFICER_AGENT_KEY and got the owner's calendars back.

OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc —
it is inline json, it MERGES into the same chain as the config files (checked in
the binary), and it dies with the process. the alternative is the platform
editing a dotfile it does not own and leaving state behind that outlives the
install.

affordances ride the `instructions` key as one composed file, written beside the
serve's state. loaded — `/config` shows it — but NOT model-verified: this box has
no provider credential in auth.json, so a turn 401s before reaching a model.
whoever adds one should check that first.

the credential module moved out of sidecar/claude/. it was never claude-shaped;
it was just there first, and it had one importer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 11:13:00 +00:00
pastilhasandClaude Opus 5 0918dd7443 todo: what the calendar API deliberately cannot write
recurrence, alarms, attendees and EXDATE. all four read correctly and none can
be authored — which is what the agent skill still drops to raw DAV for. filed
with an order to do them in (EXDATE first: "cancel next Tuesday" is a thing the
owner will say and the honest answer today is a refusal) and the reason the API
stops where it does.

new theme, because burying a calendar gap under plugin-repos is where it goes to
be lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:56:51 +00:00
pastilhas 3fac1460cd close the request-body todo: the proxy claims only what it forwards 2026-08-27 08:48:54 +00:00
pastilhasandClaude Opus 5 f00abe816e a sidecar proxy claims only the sub-path it forwards
registerProxiedPrefix tells bodyParser to leave a prefix's request bodies alone.
that is correct for forwarded traffic and load-bearing for uploads: a parsed
multipart body is re-serialised with a new boundary while the original
content-type header is still forwarded, and every upload fails.

but a plugin mounts its OWN routes under the same prefix, and those are not
forwarded — they are served in-process and they need their bodies. claiming the
whole prefix meant every field on them read as missing.

caldav hit it the day it was extracted: POST /api/caldav/provision/ios answered
"deviceLabel is required" for a request that carried one. its app passwords used
to live at /api/dav, a second prefix that was not proxied, and one prefix per
plugin ended that arrangement.

so `proxiedSubPath` — caldav's sidecar answers `_officer/*` and nothing else, so
it claims `/api/caldav/_officer` and the plugin's own routes are parsed normally.
omitted means the whole prefix, which is right for every sidecar with no routes
of its own, so nothing else changes.

this affects any plugin mixing its own routes with a proxy, which is most of
them. they have not hit it because their own routes are GETs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:48:40 +00:00
pastilhasandClaude Fable 5 6762a32ea6 todo: turn a chat into a commander or a bot, carrying its context
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 08:46:36 +00:00
pastilhasandClaude Opus 5 68f3643df7 a pull leaves origin/master where it found it, so every checkout looked ahead
plugins:check reported "2 commit(s) NOT PUSHED" for all twenty-one installed
plugins on a machine where nothing had been committed. the check was right about
what it measured and the cause was here.

`git fetch origin HEAD` writes FETCH_HEAD and nothing else. refs/remotes/origin/
<branch> keeps whatever it had, so after a successful pull the checkout sits at
the newest commit while origin/master still points at an older one — and every
`rev-list origin/master...HEAD` reports the checkout as ahead by however many
commits it just pulled.

so the remote-tracking ref is refreshed too, explicitly, after the fetch that
cannot do it. best effort, because the local branch may not exist on the remote
under that name — which is the same reason the fetch asks for HEAD rather than
for a branch.

fetch also asks for --tags now. tags are how releaseState decides whether there
is anything to update to, so a fetch that skipped them left the Update button
answering from refs older than the release it was offering.

verified by staling a ref on purpose: one false positive before, none after a
single pullPlugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:46:18 +00:00
pastilhasandClaude Opus 5 0d5c2c0083 servers.md: the eighth machine, and the port that is not 9000
two facts that each cost time today and neither was written down.

macbruno was missing entirely — 100.64.0.22, login
brunoalcadapadezmonteirodaconceicao, ~/officerdev/platform, pm2 under Homebrew.
nothing on the fleet knew it existed.

and THE PORT IS NOT ALWAYS 9000. macbookpro and production-pertento-bridge run
on 9010. a health check against 9000 on production hit one of Pertento's own
apps, which answered 403, and that was read as officer failing after a deploy.

while there: production runs eight processes that are NOT officer — Dashboard,
Tracking, Uptime and five Crons. they are production-critical and belong to
Pertento. anything sweeping pm2 list must filter to officer and officer-*.

added a section on pm2 not being on the PATH of a non-interactive shell, because
that read as "pm2 is not installed" three times in one day and twice sent
someone diagnosing a healthy machine. zsh reads ~/.zshenv for non-interactive
shells and neither .zshrc nor .zprofile, so an nvm or Homebrew install is
invisible to an orchestrator. fixed at the source on the two hosts it bit; the
section says how to check rather than assume.

every row verified by reading the machine rather than recalling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:45:14 +00:00
pastilhasandClaude Opus 5 b4cf1c6427 publish the catalogue from the release clones, and make the cards look like cards
three small things.

marketplace-sync reads PLUGINS_DIR now instead of assuming the directory it sits
next to. platform/plugins/ is what a machine has INSTALLED — updated by
plugins:update, which is a git pull — while the release is made in the workspace
clones. publishing from the install directory means the catalogue advertises the
version this machine happens to be running: 21 plugins were tagged and the store
went on showing 1.0.0 until this changed.

the grid cards used bg-white/40. this app's surfaces are a DARKENING of the
panel behind them — every other card and section here is bg-duck-dark/5 — so a
translucent white read as a pale box floating on a dark theme. picking a colour
instead of a token, and it only looked right against a background this app does
not have. hover now moves the BORDER and nothing else: a background swap across
twenty-one cards makes the whole grid twitch as the pointer crosses it.

the tag pills were the same value as the card surface, so they rendered as text
with no chip at all.

and the marketplace todo said the UI was not started. it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 08:43:46 +00:00
pastilhasandClaude Fable 5 8705dc3a7c todo: a server registry for terminal ssh and agent awareness
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 08:38:18 +00:00
pastilhasandClaude Opus 5 c14b686782 pm2 save when the process list changes, and a check that does not lie about symlinks
the owner asked whether anything runs `pm2 save` when a sidecar is added or
removed. nothing did.

PM2 resurrects from ~/.pm2/dump.pm2, written only by `pm2 save`, so the dump was
whatever a human last happened to save. today it was FIVE DAYS old: 13 processes
saved against 26 running. a reboot would have silently lost every sidecar
installed since — bitcoin-wallet, desktop, gitea, invoices, iris, jellyfin,
minesweeper, music, photos, soulseek, transcribe, transmission, vault. thirteen
features gone, machine healthy, nothing saying why.

saved after start AND after delete. removal matters as much: a dump that keeps
resurrecting a deleted sidecar brings it back after a reboot as a crash loop for
code no longer on the machine. a RESTART does not save — the same processes are
running and the dump is already right.

best effort and never fatal: a failed save leaves the estate correct and only
the dump stale, which is where things already were. failing an install over
bookkeeping would be the worse trade.

separately, plugins:check filtered directories on the DIRENT's isDirectory(),
which answers false for a symlink to a directory. platform/plugins is symlinked
now, so the check would have reported "0 plugin repositories, all well-formed" —
a green check over nothing, which is worse than a red one. it stats the path
instead, the way discover.ts already does.

dump saved once by hand as well, since five days of drift was already there:
26 saved, 26 running, no difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 07:52:46 +00:00
pastilhasandClaude Opus 5 68d6dd942e update means something now: it depends on tags
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.

versions are dated and tagged now, so the rule the owner asked for is possible:

  · a checkout NOT at a tag is always updateable — it is between releases, no
    claim is being made about it, pulling is always reasonable
  · a checkout AT a tag is updateable only when a NEWER tag exists

the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.

it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.

the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.

"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.

verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 07:44:00 +00:00
pastilhasandClaude Opus 5 d58f58813c the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right.
that is a fine control panel and a poor shop: twenty-one rows sorted by nothing
anybody cares about, where "something for my calendar" means reading all of them.

now: tags down the left with counts, cards in a grid on the right, and clicking
a card takes over the panel with a back link. three things about the shape.

BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a
filter in component state is lost on Back, so filtering, opening a plugin and
pressing Back would drop you in an unfiltered grid having lost your place.
browser Back works for free for the same reason. selecting a tag also CLEARS the
selection, because a detail for a plugin outside the new filter is a page the
grid you are now looking at cannot return to.

the detail REPLACES the grid rather than sitting beside it. a grid needs the
width and a detail beside one gets half of it; neither reads well. the back link
drops ?selected= and keeps ?tag=, which is the entire point of putting the
filter there.

the panel KEYS are unchanged though both components were replaced. a stored
layout names them, and renaming would drop everyone back to the default
arrangement for nothing — the key identifies a slot, not an implementation.

two things found on the way. PluginItem and AvailablePlugin had no tags, so the
api now carries them on both branches — from the manifest for a plugin that is
here, from the catalogue for one that is not, same field either way. and the
screen used useDashboardState, which persists the whole tree: the panels this
ships would never have reached anyone who had opened it before. useAuthoredLayout
takes the structure from source and persists only sizes, which is what an
authored screen wants.

21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one
rows, and the width is worth more to the grid.

verified: bundle contains the new panels, api returns tags for all 21 with none
missing the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 07:32:05 +00:00
pastilhasandClaude Opus 5 276b12607a every brief the platform writes, where both harnesses will find it
opencode discovers AGENTS.md and nothing else — its only target is
H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers
CLAUDE.md. no overlap.

every brief the platform wrote was CLAUDE.md, so under opencode a commander got
none of its standing brief, a bot none of its arming instructions, and a plugin
chat none of its plugin's guidance. nothing failed and nothing was logged: the
agent simply knew nothing, which is indistinguishable from the feature not
existing, and is why it lasted this long. commanders can already run under
opencode, so this was live.

writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a
pointer rather than two copies because these are rewritten every turn, two
copies of that will eventually disagree, and the stale one is the one somebody
reads.

three writers, and the third needed a flag the others must not have:

  bots          rewritten every turn ON PURPOSE, so a template change reaches them
  plugin chats  same
  commanders    onlyIfAbsent — a commander EXTENDS this file over its life, and
                rewriting it when the template changed would delete the job it
                had agreed with the owner

two things that would have quietly defeated it. the file listing handed to a
commander said "CLAUDE.md — this file", which would have had it editing the
pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a
new commander means burying a credential section inside six lines nobody reads —
it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old
commanders still work.

plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now
chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its
own repository on its own release cadence and dropping the old name would
un-brief any installed copy that had not updated — silently, again.

tested: both files written, pointer is not a second copy, idempotent, rewrites
on change, and onlyIfAbsent leaves an extended brief alone. verified on disk
after a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 07:18:31 +00:00
pastilhas 1c28a48299 the check can look at either plugins directory, and knows what is not a plugin
there are two now. `platform/plugins/` is what this machine has INSTALLED;
`officer-dev/plugins/` is every repo in the org cloned side by side, which is
where a plugin is worked on from now on. they hold different sets, so the check
has to be pointable rather than assuming the one it sits next to.

and it skips a repo with no manifest.ts instead of reporting it broken.
`av-club` is the case — a standalone media service in the plugins org, meant to
become a plugin in time. checking it against a plugin's conventions would flag
its branch name as a defect for ever, and a check that cries wolf is one people
stop reading. presence is the declaration here exactly as in discover.ts: the
manifest is what makes something a plugin, so its absence is what makes
something not one yet.
2026-08-27 07:09:06 +00:00
pastilhas 5d51212cca write down what today left unfinished
three, so none of it is re-derived:

agents-md-remaining — the five doc pairs landed; the runtime writers did not,
and that half is BROKEN rather than untidy. commanders, bots and plugin chats
write CLAUDE.md, opencode reads only AGENTS.md, so a commander run under
opencode is briefed with nothing and nothing reports it. names the three writers
by file. the 21 plugin repos are marked as the owner's to lead — not to sweep
unprompted.

marketplace-browse-by-tag — data layer done, UI not started. carries the two
questions that decide the shape: "All" as default, and whether the detail
replaces the grid or sits beside it, which is master-detail vs list-then-page and
contradicts how the screen is deliberately built today.

calendar-skill-never-used-by-an-agent — every recipe was run BY HAND and works;
no agent has ever read the skill and acted on a sentence. those are different
claims and only one of them is tested.
2026-08-27 07:04:05 +00:00
pastilhasandClaude Opus 5 a865bcfdfc put back the orientation the sweep overwrote
2360a3ef moved CLAUDE.md's text into AGENTS.md verbatim — which is exactly what
its brief asked for, and the brief was wrong. it described root AGENTS.md as "41
lines, currently a pointer". it was not: it carried an Orientation section — the
owner/member split, why execution permissions can never be granted, that
mounting a router without a registry entry refuses the boot, and the reading
list. all of it was replaced rather than merged, and none of it survived
anywhere else in the tree.

my fault, not the agent's. i skimmed a file and then told something else to
overwrite it. the other four pairs are clean — none of those directories had a
prior AGENTS.md, so there was nothing to lose.

restored at the top of the file, with three corrections rather than a straight
revert:

  · the sibling repo is `capabilities/`, not `permissions/` — the old text named
    a directory that does not exist
  · "read the Permissions section of CLAUDE.md" now says "below", since this is
    that file
  · the reading list no longer lists CLAUDE.md as the architecture doc, which
    would now be a pointer pointing at the thing pointing at it

the file also still called itself "# CLAUDE.md". it says AGENTS.md now, and says
why both exist: opencode walks the tree for AGENTS.md and nothing else, Claude
Code for CLAUDE.md, and one set of instructions has to reach both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 06:59:48 +00:00
pastilhasandClaude Opus 5 d381140d54 plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful.

TAGS. the marketplace is going to be browsed rather than scrolled: tags on the
left, the plugins carrying one on the right. a plugin declares its own in the
manifest and the catalogue caches it on sync, exactly as it already does for
label and summary — the alternative is editing a table every time a plugin
ships, and a plugin that cannot say what it is.

the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab,
home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms
is worse than no filter. an unknown tag is refused at discovery BY NAME, so the
author is told which tag rather than which array index.

all 21 are tagged, each with a one-line note saying why, so a later disagreement
is with a stated reason rather than a mystery. every tag has at least three
plugins behind it — no filter opens onto an empty page.

PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and
none of the reasons were about the commit: six remotes were HTTPS and cannot
authenticate unattended, three checkouts were behind their remote, one URL had
no .git suffix so the catalogue published a clone URL unlike every other, and
offscale's fetch refspec named a branch that does not exist.

that last one is why this is a script and not a habit. every command reported on
a phantom — `git push` said "Everything up-to-date" while the branch looked
diverged — and the repository was fine the whole time. twenty minutes spent in a
repo with nothing wrong with it.

none of it is visible until you try to push, which is the worst moment to learn
it: mid-task, across 21 repositories, and every failure looks like it is about
your change.

so it checks the facts with one right answer — remote URL, fetch refspec,
branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a
separate flag so running the check can never be what changes your tree, and
divergence is never auto-repaired because it needs a decision about content.

verified by breaking each case on purpose and watching it catch them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 06:55:03 +00:00
pastilhas 2360a3ef60 agents.md holds the instructions, claude.md points at it
opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the
content living in CLAUDE.md, every doc here was invisible to opencode. Swaps
all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now
holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's
permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the
workspace-level officer-dev/CLAUDE.md check is untouched, out of scope.
2026-08-27 06:35:31 +00:00
pastilhas 185aa3168e two todos from the caldav extraction
setting up a phone should be one link inside officer — open Calendar, the screen
already knows this phone is not set up, click, done. the endpoint for it exists
(POST /api/caldav/provision/ios) and nothing in the UI calls it, so the smooth
path is built and hidden while the path a new person walks has four fields.

it also does not currently work: the profile is refused by iOS 26.6 as "Invalid
Profile". the todo records what was RULED OUT with evidence rather than
reasoning, because that is the expensive part to redo — delivery works, the
phone really received it (the claim is logged now), the server side is correct,
and removing PayloadScope did not fix it. signing is untested and is the first
experiment.

second: a plugin with a sidecar proxy loses the request body on its own routes.
createSidecarProxy registers the whole mount prefix and bodyParser skips it,
which is right for forwarded traffic and load-bearing for uploads — but it
covers the plugin's own routes too. caldav works around it locally; the real fix
is the proxy claiming only the sub-path it serves, which is shared machinery and
not a thing to change with a phone waiting.
2026-08-27 05:46:57 +00:00
pastilhasandClaude Opus 5 fdf50ce2a4 ship screenArtUrl, which the last commit left behind
52f209c6 named its paths explicitly and this file was not among them, so the
export that mount.ts imports never left this machine. alpha pulled, could not
resolve it, and crash-looped nineteen times before the log said so:

  SyntaxError: Export named 'screenArtUrl' not found in module assets.ts

the path-scoped commit is the right habit — two agents share this tree and a
bare `git commit` sweeps up whatever the other one staged. the cost is this: a
list is a thing you can be incomplete about, and tsgo cannot catch it because
the file is right here on disk. only a machine that pulls finds out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 05:04:31 +00:00
pastilhasandClaude Opus 5 52f209c69f caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.

what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.

three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:54:42 +00:00
pastilhasandClaude Opus 5 e0df28ff5d a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the
account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on
every request and have nowhere to put a JWT: /dav, the ios profile under it, and
the two .well-known discovery paths a client probes before it has any credential.

the mechanism for that already existed — unauthenticatedRootPaths, exclusive
claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and
what that list was recording is worth being exact about: `dav` and `.well-known`
are in it because the PLATFORM serves them today, not because they are dangerous
in themselves. once caldav is a plugin, the thing the reservation protects IS
the plugin, and keeping it would mean the platform permanently owning routes for
a feature it no longer contains.

so a reserved name is claimable by a first-party plugin and refused to everyone
else. the owner drew that line today: fine for a plugin we wrote, and letting a
stranger's plugin claim /dav is a question that needs thinking about properly
rather than falling out of a flag. /api stays refused to us too — everything of
officer's lives under it and protocolHeaders is the only sanctioned way in.

.well-known needed two segments, which the single-segment rule refuses. allowed
by NAME rather than by pattern, and the reason is in the same namespace:
acme-challenge is how this machine gets its TLS certificates, so a plugin owning
it could take the domain. caldav and carddav are on the list; nothing else is,
for us as much as for anyone.

the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a
preflight and never calls the route beneath — which to a DAV client is not a
preflight but the question "what can you do", answered by a header iOS refuses
the account without. that was a literal list of four DAV paths; it asks the claim
registry now, so it follows whoever owns the route rather than naming it here.

verified: server boots (totality passes), /.well-known/caldav still 301s and
/dav/ still asks for basic auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:22:27 +00:00
pastilhas 6b5c87da10 a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and
a frontend — happened to be the same string. caldav is where that stops holding:
one sidecar, one permission, one install, and two screens that have nothing to
do with each other on screen. calendar and contacts are not sections of one
page, and forcing them into one would be a worse product than the core screens
they replace.

nothing else moved. all eighteen plugins with a frontend use flat
web/panels.ts, which is now "one screen, at the mount prefix, labelled by the
manifest" — the same file, the same route, the same persisted layout key. the
nested form is opt-in and caldav is its only user.

the split of who declares what follows the rule this file already had. the
DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST
says what to call it and which glyph to draw, because a directory listing cannot
say either and those are a human's choice. the two are cross-checked and a
mismatch is refused rather than reconciled: a segment with no directory renders
nothing, a directory with no entry has no tile and no label, and both are the
quiet kind of broken.

the part that needed the most care is that a segment is a URL the plugin CHOSE
— /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a
plugin to shadow a core route, and a chosen segment steps straight out of it. so
this is first-party only, refused at discovery, and tested: without that check
this field is a way for any installed plugin to take over /files.

screenId, not appName, is now the layout key and the panel allow-list's owner.
two screens sharing one would persist over each other's sizes and each would
accept the other's panels. existing plugins get screenId === appName, so no
stored layout is orphaned — verified by regenerating.
2026-08-27 04:15:51 +00:00
pastilhasandClaude Opus 5 131af603ff remove launcher_config.json, an unused stray from the initial commit
Nothing in this codebase reads or writes it — no code, no docs, no reference to
port 3333 anywhere. Its own last_updated timestamp (Dec 2025) predates this repo,
and its shape (host/port/enable_request_logging, Python-isoformat timestamp)
doesn't match anything Officer generates; there is no Python in this repo at all.
Swept into the first commit by accident, not something the platform depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 04:34:55 +01:00
pastilhasandClaude Opus 5 8a3047b06b fix_ssh_permissions never checked the home directory itself
Asked to look at user provisioning and .ssh dir creation — the recurring pain point
was "the .ssh permissions are never right even when I do everything correctly", and
tracing it: fix_ssh_permissions() only ever touched ~/.ssh and its contents. sshd's
StrictModes (on by default; nothing here turns it off) also refuses authorized_keys
if the HOME DIRECTORY is group- or world-writable — not just .ssh. An account
created by this script's own adduser call gets a safe 0755 home by default, but one
that already existed some other way (cloud-init, a prior useradd, LDAP, or simply an
account that was already there) might not, and the script never checked or reported
on it. From the user's side that looks exactly like the complaint: .ssh is 0700, the
key is present and correctly formatted, and login still fails, with nothing in the
script's own output pointing at why.

Fixed by extending fix_ssh_permissions() — called on every path that adds or
verifies a key — to check the home directory's mode first and clear only the
group/other write bits if set (go-w), reported with a warn rather than done
silently, since it's genuinely surprising when it fires. Deliberately not forced to
a fixed mode: 0755 vs 0750 is a real choice some setups make on purpose, and this
only removes the specific bits sshd refuses to see.

Verified with two simulations: a 775 home directory gets warned about and corrected
to 755 (.ssh and authorized_keys already-wrong modes fixed too, as before); a 750
home directory is left untouched with no warning. The stat call tries GNU's -c
first (this step only runs on Linux) with a BSD -f fallback, same pattern
officer-setup's layout.sh already uses for the same reason — not a live concern
here since MACOS_SKIP keeps this step off macOS entirely, but consistent rather
than a call that would silently do nothing if that ever changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 04:11:16 +01:00
pastilhasandClaude Opus 5 2e7fb84454 fix three officer-setup bugs found on a general review, unrelated to macOS
env_get() never matched its own file. write_env() always writes OFFICERDEV_-prefixed
keys, but env_get() compared against the bare name — verified against a real written
.env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000]. Every one of its 5
call sites uses the bare name, so this always came back empty. Two consequences: the
"recover what earlier runs already decided" block never recovers anything — its own
comment says it exists to fix "Build announced PUBLIC_URL not set on a machine whose
.env had been written twenty minutes earlier", and that exact bug was still live — and
re-running the Environment step showed the wrong default (9000, tailnet URL) instead
of the machine's actual current config. Fixed to check the prefixed key first,
falling back to the bare one for an old unprefixed file, matching the runtime's own
`OFFICERDEV_X ?? X` priority.

pg_password_from_env_file() returned success with an empty string when the compose
.env existed but had no POSTGRES_PASSWORD= line. The caller
(`pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"`) would
then treat that as "reuse this password" and build POSTGRES_URL with a blank
credential rather than falling through to provision fresh. Only reachable via a
hand-edited or corrupted file — write_pg_compose always writes the line — but
"silently reuse a blank password" was the wrong direction to fail in. Now returns 1
when the line is missing or empty.

SETUP_DOCKER_NETWORK was read independently by both scripts, each defaulting to
"services", but never actually persisted across the machine-setup -> officer-setup
boundary — not one of the three keys load_machine_answers recognised. A customised
bridge name from machine-setup would silently not carry over, and Proxy would land
back on "services" regardless of what Docker was actually told to use. Now saved by
machine-setup's save_answers(), recognised by officer-setup's load_machine_answers(),
and PROXY_NET is re-derived from it right after that load — proxy.sh's own top-level
assignment runs at source time, before the answers file could have populated the
variable, same ordering issue ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL already had to
solve.

All three verified with isolated reproductions before and after: env_get against a
real prefixed .env and a legacy bare one, pg_password_from_env_file against a normal
and a corrupted compose .env, and the full save/load/re-derive chain for
SETUP_DOCKER_NETWORK. bash -n clean on every touched file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 04:01:52 +01:00
pastilhasandClaude Opus 5 5dc9f2d5f7 neovim had no macOS install path at all — it always tried the Linux tarball
Asked to check how the script installs Neovim on Mac, and the answer was: it doesn't.
nvim_install() had zero `$OS` branching, so on macOS it unconditionally fetched
nvim-linux-<arch>.tar.gz — a URL that 404s there. Not a hard crash: the caller's
`if nvim_install; then … else warn …` catches it and reports "Neovim: install failed"
rather than aborting the run, which is exactly the shape that's easy to miss in a long
scrollback. brew has a current, real neovim formula (0.12.5), so that's the macOS
branch — no keg-only linking needed, unlike the Postgres client fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 03:55:21 +01:00
pastilhasandClaude Opus 5 d02aac1eb8 drop htop from the core package list, unused next to btop
Nothing references it — btop is the one the System Monitor app actually embeds
(docs/navigation-audit.md, /system-monitor/btop). htop was just sitting beside it on
all four package managers with no caller anywhere, and its own first-run message asks
for sudo to be useful at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:49:41 +01:00
pastilhasandClaude Opus 5 e37fe8330e skip radicale and officer-caldav on a macOS dev install, not the platform
Hit live: `brew install radicale` fails outright, "No available formula with the name
radicale" — it is a pure-Python package (pip/pipx), not something Homebrew carries, unlike
every other name in that list. apt/pacman/dnf all package it for real and are untouched.

Narrower than removing the feature: officer-caldav (Calendar/Contacts) stays CORE on Linux.
On macOS the sidecar has nowhere to find Radicale — resolveRadicale() failing is exactly the
"took a whole sidecar down on a fresh install" case its own test already guards against — so
CORE_PROCESSES leaves it out on Darwin rather than let it crash-loop. Calendar/Contacts is
the one core feature not available on a Mac dev install; nothing else changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:40:59 +01:00
pastilhas 681df1c8bc make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.

The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.

Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.

Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:

  - grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
    table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
    jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
    dependency elsewhere in this script, so that's a correctness fix, not just a port.
  - ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
    enough for NPM's container to reach) — both silently mis-reported on macOS, since the
    "command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
    New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
  - getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
    its full 30-minute timeout regardless of whether the record actually resolved. macOS
    branch uses dscacheutil.
  - stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
    option -- c"), and the failure was VISIBLE and made every layout directory report as
    wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
  - apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
    installers (one literally named install_update_linux.sh, the other hardcoding
    "_Linux_" into the release asset URL) — each gets a brew branch, formula names
    verified against the live Homebrew catalog rather than assumed.
  - sudo -u "$USERNAME" to become the SAME account the process already is — real on
    Linux, where this whole script runs as root and dropping to the owner needs no
    password; on macOS the process never escalates in the first place, so re-invoking
    sudo for yourself asks for a real password on every call, or fails outright without
    sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
    opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
    setup's own as_owner() (already the shared entry point there) got the same branch,
    and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
    were rerouted through it too.

Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).

Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.

Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
2026-08-26 21:10:08 +01:00
pastilhasandClaude Opus 5 7e6cfc85af one resolver for where an email chat lives
the picker above an email composer listed conversations from the owner's home —
a chat started somewhere else entirely, offered as if it belonged to the mailbox.

the address had two resolvers. the chat socket knew `context: 'email'` and ran
the turn in the account's directory; `GET /chat/sessions` did not, and fell
through to `who.home`. so a message went into the mailbox and could not be found
in the list directly above where it was typed. two answers to "where does this
chat live" is one too many, and the one that was missing failed quietly.

`resolveEmailAccountDir` in data-path.ts is now the only answer, and both call
it. it verifies a client-named mailbox against disk rather than sanitising it
into shape, which matters because the socket's caller ends in mkdirSync — an
unrecognised value would not fail, it would CREATE a directory named after
whatever arrived and run turns in it. `all`, the list's own sentinel for the
unified view, is exactly the value most likely to arrive by accident.

pure: no mkdir, no writes. the caller that needs the directory says so itself.

tested, because a missing branch is what this was: the named mailbox wins, `all`
never becomes a directory, a mailbox with no emails.db is not a mailbox, and no
account at all resolves to the accounts root rather than to something invented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 18:47:37 +00:00
pastilhasandClaude Opus 5 be210c3ed4 a plugin declares its chat directory; email stops being hardcoded here
ChatPanelWrapper had one plugin's screen id compiled into a shared platform
package: `workspace.id === 'email'` → `{ context: 'email' }`. its own comment
said the general fix was a way for a plugin to DECLARE its chat context, and
that a second hardcoded id would be the signal it was needed. that fix exists
(`declared`), the email plugin now passes it, so the branch is gone.

it also could not fire any more, which is the part worth keeping. a plugin's
workspace key is `screens/plugin/<name>`, so email's id became `plugin/email`
the day it was extracted and the test silently stopped matching. a hardcoded id
does not fail loudly when the thing it names moves — it just stops being true,
and nothing said so for eight days.

two things on the backend half:

resolveEmailCwd now installs the email plugin's chat/CLAUDE.md. an email chat
runs in the account's directory, not the plugin's chats directory, so the
`plugin` branch never reaches it — the brief follows the chat rather than the
chat moving to the brief. a second plugin wanting this is the signal that
"which directory does this chat run in" belongs in the manifest.

and it verifies the mailbox exists before joining it. `accountEmail` comes from
the client (the mailbox picker's `?account=`) and this function ends in mkdirSync
— so an unrecognised value did not fail, it CREATED a directory named after
whatever arrived and ran the turn in it. the list's unified view uses the
sentinel `all`, which is the value most likely to show up by accident. the panel
drops it; this is the half that does not depend on the panel remembering to.

docs/claude-md-touchpoints.md updated: the copier now has two callers.

unrelated and NOT from this change: ModelSelector's "an unstarted composer gets
a default" fails on master too — verified by stashing these files and rerunning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 18:43:13 +00:00
pastilhasandClaude Opus 5 d58652bf14 a deleted bot stays deleted
the bot came back three times after being deleted, untitled each time, and the
mechanism is worse than an orphan process.

its watch had no record, so deleting it stopped nothing. the watch kept polling,
fired, and the hand-back resumed the session by transcript id — at which point
`claude --resume` RECREATED the transcript that had just been deleted. so the
bot reappeared in the list and did it again on the next fire. same shape
22a9d58d fixed for commanders, arriving from a different direction.

the record could never have covered this: it depends on the bot having written
one, and a bot that did not is precisely the one nobody can stop. a mechanism
that only works when the agent remembered is not a mechanism.

so deleting also sweeps: any process running a .sh inside the bots directory
whose pid appears in NO record is killed. claimed pids are never touched,
whatever else is true — a live bot's watch is the one thing this must not
reach. reads /proc rather than shelling out, since it runs inside the delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 08:14:46 +00:00
pastilhasandClaude Opus 5 f6bd7564bb a bot names itself after it arms
the automatic title is the owner's first sentence truncated — "can you notifiy
me when https://newweb.fofos…", typo included. that is a prompt, not a name,
and it is what they scan a list of bots for later.

so after arming: two to four words, the subject and what is watched about it.
"newweb uptime", "NYT front page". explicitly NOT "Website Monitor Bot" — every
bot is a monitor and the word earns nothing in a list of them.

this also closes the hand-wave in the record step. it said "your session id is
the file name you were told to use — if you do not know it, look at which
transcript you are in", which is not an instruction anybody can follow. now it
lists /chat/sessions?context=bots and matches on the title, because claude
titles a session from its first message and on the arming turn that entry is
you.

which puts the three steps in a REQUIRED order, stated: find your id, write the
record, then rename. renaming first destroys the only thing that identifies you
in that list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 08:09:54 +00:00
pastilhasandClaude Opus 5 84d9c9d2d8 deleting a bot stops it, and a dead bot wakes up
a bot was deleted tonight and its watcher was still polling a website eleven
minutes later. nothing in the product could find it: a bot's watch is a
detached shell process, killing the harness ends the agent, and the loop it
started is not its child.

so a bot writes <sessionId>.bot.json beside its script — what it watches, the
script, the state file, the pids. that record is the ONLY thing connecting a
process to the bot that owns it, and the brief now says so in those words
rather than as bookkeeping.

the id is in the FILENAME because bots share a directory: deleting knows only
an id, so this is a lookup rather than a scan, and a record whose contents are
damaged still gets found and cleaned up.

DELETE /chat/sessions/:id tears it down after forgetting the tables — children
first then the recorded pid, same ordering as stopCommanderProcesses, so the
loop cannot spawn a replacement between the two. every transcript in the
returned chain, not just the id asked for. unconditional rather than gated on
"was this a bot", because the record only exists in the bots directory and a
branch on cwd is a second place to get it wrong. script and state go too, and
only if they are inside the bots directory — an agent wrote that path.

and the reviver sweeps bots on the same tick. a record with pids and none of
them alive is a stopped watch; there is no armed flag and there should not be,
since the record is written when it arms, so its existence IS the intent. the
three-wake bound is shared, keyed bot-<sessionId>.

the wake prompt forbids reconstructing a watch from memory when the record is
gone: a watch subtly different from the one agreed is worse than none, because
it looks like it is working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 08:04:49 +00:00
pastilhasandClaude Opus 5 513a50f2e8 a bot is a card, not a conversation in a list
it looked like the generic chat panel because it WAS one, and that leaks the
implementation at exactly the wrong moment: a bot is a chat underneath, and
nobody using this should have to hold that. the session list says
"conversation, last touched" because that is what a conversation is.

so it gets Command HQ's object instead — a card with a name, an id pill, rename
and delete, selected ring, controls on hover. same arrangement: the whole card
is the link with the buttons layered over it, so a click anywhere opens it and
the controls still work.

the pill is the first eight characters of the session, which is also what an
@mention carries. that is the point rather than a coincidence — the thing shown
on the card is the thing typed to reference it.

the panel's own header picker is gone. the cards are the list, and a second
picker in the panel bar is the same choice offered twice. command hq does not
have one either.

deleting the selected bot navigates back to /bots, or the route points at
something that no longer exists and renders an empty chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:50:55 +00:00
pastilhasandClaude Opus 5 8b3ad8418a the brief carries the mechanism, so a bot stops going to find it
arming one watch took eight discovery calls and a 307-line read of the
commander skill before anything happened. the brief said "use the background
job you already know" and it did not know it, so it went looking — and what it
found is a document about spawning agents in a loop, almost none of which
applies to a bot.

so the mechanism is written down here instead: write the script in this
directory, run it with the Bash tool and run_in_background, the script waits in
the shell and exits when the condition is true, the exit is the signal, re-arm
before ending the turn. plus the shape almost every bot is a variation of, with
the twice-in-a-row rule already in it.

and it says NOT to read the commander skill, by name. an instruction to look
somewhere is an invitation to read everything on the way.

one real fix rides along: the first bot wrote its script to /tmp, which does
not survive a reboot. the template puts the script and its state file beside
each other in the bots directory, and says to write the new state BEFORE
exiting — otherwise the next arming fires on the same change again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:46:56 +00:00
pastilhasandClaude Opus 5 20d60cd3be /bots looks like Command HQ, and stops opening the wrong chat
TWO BUGS AND A LAYOUT.

the first outing of /bots opened a months-old conversation about deleting proxy
hosts. resume-newest called useClaudeSessions(absoluteCwd) with no context, and
a screen that owns its own directory has cwd '~', so that join yields null and
the hook listed the caller's HOME. the panel then resumed the newest thing
there. same bug the header had, one function down; the declared context wins now.

second: ChatPanelWrapper takes an optional sessionId, because a screen whose
selection lives in the URL cannot also let usePanelSession decide — two sources
would disagree and the URL would lose. null means "the screen chose a new chat",
undefined means "no opinion", and collapsing them would make New snap back to
the newest.

the layout is Command HQ's: a narrow list left, the conversation right, and a
route pair — /bots is the new-bot state, /bots/:sessionId is one of them. rows
are real links, so cmd-click works and a reload keeps its place.

no categories pane. a commander is a job somebody files and a filter earns its
place; a bot is a sentence, and a filter over a handful of one-line watches
would be furniture.

no table either: every bot is a chat in bots/, so the list IS the session list
for that directory. nothing to keep in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:21:28 +00:00
pastilhasandClaude Opus 5 f119bbdf93 a bot has defaults, so it stops asking
"notify me when x.com is down" is a complete instruction. the brief now says so,
and lists every question it was likely to ask along with the answer it should
use instead: one minute, twice-in-a-row before calling something down, follow
redirects, ten second timeout, extract the smallest stable piece rather than the
whole page.

two of those are the difference between a bot somebody keeps and one they turn
off. firing on a single failed request produces a watcher nobody trusts. and the
FIRST run establishes the baseline without firing — otherwise arming a headline
watch immediately announces the current headline, which nobody asked about.

and it can be told who to tell in prose. "@Name [id]" carries an id, but "the
todo guy" is how it will actually be said — so: look it up in the chat list,
match on title, use it if exactly one matches and say which. several matches is
worth the one question. no match is worth asking.

the line between defaulting and asking is stated rather than left to taste: no
address at all is a question, a missing interval is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:17:08 +00:00
pastilhasandClaude Opus 5 de538cfc1b /bots — one sentence arms a watch
a bot is a chat whose working directory is bots/, armed through the background
job any chat can already arm. no scheduler was written and none was needed:
handoff already means "poll cheaply, wake the agent only when it fires", which
is the expensive half of a commander without any of its ceremony.

ONE directory, not one per bot. a commander earns its own because it is a job
with procedures and a ledger; a bot is a sentence and a watch, and a directory
each would mean a creation step before anything can be said. sharing it makes
the list free — every bot is a chat in that cwd, so the header's picker is the
list of bots.

it earns NO permission of its own. /bots rides `chat`, because a bot adds no
surface: same routes, same socket, same confinement. the registry says so
rather than leaving the next reader to wonder.

the brief is the feature. it tells the agent to arm on the FIRST message and
not interview — pick a sensible interval, arm, say what you armed in one line,
and let the owner correct it, which is cheaper for them than three questions.
it says the owner will never say "notify" or "nudge" and must not have to. it
forbids the loop that wakes on a timer to look for itself, which is the
expensive mistake available here. and it draws the line: a bot watches and
tells, it does not fix — a request for work is a commander, say so and stop.

bots joins commanders and plugins in confineUserTree, third time: a member's
bot runs as the member and a 700 service-user directory kills the turn on its
own cwd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 07:13:57 +00:00
pastilhasandClaude Opus 5 2faf1fa4b2 a mention renders as a pill, and the id stops being visible
@Todo Guy [6305e570] read as literal text in the bubble, brackets and all,
which is not what a reference should look like.

it renders as a LINK node at #officer-mention-<id>, and the bubble's existing
`a` override turns that into a pill. a span with a class was the obvious thing
and does not survive — rehypeSanitize strips exactly that. a fragment href
passes untouched. inlineCode also survives and was dropped because it cannot be
told apart from somebody typing backticks around an @; only a real mention
should look like one.

it is a span rather than an anchor: there is nowhere to navigate to yet, and
opening the referenced conversation is a real feature with its own decisions.
it should not pretend to be clickable.

the pill reads @Todo Guy. the [6305e570] stays in the text that reaches the
agent, which is the entire reason it is there. discord does the same — a human
reads a name, the machine reads an id.

hand-rolled tree walk: installs are frozen and neither unist-util-visit nor
@types/mdast is in the lockfile, so a nine-line walk does not get to change it.
node shapes declared locally, only the fields this touches.

tested against over-matching, which is the failure that would matter: an email
address, a bracketed price, a non-hex id. eight hex characters is the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 06:52:20 +00:00
pastilhasandClaude Opus 5 b5385ff40f @ in the composer, to reference another chat
type @, a list appears, keep typing to narrow, arrows to move, enter or tab to
pick, escape to dismiss. the shape everybody already knows.

it inserts `@Todo Guy [6305e570]` rather than just a name. the composer is a
textarea — discord renders a pill because it uses a contenteditable, and
matching that means replacing the composer — so a mention here is text, and the
text has to carry enough to ACT on. 28 sessions on this machine, several with
near-identical opening lines: "the todo one" is not a resolvable instruction.

COMMANDERS COME FROM COMMAND HQ, not the chat list, and that is the whole
reason this is not three lines. a commander's real name lives in its row; its
transcript title is the setup preamble it was created with. a picker reading
only /chat/sessions would show "You have been named "Platform Deployer", and
that is th…" for exactly the sessions most worth referencing.

the empty-query list is commanders then recent chats. it would be better to
offer only sessions somebody NAMED — the server knows, as hasExplicitTitle —
but that field is not in the client payload, and a heuristic for "does this
look like a name" would be wrong more often than recency is. said so in the
file rather than pretending.

two details that are bugs if missed: the token follows the CARET, not the end
of the value, so editing mid-sentence still opens the menu; and the query
survives a space, because the names people give things have spaces in them and
a picker that closed on the first one would miss every one of them. both
tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 06:39:27 +00:00
pastilhasandClaude Opus 5 0a4d7f8ad9 deploy report 2026.08.26.02
five of five target hosts, no schema change, standing db:push diff only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 04:39:40 +00:00
451 changed files with 10518 additions and 4929 deletions
+578 -25
View File
@@ -1,41 +1,594 @@
# AGENTS.md
Guidance for any coding agent working in the Officer platform repo.
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
Guidance for any coding agent working in the Officer platform repo. **This is the file with the
instructions in it**; `CLAUDE.md` beside it only points here. Both exist because the two harnesses
discover different names — opencode walks the tree for `AGENTS.md` and nothing else, Claude Code for
`CLAUDE.md` — and one set of instructions must reach both.
## Orientation
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
per-role permission grants, resolved on every request.
Officer is built around **one owner** (user id 1, role `Super Admin`, who bypasses every permission
check), which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. What
each may reach is decided by per-role permission grants, resolved on every request.
If a design question turns on "which user", the answer depends on the surface: real for the **app**
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything that
executes code or touches the disk — terminal, chat, tasks, files, desktop and browser are
`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
section of `CLAUDE.md` before adding one.
**Mounting a router without a registry entry makes the server refuse to boot.** Read the Permissions
section below before adding one.
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
management". That was written to correct an _older_ drift in the opposite direction — a fictional
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
paragraph above; treat the permission registry as the source of truth over either.
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as plain
files, and is where many changes belong — adding or changing a task needs no code change here and no
restart.
This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
plain files, and is where most changes belong — adding or changing a task needs no code change here
and no restart.
- [`CLAUDE.md`](CLAUDE.md) — this repo's architecture, conventions and code style
- [`docs/working-on-officer.md`](docs/working-on-officer.md) — the deployment-wide guide: which
directory a change belongs in, how to run and verify it, the task system, and the failure modes
worth knowing about
directory a change belongs in, how to run and verify it, the task system, and the failure modes worth
knowing about
- `CONVENTIONS.md` — component organisation and React patterns, with rationale
- `TODO.md` — current direction; takes precedence where it disagrees with anything else
Treat the code as the source of truth where anything disagrees with it.
## Project Overview
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
behind a single web app.
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
written: `users` holds six rows. The accurate statement is narrower and more useful —
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
HTTP routes, the websocket doors and the dock together.
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
below before adding one.
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
the counts stay right and the sentences rot.
## Architecture
One Bun process (`src/server.tsx`) serves everything:
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
deliberate — it is the owner's own machine. Do not add a jail without being asked.
## Repository Layout
```
src/
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
├── apps/
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine
│ └── sidecar/ # sidecar implementations + the wire protocol
├── databases/officer_db/ # the only database (Postgres + Drizzle)
├── extensions/browser-relay/
└── workspaces/ # shared packages, each a bun workspace
```
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
`src/apps/officer-web` is only the shell — screens, routing and settings.
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
## Tech Stack
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
## Data
Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
catalogue served by a process with no login, no session and no user, so sharing a connection would put
an unauthenticated surface one query away from every private table. It runs only on the host serving
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
what is inside it.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
failing.
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
passed).
### Schema changes use `push`, not migrations
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
history was deleted because it had drifted from the real schema. Treat the schema code, not those
files, as the source of truth.
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
"Composite keys" before adding either.
## Security Model
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
rules key off it — they fail closed.
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
is a valid token on every protected route plus the permission gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts.
### Permissions — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
survive the next door; refusing to boot does.
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time.
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
into a refusal.
## Commands
```bash
bun dev # the whole app — SPA + API + WebSockets, watched
bun start # production
bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform
```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
needs to, and on macOS never does, because Homebrew refuses to run as root.
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
See `docs/working-on-officer.md` for which process a given change needs restarted.
### Installs are frozen. Never resolve a dependency you did not ask for.
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
`error: lockfile had changes, but lockfile is frozen`.
It is config rather than a habit because a supply-chain compromise does not wait for the one time
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
unaffected only because nothing had installed since 2026-08-02.
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
in a diff is the signal this exists to produce.
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
## Code Style
- **Paradigm**: functional — pure functions, immutability, composition
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
- **Async**: always async/await
- **Exports**: named only, no defaults
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
### Imports
Order: types → external → workspace → relative.
```ts
import type { User } from 'types';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { formatDate } from 'helpers/formatters';
import { useWebsites } from '../useWebsites';
```
In app code import types from `'types'`, which re-exports the database types. Only server and
database code imports from `officerdb/types` directly.
### Functions
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
parameter lists — extract a params type instead.
```ts
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
export function processData({ body, query }: ProcessDataParams): Result { ... }
```
### Components and hooks
```tsx
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
```
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
always named `ev`, never `e`.
### API calls
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
type parameter and let inference flow from it.
## Working With Me
- **Ask first** — confirm the approach before a significant change
- **Explore thoroughly** — read the related files before editing
- **Keep it simple** — no over-engineering, no premature abstraction
- **Be explicit** — no magic, no implicit behaviour
- **Stay focused** — note unrelated problems, don't fix them uninvited
- **Report honestly** — say what you verified and what you didn't
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
withholding the commit until it is good.
Commit messages: simple lowercase, no prefixes.
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
the two-step form only looks like it does.
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
follow-up note instead. Cheap to avoid, awkward to undo.
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
wrong commit, and only because the tag was checked afterwards did that surface at all.
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
```
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
```
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
restart in a single night. The transcript id survived both.
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
whose "why" is still answerable in six months.
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
only exposure is that a background task's output path contains it, which means starting a job to learn who
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
add the trailer and one that does not should leave it out rather than guess.
## Frontend route conventions (apply to EVERY new dashboard route)
Worked examples: `/soulseek`, `/music`, `/chat`.
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
`<WorkspaceView workspace={ws} locked />` where
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
that pins `appType`s to an allow-list. Panels are windowed apps under
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
`usePublishPageTitle` in `officerdev`.
### The URL is the source of truth for selection
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
link-focusable). Half the app still does this; none of the new code should.
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
_mutates_ rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
canonicalises both the bare route and a bogus param.
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
Action buttons are **siblings** of the anchor, never nested inside it.
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
when you want `isActive`.
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
## COMMS — a channel between agents, when one is open
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
sessions.
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
convention rather than an oversight: a spent channel left in place gets read as current.
If you open one:
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
agent read identically.
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
participant. "I think we're done" can close a thread with work still in it.
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
lands, delete the channel and move anything still open to `TODO.md`.
Two things that made it work, and neither is about either agent being more careful. One writes, the other
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
explaining why it was safe.
## Further Reading
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
a number produced by searching is confidently wrong with no signal that it is
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
question to settle first, which is retention rather than anything technical
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
agent. Read before writing one
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
it is what `docs/workspace-panel-todo.md` is ranked against.
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
persistence key families. Read before building a panel app. Its defect list is
`docs/workspace-panel-todo.md`.
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
check turns into an endless resync between servers without it. The server identifier is an SSO decision
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
before any agent is asked to look
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
one home directory happened to be mode 751
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
anything it already rules out
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
conversation. The CLAUDE.md in its own working directory is the living document, for free
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
`proxy-manager` ships a chat brief today)
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
a namespace makes provenance readable, separate directories make it structural
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
get wrong — a team folder that is not `setgid` charges nothing to the team
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
- `src/apps/CLAUDE.md` — shared frontend patterns
- `src/databases/CLAUDE.md` — database patterns
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
Treat the code as the source of truth where anything here disagrees with it.
+3 -560
View File
@@ -1,563 +1,6 @@
# CLAUDE.md
## Project Overview
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
behind a single web app.
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
written: `users` holds six rows. The accurate statement is narrower and more useful —
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
HTTP routes, the websocket doors and the dock together.
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
below before adding one.
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
the counts stay right and the sentences rot.
## Architecture
One Bun process (`src/server.tsx`) serves everything:
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
deliberate — it is the owner's own machine. Do not add a jail without being asked.
## Repository Layout
```
src/
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
├── apps/
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine
│ └── sidecar/ # sidecar implementations + the wire protocol
├── databases/officer_db/ # the only database (Postgres + Drizzle)
├── extensions/browser-relay/
└── workspaces/ # shared packages, each a bun workspace
```
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
`src/apps/officer-web` is only the shell — screens, routing and settings.
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
## Tech Stack
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
## Data
Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
catalogue served by a process with no login, no session and no user, so sharing a connection would put
an unauthenticated surface one query away from every private table. It runs only on the host serving
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
what is inside it.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
failing.
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
passed).
### Schema changes use `push`, not migrations
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
history was deleted because it had drifted from the real schema. Treat the schema code, not those
files, as the source of truth.
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
"Composite keys" before adding either.
## Security Model
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
rules key off it — they fail closed.
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
is a valid token on every protected route plus the permission gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts.
### Permissions — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
survive the next door; refusing to boot does.
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time.
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
into a refusal.
## Commands
```bash
bun dev # the whole app — SPA + API + WebSockets, watched
bun start # production
bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform
```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
needs to, and on macOS never does, because Homebrew refuses to run as root.
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
See `docs/working-on-officer.md` for which process a given change needs restarted.
### Installs are frozen. Never resolve a dependency you did not ask for.
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
`error: lockfile had changes, but lockfile is frozen`.
It is config rather than a habit because a supply-chain compromise does not wait for the one time
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
unaffected only because nothing had installed since 2026-08-02.
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
in a diff is the signal this exists to produce.
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
## Code Style
- **Paradigm**: functional — pure functions, immutability, composition
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
- **Async**: always async/await
- **Exports**: named only, no defaults
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
### Imports
Order: types → external → workspace → relative.
```ts
import type { User } from 'types';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { formatDate } from 'helpers/formatters';
import { useWebsites } from '../useWebsites';
```
In app code import types from `'types'`, which re-exports the database types. Only server and
database code imports from `officerdb/types` directly.
### Functions
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
parameter lists — extract a params type instead.
```ts
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
export function processData({ body, query }: ProcessDataParams): Result { ... }
```
### Components and hooks
```tsx
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
```
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
always named `ev`, never `e`.
### API calls
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
type parameter and let inference flow from it.
## Working With Me
- **Ask first** — confirm the approach before a significant change
- **Explore thoroughly** — read the related files before editing
- **Keep it simple** — no over-engineering, no premature abstraction
- **Be explicit** — no magic, no implicit behaviour
- **Stay focused** — note unrelated problems, don't fix them uninvited
- **Report honestly** — say what you verified and what you didn't
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
withholding the commit until it is good.
Commit messages: simple lowercase, no prefixes.
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
the two-step form only looks like it does.
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
follow-up note instead. Cheap to avoid, awkward to undo.
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
wrong commit, and only because the tag was checked afterwards did that surface at all.
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
```
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
```
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
restart in a single night. The transcript id survived both.
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
whose "why" is still answerable in six months.
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
only exposure is that a background task's output path contains it, which means starting a job to learn who
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
add the trailer and one that does not should leave it out rather than guess.
## Frontend route conventions (apply to EVERY new dashboard route)
Worked examples: `/soulseek`, `/music`, `/chat`.
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
`<WorkspaceView workspace={ws} locked />` where
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
that pins `appType`s to an allow-list. Panels are windowed apps under
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
`usePublishPageTitle` in `officerdev`.
### The URL is the source of truth for selection
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
link-focusable). Half the app still does this; none of the new code should.
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
_mutates_ rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
canonicalises both the bare route and a bogus param.
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
Action buttons are **siblings** of the anchor, never nested inside it.
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
when you want `isActive`.
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
## COMMS — a channel between agents, when one is open
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
sessions.
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
convention rather than an oversight: a spent channel left in place gets read as current.
If you open one:
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
agent read identically.
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
participant. "I think we're done" can close a thread with work still in it.
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
lands, delete the channel and move anything still open to `TODO.md`.
Two things that made it work, and neither is about either agent being more careful. One writes, the other
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
explaining why it was safe.
## Further Reading
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
a number produced by searching is confidently wrong with no signal that it is
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
question to settle first, which is retention rather than anything technical
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
agent. Read before writing one
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
it is what `docs/workspace-panel-todo.md` is ranked against.
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
persistence key families. Read before building a panel app. Its defect list is
`docs/workspace-panel-todo.md`.
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
check turns into an endless resync between servers without it. The server identifier is an SSO decision
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
before any agent is asked to look
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
one home directory happened to be mode 751
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
anything it already rules out
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
conversation. The CLAUDE.md in its own working directory is the living document, for free
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
`proxy-manager` ships a chat brief today)
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
a namespace makes provenance readable, separate directories make it structural
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
get wrong — a team folder that is not `setgid` charges nothing to the team
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
- `src/apps/CLAUDE.md` — shared frontend patterns
- `src/databases/CLAUDE.md` — database patterns
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
Treat the code as the source of truth where anything here disagrees with it.
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
+43 -42
View File
@@ -22,43 +22,44 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**100 open, 31 closed**, across 33 themes.
**106 open, 33 closed**, across 34 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
| [Affordances](todo/affordances/) | 2 | 0 |
| [Agents coordinating agents](todo/agent-coordination/) | 4 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
| [Backburner](todo/backburner/) | 3 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 1 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 5 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [SendOff](todo/sendoff/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 2 | 0 |
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
| [Backburner](todo/backburner/) | 3 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 1 |
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 2 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 6 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [SendOff](todo/sendoff/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning
@@ -69,13 +70,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
- `2026-08-25` **Sweep: AGENTS.md becomes the real instructions everywhere, CLAUDE.md a pointer beside it** — [docs-conventions/sweep-agents-md-primary-claude-md-pointer](todo/docs-conventions/sweep-agents-md-primary-claude-md-pointer.md)
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
+98 -35
View File
@@ -122,6 +122,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"sounds": "workspace:*",
@@ -365,7 +366,7 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
@@ -443,31 +444,43 @@
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
@@ -475,11 +488,13 @@
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
@@ -2135,7 +2150,7 @@
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -2147,7 +2162,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -2419,6 +2434,20 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-linux-arm/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-linux-arm64/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-linux-x64/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-linuxmusl-arm64/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-linuxmusl-x64/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2519,6 +2548,8 @@
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -2557,6 +2588,8 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"node-pty/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
@@ -2597,35 +2630,23 @@
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
"sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
"sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
"sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
"sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
"sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
"sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
"sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
"socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -2727,6 +2748,46 @@
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ=="],
@@ -2899,6 +2960,8 @@
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"react-email/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
"wif/bs58check/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
+119
View File
@@ -0,0 +1,119 @@
# Deploy report — 2026.08.26.02
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`).
Tag `deploy/2026.08.26.02` → `121a77ce`. `package.json` at that commit reads `2026.08.26.02` — tag and
version agree. Every host ended at `121a77ce` exactly, merged by `--ff-only` against the tag rather than
the branch.
Second release under this watcher, and the second the loop has executed. The claim was taken by the
watcher before this agent was spawned; this agent recorded `done` after the rollout.
## The release changed no schema, and that makes the `db:push` reading solid
This release touches five files: two deploy documents, `package.json`, and
`src/workspaces/state/useAuthoredLayout.ts` with its test. **No `schema.ts` changed**, so drizzle's desired
state is byte-identical before and after the merge. Anything `push` wants is pre-existing drift, deductively
rather than by assumption.
Per doctrine, exit 0 was not taken as evidence. Every host was run with `--verbose` to read the statements
push actually wanted, and then queried directly.
**The standing diff from `2026.08.26.01` is unchanged and is present on all five:**
```sql
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
```
`alpha` additionally wants the documented drop-and-re-add of `pk_music_now_playing`. Nothing new appeared on
any host — the set of wanted statements is identical to last night's, host for host.
Verified by query, per host, not inferred:
| Host | `command_hq_sessions.reference_paths` | `pk_music_now_playing` | `roles` |
| ---------------------------- | ------------------------------------- | ---------------------- | ------- |
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
Each database is already in the state the schema code asks for. The rollout was not stopped for it: the
plan's stop condition is "if it wants to alter something", and this is drizzle re-emitting a statement
whose effect is already in place. **That stop signal is still spent**, exactly as last night's report said —
worth fixing at the schema code, and it is the one thing here the owner should act on.
No direct SQL was run anywhere. None was authorised and none was needed.
## Per host
Steps in plan order on every host: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
`pm2 restart officer`. No `bun install` anywhere — the plan forbids it and no dependency changed.
`officer-claude-code` was not restarted anywhere, per the plan.
### `alpha` — OK
- `e9f1760f` → `121a77ce`. Tree clean, no untracked files.
- `db:push` exit 0; standing diff plus `pk_music_now_playing`; database verified correct by query.
- `plugins:update` 14/14 in 55s. That step bounces roughly a dozen sidecars itself (`sidecar: started` per
plugin) — more moved on this host than the `officer` line below suggests.
- `officer` restarted: `online`, `restart_time` 4 → 5.
### `fofos` — OK
- `a7c9dab2` → `121a77ce`, the short fast-forward the plan predicted: it adds only the version bump and the
plan file, since the fix itself was hand-applied here at 05:22. Not drift.
- One untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward cannot touch it, and
nobody's work was discarded.
- `db:push` exit 0; standing diff only; verified by query. 43 tables in `public`.
- `plugins:update` 3/3 in 21s (`browser`, `gitea`, `proxy-manager`).
- `officer` restarted: `online`, `restart_time` 31 → 32.
- **This is a host the release actually matters on** — it has the `browser` plugin whose layout names
`officerdev/chat`.
### `production-pertento-bridge` — OK
- `e9f1760f` → `121a77ce`. Tree clean.
- `db:push` exit 0; standing diff only; verified by query.
- `plugins:update` 1/1 in 4s.
- `officer` restarted: `online`, `restart_time` 9 → 10.
### `deadpool-rezio-bridge` — OK
- Logged in as `br`, per `docs/servers.md`.
- `e9f1760f` → `121a77ce`. Tree clean.
- `db:push` exit 0; standing diff only; verified by query.
- `plugins:update` 2/2 in 7s.
- `officer` restarted: `online`, `restart_time` 15 → 16.
### `macbookpro` — OK, and it was awake
- `e9f1760f` → `121a77ce`. Tree clean.
- `PATH` appended with `/opt/homebrew/bin` for the `bun` steps; `pm2` run as a separate command on the
interactive shell, so nothing was shadowed.
- `db:push` exit 0; standing diff only; verified by query.
- `plugins:update`: `no plugins installed — nothing to update`. Not a failure.
- `officer` restarted: `online`, `restart_time` 17 → 18.
## Skipped, and why
- **`officer-dev`** — the host this agent runs on. Not pulled, nothing restarted. It already holds this
code at `121a77ce`. Its working tree carries another session's uncommitted changes
(`src/servers/hono.ts`, `src/workspaces/hooks/src/useClient.ts`, an untracked
`src/servers/hono-error-shape.test.ts`) — untouched. **The owner restarts `officer` here deliberately,
as the last step.**
- **`edge-pertento-bridge`** — skipped on the plan's instruction: 300+ commits behind with no `roles`
table, its catch-up is a by-hand job, do not contact it. It was not contacted.
Note the disagreement, because an orchestrator reading the wrong document gets this wrong: `docs/servers.md`
lists this host in the platforms table as a deploy target again, with an empty "Deliberately skipped"
section. The **plan** overrides it, and the plan is right — a `db:push` on a host with no `roles` table is
the exact shape of change this doctrine refuses. `docs/servers.md` is stale on this point.
## What was not verified
**The fix itself was not clicked.** The plan's check is "open `/browser`, hard refresh, confirm it renders
rather than showing 'This screen failed to render'". That is a browser action on a logged-in session, and
this agent has no session on any of these hosts. What is verified is that the code carrying the fix is at
`HEAD` on every target and that `officer` came back `online` on each. **The one-click confirmation is
still owed, on a host with the `browser` plugin — `fofos` or `alpha`.**
+37
View File
@@ -0,0 +1,37 @@
# deploy/2026.08.27.01
55 commits since `deploy/2026.08.26.02`. A full day's work, most of it in two areas.
## What is in it
**Calendar and contacts became usable rather than readable.** Create, edit and delete on both screens; a
filter on each; `?collection=<name>` instead of an escaped DAV path. The agent skill was rewritten off
hand-written iCalendar onto JSON routes — an editor now patches the stored text rather than rebuilding it,
so alarms, attendees and recurrence rules survive an edit made from a form that cannot show them.
**OpenCode can reach the platform.** It gets the skills index, an Officer identity instead of "you are a
coding agent", an API credential, and its permission prompts answered — a server session has nobody to
answer them and a turn simply hung. Its chats also keep their name, their transcript and their model when
reopened.
**Smaller, and each one somebody hit today:** `/api/notify/user` (and `/broadcast`, which had answered 404
since it landed — shadowed by the sidecar proxy's catch-all); a commander started from a plugin chat, so
pointing one at a mailbox no longer needs a path only the owner can type; file preview inside a
commander's own browser; a dock tile for `/bots`; and the new wordmark in the header, following the theme.
## Risk
Low-to-moderate. Nothing here changes a schema. The two worth watching after the rollout:
- **`useThemeAsset`** now reads in `requestAnimationFrame` rather than during the effect. It fixes the
logo and the wallpaper being one theme behind; if a themed asset ever fails to appear, this is the first
place to look.
- **The notify router's mount order.** The platform's own routes now sit before the sidecar proxy's
catch-all. Device registration still forwards — verified — but it is the change most likely to surprise.
## Hosts
Seven. `officer-dev` is skipped: it is the host running the rollout, and restarting it ends the rollout.
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
+78
View File
@@ -0,0 +1,78 @@
# 2026.08.27.02
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
claimed and undeployed; do not try to revive it.
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
## The schema question, answered per host
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
Surveyed 2026-08-28, every host:
| Host | `plugins/caldav/` | `dav_app_passwords` |
| --- | --- | --- |
| `alpha` | present | **8 rows** |
| `fofos` | absent | present, 0 rows |
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
| `macbruno` | absent | present, 0 rows |
| `production-pertento-bridge` | absent | present, 0 rows |
| `edge-pertento-bridge` | absent | present, 0 rows |
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
holds anything:
```
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
# anything else → STOP. Report the count and do not continue on this host.
```
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
4. `bun run plugins:update`
5. `pm2 restart officer`
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
nothing about OpenCode changes.
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
normally and must report no changes.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
it, and it is the owner's call.
## Risk
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
count and it is skipped on the one host where the table has contents.
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
device registration is worth one check (it forwards correctly on `officer-dev`).
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout.
+96
View File
@@ -0,0 +1,96 @@
# 2026.08.27.02 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
was dirty in tracked files, none diverged, none needed a skip.
## Per host
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
| --- | --- | --- | --- | --- | --- |
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
process was left `errored` or `stopped` anywhere.
## The schema, verified by query per host
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
not inferred from an exit code. Final sweep:
```
alpha caldav=yes dav_app_passwords=present rows=8
fofos caldav=no dav_app_passwords=GONE
macbookpro caldav=no dav_app_passwords=GONE
macbruno caldav=no dav_app_passwords=GONE
production-pertento-bridge caldav=no dav_app_passwords=GONE
edge-pertento-bridge caldav=no dav_app_passwords=GONE
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
```
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
are intact.
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
under the plan's own guard rather than under an assumption.
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
`officer-marketplace`, confirmed zero of seven.
## `bun db:push` prints `Changes applied` even when it changes nothing
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
unchanged database. It is unconditional output, not evidence.
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
real content was:
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
`pk_music_now_playing` as the same two-column key
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
said it would.
Not fixed here. A rollout is the wrong place for a schema-code change.
## Plugins
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
deadpool 2/2, macbookpro none installed.
## What was not done
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
owner restarts it deliberately.
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
knowing.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
touch it and it was left alone.
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
## Worth a look, per the plan's own risk note
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
router's mount order changed (device registration is worth one check).
+71
View File
@@ -0,0 +1,71 @@
# 2026.08.28.01
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
Two commits since `2026.08.27.02`, one of which is that release's own report.
## No schema change. No dependency change. No plugin change.
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
plan got wrong, so it is stated here as a check that was run rather than a belief.
## `bun db:push` is deliberately NOT a step
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
mis-diff in `src/databases/CLAUDE.md`.
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
2. `pm2 restart officer`
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
boot.
## Verify, per host
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
401 or 403 means mounted, 404 means the bundle did not move:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
## Risk
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
impersonated — that last one is what stops it chaining into owner powers.
Nothing to roll back but the merge: no data is written and no schema is touched.
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.28.01 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout, and it
was already at the tagged commit.
Every host moved from `91147b40` (`deploy/2026.08.27.02`) to `9c8af9d4` (`deploy/2026.08.28.01`) by
`git merge --ff-only` onto the TAG. Every host started from the same commit, so the fleet was coherent
before this release and is coherent after it. No host was dirty in tracked files, none diverged, none
needed a skip.
This is the first release rolled out under the new watcher loop. The rollout procedure is the same one
that ran the previous nine; what was unproven was the loop around it.
## Per host
| Host | HEAD before | HEAD after | Restarted | Route probe |
| --- | --- | --- | --- | --- |
| `alpha` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9000) |
| `fofos` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
| `macbookpro` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9010) |
| `macbruno` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
| `production-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 2→3, online | **401** (9010) |
| `edge-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 3→4, online | **401** (9000) |
| `deadpool-rezio-bridge` | `91147b40` | `9c8af9d4` | officer 20→21, online | **401** (9000) |
Every restarted process came back `online` with `restart_time` moved. Nothing was left `errored` or
`stopped` on any host.
## The verification, per host
The plan's check is an unauthenticated probe of the new route: `401` proves it is mounted, `404` would
mean the bundle did not move. Run on each host against that host's own port, taken from its `.env` and
not from habit:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
All seven answered **401**. None answered 404, so the frontend bundle and the route moved everywhere.
Three hosts — `fofos`, `production-pertento-bridge`, `edge-pertento-bridge` — answered `000`
(connection refused) on the first probe and `401` seconds later. That is `bun start` still coming up,
not a fault: `pm2` returns as soon as it has respawned the process, before the server binds. A probe
fired immediately after a restart is testing the wrong moment. The probes here retried until the port
answered.
## No schema change — checked, not assumed
The plan claimed no schema change, no dependency change and no plugin change. That claim was
**re-derived before the first host was touched**, not taken on trust:
```
git diff --name-only deploy/2026.08.27.02..deploy/2026.08.28.01^{}
```
Ten files: two `deploy/*.md`, `package.json`, and six under `src/` — a route, a router line, two
frontend files, a workspace app and its index. No `schema.ts`, no drizzle config, no `bun.lock`,
nothing under `plugins/`.
So there is no schema verification section in this report, because there was nothing to verify. That is
the honest answer rather than an omission.
## Steps the plan deliberately did NOT include, and which were NOT run
- **`bun db:push` was not run on any host.** The plan forbids it and gives the reason: it prints
`Changes applied` even when it applies nothing, so it is not evidence, and on this fleet it is not
free — it re-applies a default on `command_hq_sessions.reference_paths` everywhere, and drops and
re-adds `pk_music_now_playing` on `alpha` and `edge-pertento-bridge`. Nothing in this release touches
a schema, so running it would have been churn.
- **`bun run plugins:update` was not run on any host.** No plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
- **`officer-claude-code` was not restarted anywhere.** It ends every live agent session on the host and
nothing in this release needs it.
- **`officer-opencode` was not restarted anywhere.** This release changes nothing it reads at boot.
The consequence worth stating: only `officer` moved on each host. Unlike `2026.08.27.02`, where the
plugins step bounced every sidecar, nothing else on the fleet restarted during this rollout.
## Version agreement
The tag `deploy/2026.08.28.01` is annotated and dereferences to `9c8af9d4`. `package.json` at that
commit reads `2026.08.28.01`. They agree, so there was no question of which tree to deploy.
## Say what was not done
- **`officer-dev` was skipped entirely** — not pulled, nothing restarted. It is the machine this
rollout ran on, and restarting `officer-claude-code` there would have ended the session mid-rollout.
It is already at `9c8af9d4`, so its code is current; what it has not had is a restart of `officer`.
**That is the owner's to do, deliberately, as the last step.** Until then `officer-dev` is running the
previous release's process against the new code on disk.
- **`fofos` carries an untracked `scripts/chk.ts`.** Left alone. An untracked file is a note, not a
reason to skip a host, and a fast-forward could never have touched it.
- **The eight non-Officer processes on `production-pertento-bridge` were not touched.** `Dashboard`,
`Tracking`, `Uptime` and five `Crons *` were all `online` with unchanged `restart_time` after the
rollout — checked, not assumed. The restart named `officer` alone.
- **No host needed a retry for SSH.** `deadpool-rezio-bridge`, which refused once during the
`2026.08.27.02` survey, answered first time. `macbookpro` was awake.
- **Nothing was rolled back and nothing needed repair.** No host failed, so no half-applied state
exists anywhere.
+122
View File
@@ -0,0 +1,122 @@
# 2026.08.29.01 — sharp becomes infrastructure
**This release changes what a machine must have.** Every other deploy this month was code; this one adds
a dependency with a native binary and a system package, so `bun install` is a required step rather than
an optional one, and there are two things to verify per host that no previous plan asked for.
Read the whole file before starting a host. The per-host section at the bottom is not optional detail —
one host needs a command none of the others do.
## What is in it
- **File browser thumbnails.** Images and videos draw a picture in both grid and list. New endpoint
`GET /api/file-browser/thumb`, cached under each user's `cache/thumbs/`.
- **sharp is now a declared dependency** (`^0.35.3`, resolving 0.35.4). It does the images; ffmpeg keeps
the video. `package.json` and `bun.lock` both changed.
- **ffmpeg is now declared in machine-setup's package list.** It was always a hard dependency of the file
browser — subtitles, audio extraction, audio tracks — and was never in the list. Existing hosts were
not provisioned by the new script, so this release checks rather than assumes.
- Marketplace: install a plugin from a git URL; Marketplace overview section; nav restyle.
- Dock and Spotlight clip bare plugin artwork to the tile shape.
- The platform-skill generator emits an identifier for `name` again, so `docs:check` is green.
## Schema: UNCHANGED. Do not run `db:push`.
Verified, not assumed:
```bash
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
```
returns nothing. There is no table, no column and no constraint in this release. **No `bun db:push`
step exists in this plan and none should be improvised.** If `bun run plugins:update` reports that a
plugin moved, say so in the report — do not push a schema to cover it.
## Every host — the commands
Run these after the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin
--tags`, `git merge --ff-only deploy/2026.08.29.01`, `bun run plugins:update`), in this order, in the
checkout from PM2's `pm_cwd`.
### 1. Install — REQUIRED, and it is the point of this release
```bash
bun install
```
The lockfile is frozen, so this resolves from `bun.lock` and nothing else. It should report installing
around 14 packages — sharp and its platform-specific libvips binaries.
**A failure here stops this host.** Do not continue to the restart, and do not reach for `--force` to get
past it: if the lockfile and `package.json` disagree, that is the freeze doing its job and it wants a
human.
### 2. Prove sharp actually loads — the check that matters
```bash
bun -e "import('sharp').then(m => console.log('libvips', m.default.versions.vips)).catch(e => { console.error('SHARP FAILED:', e.message); process.exit(1) })"
```
Expect `libvips 8.18.6`.
**This is a separate step from the install on purpose.** sharp does not compile — it downloads a prebuilt
binary chosen by platform and architecture. So `bun install` can succeed and the module still be
unusable, and the symptom is not an error anywhere: it is a file browser drawing generic icons over a
folder of photographs, which nobody reads as "a native module is missing".
A host where this fails is a **failed host**. Report it, move on. `bun install --force` is the usual
repair and it is a human's to run, not this rollout's.
### 3. ffmpeg must be present
```bash
command -v ffmpeg && ffmpeg -version | head -1
```
Video thumbnails need it, and so do three file-browser features that predate this release. Surveyed
2026-08-29: present on every host **except `macbruno`** — see below.
Absent and not covered by a per-host command below → report the host as deployed-with-a-caveat, name
ffmpeg, and do not install it yourself. Adding a system package to a machine is not something a rollout
improvises.
### 4. Restart
```bash
pm2 restart officer
```
**`officer` only.** Nothing else in this release touches a sidecar. Never
`pm2 restart ecosystem.config.cjs`.
### 5. Verify it came back
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Then, for this release specifically:
```bash
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<PORT>/api/health
```
`200` or `401` are both healthy — the port is per host, in `docs/servers.md` (9010 on `macbookpro` and
`production-pertento-bridge`, 9000 elsewhere).
## Per host
| Host | Extra command | Note |
| --- | --- | --- |
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here. Do not pull, do not restart. Name it as skipped. |
| `alpha` | — | x86_64, ffmpeg present. |
| `fofos` | — | **Already at this code and verified** — it was the test host on 2026-08-29 before the tag. Bring it to the tag anyway so its HEAD matches the fleet; `bun install` will be a no-op. 1.9GB box: expect the install to be the slowest part. |
| `macbookpro` | — | macOS/arm64, port **9010**. A laptop — unreachable is a SKIP with a note, not a failure. |
| `macbruno` | `brew install ffmpeg` **before step 4** | macOS/arm64, login `brunoalcadapadezmonteirodaconceicao`. **The only host without ffmpeg.** Without it, image thumbnails still work — sharp does those — and video ones do not, along with subtitles and audio extraction. If brew is missing or the install fails, deploy anyway and report the caveat: nothing in this release regresses without ffmpeg, it simply does not gain the video half. |
| `production-pertento-bridge` | — | x86_64, port **9010**. |
| `edge-pertento-bridge` | — | x86_64. Node v24 here where the fleet is v22; sharp ships binaries for both and this was not a problem for the install on any other host, but if step 2 fails anywhere it is most likely here. |
| `deadpool-rezio-bridge` | — | x86_64, login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `bun install` result, the libvips version step 2 printed, whether ffmpeg
was found, and `officer` back online with a moved `restart_time`.
The two lines worth having in the summary that no previous report needed: **which hosts print a libvips
version**, and **which hosts have ffmpeg**. Those are the two facts this release is actually about.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.29.01 — deploy report
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
## The two facts this release is about
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
v24) as the most likely failure; it was not one.
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
outstanding on any host.
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
| --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
## Schema
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
taking its word:
```
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
```
returns nothing. No table, column or constraint in this release, so the "check the database by query"
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
release.
## The one deviation, and why
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
`bun run plugins:update` exited 1 with:
```
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
```
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
release is about to add.
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
does not make.
## What else moved
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
restart officer`, per host, by name.
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
restart. Nothing there was named or touched.
## Notes, not problems
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
cannot touch it. No host had tracked changes; no work was discarded anywhere.
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
ffmpeg is on the restarted process's PATH. Verified, not assumed.
## For the owner
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
not necessarily present for the running process. Restart it deliberately when you are happy with the
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
dependency.
+119
View File
@@ -0,0 +1,119 @@
# 2026.08.29.02 — a plugin can ship a commander
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
for each host.
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
where the standard `plugins:update` ran before the plan's install.
## What is in it
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
before it can arm.
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
- Marketplace, file browser and Convert Image work from earlier today rides along.
## Schema — CHANGED. `db:push` is required.
```
command_hq_sessions
+ plugin text null which plugin's template made this commander
+ plugin_config jsonb null the owner's answers to that template's inputs
```
Verified as the whole of it:
```bash
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
# → src/databases/officer_db/src/command-hq/queries.ts
# src/databases/officer_db/src/command-hq/schema.ts
```
`queries.ts` is code, not schema. The only structural change is the two columns above.
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
create-then-constrain here, and no `--force` is needed or permitted.
## Every host — the commands
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
`pm_cwd`:
### 1. Back the database up first
```bash
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
```
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
### 2. Push the schema
```bash
bun db:push < /dev/null
```
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
### 3. VERIFY BY QUERY. The exit code is not the answer.
```bash
bun -e "
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
const r = await db.execute(sql\`select column_name from information_schema.columns
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
process.exit(0)"
```
Must print exactly:
```
plugin plugin_config
```
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
### 4. Restart
```bash
pm2 restart officer
```
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
### 5. Check it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are both healthy.
## Per host
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
| `alpha` | Nothing special. |
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
| `production-pertento-bridge` | Port **9010**. |
| `edge-pertento-bridge` | Nothing special. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
the verification query**, and `officer` back online with a moved `restart_time`.
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
thing this release actually changes about a machine.
+74
View File
@@ -0,0 +1,74 @@
# 2026.08.29.02 — deploy report
**7 of 7 target hosts deployed. `officer-dev` skipped by design.** Every host prints
`plugin plugin_config` — the two new `command_hq_sessions` columns exist on all eight databases.
All eight checkouts were at `58f50a52` (the `2026.08.29.01` tag) before this rollout, and all are at
`78bf63c2` (`deploy/2026.08.29.02`) now. No host was ahead, none diverged, none was dirty.
This was the first release under a new watcher loop. The rollout itself behaved as written.
## Per host
| Host | HEAD before → after | plugins:update | backup | db:push | verification query | officer | health |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — **SKIPPED** | — | — | — | `plugin plugin_config` | not restarted | — |
| `alpha` | `58f50a52` → `78bf63c2` | 15/15, exit 0 | 12,309,974 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (9000) |
| `fofos` | `58f50a52` → `78bf63c2` | 5/5, exit 0 | 2,792,097 B | exit 0 | `plugin plugin_config` | 26 → 27 restarts, online | 401 (9000) |
| `macbookpro` | `58f50a52` → `78bf63c2` | no plugins installed | 5,179,143 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (**9010**) |
| `macbruno` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 86,480 B | exit 0 | `plugin plugin_config` | 6 → 7 restarts, online | 401 (9000) |
| `production-pertento-bridge` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 187,488 B, **second attempt** | exit 0 | `plugin plugin_config` | 4 → 5 restarts, online | 401 (**9010**) |
| `edge-pertento-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 5,916,050 B | exit 0 | `plugin plugin_config` | 5 → 6 restarts, online | 401 (9000) |
| `deadpool-rezio-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 92,979 B | exit 0 | `plugin plugin_config` | 22 → 23 restarts, online | 401 (9000) |
`401` is the healthy answer from `/api/health` behind the session gate, as the plan says.
## What the release was for, confirmed on the host that matters
`fofos` carries `av-club`, and the commander template it now ships is on disk:
`plugins/av-club/commanders/convert-on-arrival/COMMANDER.md`, plugin checkout at `5b2fe03`
("Convert on arrival — the first plugin-shipped commander"). The plugin arrived through the standard
`bun run plugins:update` step, as the plan predicted — nothing in this plan installed it.
## What did not go to plan
**`production-pertento-bridge` failed its backup on the first attempt and the host stopped there.**
The plan's `pg_dump` is Ubuntu's 16.15; that host's Postgres is 18.3, and `pg_dump` refuses a newer
server outright:
```
pg_dump: error: aborting because of server version mismatch
pg_dump: detail: server version: 18.3; pg_dump version: 16.15
```
The host was left correct but half-done: code at the tag, plugins updated, **no `db:push` and no
restart**, so the running process was still serving the old code against the old schema. Nothing was
broken by stopping there.
Postgres on that host runs in a docker container (`postgres:18.3-alpine`), so a version-matched client
was already present. The backup was retaken with `docker exec postgres pg_dump` — same dump, correct
tool, read-only — and the plan then continued unchanged from step 2. Result above.
**This is a per-host detail worth writing down**, because it will recur on every release whose plan
backs the database up: the plan's `pg_dump` line is host-portable only where the client is at least as
new as the server. `production-pertento-bridge` is the one host where it is not.
**`fofos` health read `000` immediately after the restart** and `401` fifteen seconds later. The check
was racing a cold start, not finding a fault. The health check now retries for up to thirty seconds
before it reports a number.
**`fofos` has one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could not have
disturbed it.
## Skipped
`officer-dev` — the rollout ran on it. Its checkout was already at `78bf63c2` and its database already
carries both columns (verified by the same query: `plugin plugin_config`). **Its `officer` process was
not restarted**, so it is running the previous build. The owner restarts it deliberately:
```
pm2 restart officer
```
`docs/servers.md` lists nothing under "Deliberately skipped" today, and no host was unreachable —
`macbookpro` was awake.
+85
View File
@@ -0,0 +1,85 @@
# 2026.08.29.03 — the todo board leaves, and a plugin can claim a file type
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.29.02..deploy/2026.08.29.03 | grep -iE 'schema|/db/|databases' # nothing
git diff --stat deploy/2026.08.29.02..deploy/2026.08.29.03 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
Ten commits, all code.
## What is in it
- **`/todos` is gone from the platform.** It is now the `kanban` plugin, in the marketplace. The
`todos` permission, the route, the screen and the board were all removed.
- **A plugin can claim a file type.** A manifest may declare `fileTypes`; the file browser then offers
thumbnails and a full-size render for formats the platform itself cannot read. av-club claims
`heic`/`heif`, so on a host with av-club installed, iPhone photographs get pictures instead of file
icons and open in the viewer.
- Thumbnails retry rather than giving up, and list and grid ask for the same size.
- The directory picker moved into the platform so more than one plugin can use it.
## A behaviour change to expect, and it is deliberate
**`/todos` now 404s.** Anyone with it bookmarked gets nothing. The board is the `kanban` plugin, which
is in the marketplace and installed nowhere by default — that is the intended state, not an oversight.
`role_permissions` rows naming `todos` are now orphaned. Harmless: absence denies, and the registry no
longer has the key. **Do not clean them up as part of this rollout** — it is a data edit nobody asked
for, and a row naming a permission that does not exist grants nothing.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the whole rollout this time (dirty check,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.03`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
### Verify it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
### And check the file browser still lists
This release touched the listing route, so one call per host is worth it:
```bash
bun -e "
const { sign } = await import('./src/servers/jwt');
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
process.exit(0)"
```
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
## Per host
Surveyed 2026-08-29 while writing this.
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
| `alpha` | No av-club, so no HEIC support — expected, not a fault. |
| `fofos` | **The only host with av-club installed**, and already at this commit and verified: 6 HEICs thumbnail and one renders at full size. Bring it to the tag so its HEAD matches the fleet. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
| `edge-pertento-bridge` | Did not answer the survey while this plan was written. It may simply have been busy; treat it as an ordinary target and report what you find. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`, and the `ls` check's output.
Nothing in this release needs a database query, a backup or an install. If you find yourself running
one, stop — the plan is wrong and that is worth reporting rather than working around.
+46 -36
View File
@@ -9,14 +9,14 @@ read-in-place rule this mechanism is the one deliberate exception to).
## The shape of it
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files
that merely *mention* `CLAUDE.md` in a comment suggests.
that merely _mention_ `CLAUDE.md` in a comment suggests.
| # | Site | Verb | Cardinality | Trigger |
|---|------|------|-------------|---------|
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start |
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
| # | Site | Verb | Cardinality | Trigger |
| --- | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- |
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory, plus one per email account | every plugin-panel chat start, and every email chat start |
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
Two things fell out of tracing this that are worth stating up front, because they explain why the
mechanism looks the way it does rather than something simpler:
@@ -39,7 +39,7 @@ mechanism looks the way it does rather than something simpler:
Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the
row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering
is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a
is load-bearing: a turn that spawned before the directory existed used to recreate an _empty_ one on a
race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first
and the directory has to exist before a turn can reach it.
@@ -68,7 +68,7 @@ to be reading the file yet.
**What happens after creation is the interesting part: nothing does, from the platform's side.** The
file is described everywhere as "a living document its commander has been extending" — the commander
itself edits it during its own life, the same file a human would open in the file browser. The platform
never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with
never rewrites it. That is _why_ item 3 below has to append rather than regenerate: a commander with
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
### 2. Plugin chat briefs — the copier
@@ -95,8 +95,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
}
```
Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
Called from `resolveChatCwd()` from **two** branches, and the second one is the interesting case:
- `msg.context === 'plugin'` — the plugin's own chats directory. The ordinary path.
- `msg.context === 'email'` — the **account's** directory, the one holding `emails.db`.
Never for a commander and never for an ordinary `/chat` session.
Email is the exception because an email chat does not run in the plugin's chats directory: it runs beside
the database it exists to answer from, which is the entire point of that context. So the brief follows the
chat rather than the chat moving to the brief, and `plugins/email/chat/CLAUDE.md` lands in
`email_accounts/<address>/`. A second plugin wanting this would be the signal that "which directory does
this plugin's chat run in" belongs in the manifest rather than in a branch here. That branch also does the `mkdirSync`
for the chat directory itself, so the two writers (this one and the commander generator) can never
target the same directory: a commander's cwd is `$DATA_PATH/<email>/commanders/<dirName>/`, a plugin
chat's is `$DATA_PATH/<email>/plugins/<appName>/chats/`.
@@ -105,7 +115,7 @@ The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships
read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs
no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on
an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called
once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
once per chat _start_, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the
per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A
@@ -178,11 +188,11 @@ specifically to stop a future change from "fixing" this back in.
what fixed it.
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs
un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land
*inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
_inside_ `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
reads. Writing one there would silently mix generated content into a document that is supposed to be
entirely the owner's.
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that
the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
the _old_ `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
`settings.json` describing a user's Docker container, as part of an architecture that no longer exists.
What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
generated inside either.
@@ -199,28 +209,28 @@ at the top of the plugin's own repository, picked up automatically the moment th
starts a conversation. There is no manifest field for this and nothing to register — the platform simply
checks whether the file exists at that fixed path within the plugin's directory on every chat start.
| Plugin | `chat/CLAUDE.md` | Notes |
|---|---|---|
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
| `atelier` | no | |
| `bitcoin-wallet` | no | |
| `browser` | no | |
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
| `email` | no | |
| `example` | no | |
| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
| `invoices` | no | |
| `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | |
| `music` | no | |
| `offscale` | no | |
| `photos` | no | |
| `soulseek` | no | |
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
| Plugin | `chat/CLAUDE.md` | Notes |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
| `atelier` | no | |
| `bitcoin-wallet` | no | |
| `browser` | no | |
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
| `email` | no | |
| `example` | no | |
| `gitea` | no | Ships an _affordance_ (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
| `invoices` | no | |
| `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | |
| `music` | no | |
| `offscale` | no | |
| `photos` | no | |
| `soulseek` | no | |
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that
mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code
+39 -10
View File
@@ -21,21 +21,50 @@ ssh br@deadpool-rezio-bridge # not implied
## Officer platforms
Seven, all running.
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
| Host | Tailnet IP | Login | Checkout | Domain |
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
| Host | Tailnet IP | Login | Port | Checkout | Domain |
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
| `officer-dev` | 100.64.0.13 | `pastilhas` | 9000 | `~/officer-dev/platform` | `platform.officer.dev` |
| `alpha` | 100.64.0.1 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
`.env`. This is not a footnote: on 2026-08-27 a health check against 9000 on `production-pertento-bridge`
hit one of Pertento's own apps, which answered `403`, and that was read as Officer failing after a
deploy. Take the port from the `.env`, never from habit.
**`production-pertento-bridge` also runs eight processes that are NOT Officer** — `Dashboard`, `Tracking`,
`Uptime` and five `Crons *`. They are production-critical and belong to Pertento. Anything sweeping over
`pm2 list` must filter to `officer` and `officer-*`, and a restart there must name its target.
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## `pm2` is not on the PATH of a non-interactive shell
`ssh host 'pm2 jlist'` runs a NON-interactive, NON-login shell. zsh reads `~/.zshenv` for those and reads
neither `~/.zshrc` nor `~/.zprofile` — so a pm2 installed by nvm or Homebrew is invisible to an
orchestrator even though the daemon is running and healthy.
**This read as "pm2 is not installed" three times on 2026-08-27**, and twice sent someone diagnosing a
machine with nothing wrong with it. `claude` on a fresh install has the same shape.
Fixed at the source on the two hosts where it bit — `production-pertento-bridge` (nvm) and `macbruno`
(Homebrew at `/opt/homebrew`, which is on no default PATH on Apple Silicon) — by appending a
marker-guarded block to `~/.zshenv`. Everything else already worked.
If a command comes back empty over SSH, look for the binary on disk before concluding it is absent:
```bash
ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin/pm2 2>/dev/null'
```
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
-6
View File
@@ -1,6 +0,0 @@
{
"host": "127.0.0.1",
"port": 3333,
"enable_request_logging": false,
"last_updated": "2025-12-24T04:27:17.443375"
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.26.02",
"version": "2026.08.29.03",
"private": true,
"type": "module",
"workspaces": [
@@ -39,7 +39,8 @@
"setup": "bash scripts/install.sh",
"check:theme": "bun scripts/check-theme-tokens.ts",
"docs:check": "bun run scripts/docs-check.ts",
"skills:platform": "bun run scripts/gen-platform-skill.ts"
"skills:platform": "bun run scripts/gen-platform-skill.ts",
"plugins:check": "bun run scripts/plugins-check.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
@@ -159,6 +160,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"sounds": "workspace:*",
+92
View File
@@ -0,0 +1,92 @@
# Plugins
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
plugin author needs that are not procedure.
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
appears by `git clone` when somebody installs one, and is gitignored here.
---
## Affordances
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
the file format and how they reach the agent. Read it before writing one. What follows is only the part
that is specific to shipping one from a plugin.
> An affordance is something the surface on the other end of the conversation offers the agent — declared
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
> client will honour.
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
inside the file.
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
that it was a bug. See the two worked examples in `docs/affordances.md`.
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
have nothing to do with your plugin.
### A plugin-contributed one, for reference## Notifications — the same shape, for push
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
```
capabilities/notifications/
officerdev/music/
catalogue.yaml
```
```yaml
nowPlaying:
title: 'Now playing'
route: 'officer://music/{id}'
```
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
and the device renders the title and follows the route. The point is that **adding a notification is a
server change and never an app build**, because the mapping is synced to the device rather than compiled
into it.
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
catalogue is skipped without affecting anyone else's.
See `docs/push-notifications.md`.
### A plugin-contributed one, for reference
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
```markdown
---
name: Gitea
description: Use when working with git repositories, or with Gitea issues and pull requests.
version: 1
---
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
to the server directly.
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
configure it, and do not ask the user for a token.
```
### On the word
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
`capabilities/skills/` here and by the wider AI ecosystem.
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
other end permits. Keeping that distinction in the name saves explaining it every time.
+3 -89
View File
@@ -1,92 +1,6 @@
# Plugins
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
plugin author needs that are not procedure.
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
appears by `git clone` when somebody installs one, and is gitignored here.
---
## Affordances
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
the file format and how they reach the agent. Read it before writing one. What follows is only the part
that is specific to shipping one from a plugin.
> An affordance is something the surface on the other end of the conversation offers the agent — declared
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
> client will honour.
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
inside the file.
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
that it was a bug. See the two worked examples in `docs/affordances.md`.
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
have nothing to do with your plugin.
### A plugin-contributed one, for reference## Notifications — the same shape, for push
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
```
capabilities/notifications/
officerdev/music/
catalogue.yaml
```
```yaml
nowPlaying:
title: 'Now playing'
route: 'officer://music/{id}'
```
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
and the device renders the title and follows the route. The point is that **adding a notification is a
server change and never an app build**, because the mapping is synced to the device rather than compiled
into it.
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
catalogue is skipped without affecting anyone else's.
See `docs/push-notifications.md`.
### A plugin-contributed one, for reference
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
```markdown
---
name: Gitea
description: Use when working with git repositories, or with Gitea issues and pull requests.
version: 1
---
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
to the server directly.
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
configure it, and do not ask the user for a token.
```
### On the word
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
`capabilities/skills/` here and by the wider AI ecosystem.
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
other end permits. Keeping that distinction in the name saves explaining it every time.
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+12 -4
View File
@@ -64,7 +64,15 @@ for (const row of rows) {
continue;
}
const claudeMd = join(dir, 'CLAUDE.md');
/*
AGENTS.md where it exists, CLAUDE.md where it does not.
Since 2026-08-27 a commander's brief is written as AGENTS.md with a CLAUDE.md pointing at it, because
opencode discovers only the first name. Commanders created BEFORE that have the content in CLAUDE.md
and no AGENTS.md at all. Appending to the wrong one is the quiet failure this avoids: on a new
commander it would bury a credential section inside a six-line pointer the agent never reads.
*/
const brief = existsSync(join(dir, 'AGENTS.md')) ? join(dir, 'AGENTS.md') : join(dir, 'CLAUDE.md');
/**
* Append a section only when it is not already described.
@@ -73,10 +81,10 @@ for (const row of rows) {
* describe the same thing does not end up with it twice.
*/
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
const existing = existsSync(brief) ? readFileSync(brief, 'utf8') : '';
if (existing.includes(marker)) return false;
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
await giveToAccount(row.osUser, claudeMd);
appendFileSync(brief, `\n${lines.join('\n')}`);
await giveToAccount(row.osUser, brief);
return true;
};
+1 -1
View File
@@ -89,7 +89,7 @@ async function main(): Promise<void> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
record(
checkCount(
join(ROOT, 'CLAUDE.md'),
join(ROOT, 'AGENTS.md'),
/([0-9]+|[a-z-]+) entries as of/gi,
PERMISSIONS.length,
'permission registry entries',
+26 -1
View File
@@ -60,9 +60,34 @@ function coreProcesses(): Pm2App[] {
const [name, script, args] = line.split('|');
if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`);
return { name, script, args, cwd: PLATFORM_DIR, watch: false };
});
})
.filter((app) => !DISABLED.has(app.name));
}
/*
Core processes this host does not want, from `OFFICER_DISABLED_SIDECARS` in `.env`.
A core process is core because the platform's own features depend on it — which is why the list is a
fixed array and not a setting. But "the platform depends on it" is not "every host runs it": fofos is a
1.9 GB machine serving a website, and `officer-opencode` sat there costing 456 MB of that for a harness
nobody on it uses. It froze hard twice, with no OOM line in the journal — which is what memory
exhaustion looks like when there is not enough headroom left to kill something cleanly.
Deliberately NOT a database setting. This file is regenerated during a plugin install, so a host that
has dropped a process needs it to stay dropped across one. `.env` is read at generation time and is
per-host, which is exactly the scope of the decision.
Naming something the platform genuinely needs will break that host — `officer` itself, or
`officer-pty` if anyone uses a terminal. Not validated, because which processes are safe to drop
depends on what the host is for, and this script cannot know that.
*/
const DISABLED = new Set(
(process.env.OFFICER_DISABLED_SIDECARS ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
);
/** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */
async function pluginEntries(): Promise<Pm2App[]> {
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
+11 -1
View File
@@ -60,7 +60,17 @@ export async function renderPlatformSkill(): Promise<string> {
const out = (s = '') => lines.push(s);
out('---');
out('name: Officer platform');
/*
`name` is an IDENTIFIER, not a label: lowercase-hyphenated and equal to the directory, per both
harnesses' rules. The human name is `title`.
This generator emitted `name: Officer platform` and was missed when c24859c fixed every skill by
hand — so the file on disk was right and running this script put the old shape back. `docs:check`
then failed on a line count that nobody could fix by regenerating, which is the worst place for a
drift check to point.
*/
out('name: platform');
out('title: Officer platform');
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
out('description: >-');
+25 -1
View File
@@ -66,7 +66,26 @@ const git = (dir: string, args: string[]): string | null => {
return new TextDecoder().decode(result.stdout).trim() || null;
};
const PLUGINS_DIR = resolve(import.meta.dir, '..', 'plugins');
/*
Which plugins directory to publish FROM.
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
Publishing from it means the catalogue advertises the version this machine happens to be running
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
were tagged and the store went on showing 1.0.0.
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
working copy and published the same version. It was removed on 2026-08-28.
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
real git repo with its own tags, so the released manifest can be read out of the tag itself —
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
written yet, and until it is, this script advertises what is installed.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
async function syncOne(dir: string): Promise<Sync | null> {
const appName = basename(dir);
@@ -109,6 +128,11 @@ async function syncOne(dir: string): Promise<Sync | null> {
platform: String(manifest.platform ?? '>=1.0.0'),
label: String(manifest.label ?? appName),
summary: String(manifest.summary ?? ''),
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
// quietly disagreeing with the repository it claims to mirror.
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
color: (manifest.color as string) ?? null,
tile: (manifest.tile as string) ?? null,
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
+166
View File
@@ -0,0 +1,166 @@
/*
Is every plugin checkout still a well-formed clone of its own repository?
── Why a script and not care ──
On 2026-08-27 one commit had to go into all twenty-one plugin repos. Seven pushes failed, each for a
different reason, and none of the reasons had anything to do with the commit:
· six remotes were HTTPS, which cannot authenticate non-interactively — they simply hung and failed
· three checkouts were behind their published remote, so the push was rejected as non-fast-forward
· one (`offscale`) had a fetch refspec and an upstream naming a branch called `main` that does not
exist on the remote. That one is the reason this file exists: every command reported on a phantom.
`git push` said "Everything up-to-date" while `git status` said the branch had diverged, and the
repository was actually fine. Twenty minutes went into a repo with nothing wrong with it.
· one remote URL had no `.git` suffix, so the catalogue published a clone URL unlike every other
None of that is visible until you try to push, which is the worst possible moment to find out — you are
mid-task, across twenty-one repositories, and the failures look like they are about your change.
── What it checks, and what it deliberately does not ──
Only mechanical facts with one right answer: the remote's URL form, the fetch refspec, the branch, its
upstream, and whether the checkout has diverged. It does not judge content, does not fetch by default
(that would make a local check depend on the network), and never writes — `--fix` is a separate flag so
that running the check can never be the thing that changes your tree.
*/
import { readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
/*
Which plugins directory to check.
`platform/plugins/` is the one that exists. There was briefly a second — `officer-dev/plugins/`, meant
to hold every repo in the org side by side — and it was removed on 2026-08-28 once it held nothing but
symlinks into this one. A plugin cannot live outside the platform anyway: Bun resolves a symlink to its
real path, so `@@/…` and every relative import fail from there. Tested, including
`--preserve-symlinks`, which Bun accepts and ignores.
`PLUGINS_DIR` stays overridable for a checkout somewhere else, which costs nothing and is the only
reason this is not a constant.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
const HOST = 'git@gitea.officer.dev';
const BRANCH = 'master';
const REFSPEC = '+refs/heads/*:refs/remotes/origin/*';
const FIX = process.argv.includes('--fix');
const FETCH = process.argv.includes('--fetch');
const git = (dir: string, args: string[]): string | null => {
const p = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'pipe' });
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
};
type Problem = { plugin: string; what: string; fix?: () => void };
const problems: Problem[] = [];
const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true })
// `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to
// `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every
// symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which
// would have made this report "0 plugin repositories, all well-formed" — a green check over nothing.
// `discover.ts` already does it this way, at line 239, for the same reason.
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
.filter((e) => {
try {
return statSync(join(PLUGINS_DIR, e.name)).isDirectory();
} catch {
return false; // a broken symlink, which the checks below have nothing useful to say about
}
})
.map((e) => e.name)
.filter((name) => existsSync(join(PLUGINS_DIR, name, '.git')))
.sort();
/*
A repo with no `manifest.ts` is not a plugin, and that is not drift.
`av-club` is the case: a standalone media service living in the plugins org, intended to become a
plugin in time. Checking it against a plugin's conventions would report its branch name as a defect
forever, and a check that cries wolf is one people stop reading.
Presence is the declaration here exactly as it is in `plugins/discover.ts` — the manifest is what makes
something a plugin, so its absence is what makes something not one yet.
*/
const notYet = dirs.filter((name) => !existsSync(join(PLUGINS_DIR, name, 'manifest.ts')));
const checkable = dirs.filter((name) => !notYet.includes(name));
for (const name of checkable) {
const dir = join(PLUGINS_DIR, name);
const expected = `${HOST}:plugins/${name}.git`;
// 1. The remote, exactly. SSH because that is what can push unattended, and `.git` because a URL unlike
// every other is the one an installer trips over.
const url = git(dir, ['remote', 'get-url', 'origin']);
if (url !== expected) {
problems.push({
plugin: name,
what: `remote is ${url ?? 'missing'}, expected ${expected}`,
fix: () => void git(dir, ['remote', 'set-url', 'origin', expected]),
});
}
// 2. The fetch refspec. A narrowed one (a --single-branch clone) hides every other branch; a WRONG one
// makes git report on a branch that does not exist, which is the failure that cost the most time.
const refspecs = (git(dir, ['config', '--get-all', 'remote.origin.fetch']) ?? '').split('\n').filter(Boolean);
if (refspecs.length !== 1 || refspecs[0] !== REFSPEC) {
problems.push({
plugin: name,
what: `fetch refspec is ${refspecs.join(' + ') || 'unset'}, expected ${REFSPEC}`,
fix: () => {
git(dir, ['config', '--unset-all', 'remote.origin.fetch']);
git(dir, ['config', '--add', 'remote.origin.fetch', REFSPEC]);
},
});
}
// 3. The branch, and what it tracks.
const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
if (branch !== BRANCH) {
problems.push({ plugin: name, what: `on branch ${branch ?? '?'}, expected ${BRANCH}` });
continue; // the checks below are all about this branch
}
const upstream = git(dir, ['config', '--get', `branch.${BRANCH}.merge`]);
if (upstream !== `refs/heads/${BRANCH}`) {
problems.push({
plugin: name,
what: `${BRANCH} tracks ${upstream ?? 'nothing'}, expected refs/heads/${BRANCH}`,
fix: () => void git(dir, ['branch', `--set-upstream-to=origin/${BRANCH}`, BRANCH]),
});
}
// 4. Uncommitted work, and divergence. Not fixable here — both need a human decision about the content.
const dirty = git(dir, ['status', '--porcelain']);
if (dirty) problems.push({ plugin: name, what: `${dirty.split('\n').length} uncommitted file(s)` });
if (FETCH) git(dir, ['fetch', '-q', 'origin']);
const counts = git(dir, ['rev-list', '--left-right', '--count', `origin/${BRANCH}...HEAD`]);
if (counts) {
const [behind, ahead] = counts.split(/\s+/).map(Number);
if (behind) problems.push({ plugin: name, what: `${behind} commit(s) behind origin/${BRANCH}` });
if (ahead) problems.push({ plugin: name, what: `${ahead} commit(s) NOT PUSHED` });
}
}
const skipped = notYet.length ? ` (${notYet.length} not a plugin yet: ${notYet.join(', ')})` : '';
if (!problems.length) {
console.log(`plugins:check — ${checkable.length} plugin repositories, all well-formed${skipped}`);
process.exit(0);
}
for (const p of problems) console.error(` ${p.plugin}: ${p.what}`);
if (FIX) {
const fixable = problems.filter((p) => p.fix);
for (const p of fixable) p.fix!();
console.error(`\nplugins:check --fix — repaired ${fixable.length} of ${problems.length}; re-run to confirm.`);
console.error('Divergence and uncommitted work are never repaired here: both need a decision about content.');
} else {
console.error(
`\nplugins:check — ${problems.length} problem(s). \`bun run plugins:check --fix\` repairs the mechanical ones.`,
);
}
process.exit(1);
+67 -26
View File
@@ -100,6 +100,11 @@ fail() {
ANSWERS_FILE="${ANSWERS_FILE:-}"
# DOCKER_NETWORK included so officer-setup's Proxy step lands on the SAME bridge Docker
# was told to use here, rather than each script independently defaulting to "services" and
# happening to agree only because nobody has customised either yet. lib/docker.sh sets
# DOCKER_NETWORK at source time (SETUP_DOCKER_NETWORK env var, else "services"), so it is
# always defined by the time this runs, regardless of whether the Docker step itself ran.
save_answers() {
[[ -n "$ANSWERS_FILE" ]] || return 0
cat >"$ANSWERS_FILE" <<EOF
@@ -107,6 +112,7 @@ save_answers() {
MACHINE_ROLE=${MACHINE_ROLE}
SETUP_USERNAME=${USERNAME}
OFFICER_ROOT=${OFFICER_ROOT}
SETUP_DOCKER_NETWORK=${DOCKER_NETWORK}
EOF
chmod 600 "$ANSWERS_FILE"
}
@@ -165,25 +171,39 @@ ONLY_STEP="${ONLY_STEP:-}"
# Keyed on the step title, so the sections themselves stay Linux code with no
# `if macos` branches threaded through them. The reason is printed, because a
# silent skip and a missing step look identical.
declare -A MACOS_SKIP=(
["User account"]="accounts are System Settings' business on a Mac, not a script's"
["Disk space"]="ballast and swap tuning are server concerns"
["Locale"]="macOS manages locale itself"
["Timezone"]="macOS manages the timezone itself"
["Swap"]="macOS sizes its own swap dynamically"
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
["Boot hang"]="a systemd boot ordering fix"
["SSH access"]="hardening a door a dev machine should not be opening"
["DNS"]="systemd-resolved"
["Network address"]="netplan, and a laptop moves between networks by design"
["fail2ban"]="brute-force protection for an exposed SSH port"
["Unattended upgrades"]="apt; macOS updates through Software Update"
["Firewall"]="ufw; macOS has its own application firewall"
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
)
#
# A `case` rather than an associative array — `declare -A` is bash 4+, and macOS's own
# stock `/bin/bash` is 3.2 (Apple hasn't shipped a newer one since bash went GPLv3), with
# nothing newer installed on a fresh machine. An array keyed on the step title would parse-
# fail the instant this file is sourced, before a single step could run — which is exactly
# what happened here until it was measured. Prints the reason on stdout, so the caller reads
# it with `$(...)`; exit status says whether the title matched anything at all.
macos_skip_reason() {
case "$1" in
"User account") echo "accounts are System Settings' business on a Mac, not a script's" ;;
"Disk space") echo "ballast and swap tuning are server concerns" ;;
"Locale") echo "macOS manages locale itself" ;;
"Timezone") echo "macOS manages the timezone itself" ;;
"Swap") echo "macOS sizes its own swap dynamically" ;;
"Emergency disk ballast") echo "a server trick for a machine nobody is sitting at" ;;
"earlyoom") echo "Linux OOM killer tuning; macOS has its own memory pressure handling" ;;
"inotify watch limit") echo "Linux inotify; macOS watches files through FSEvents" ;;
"Sleep and suspend") echo "a laptop SHOULD sleep — this stops a server from doing it" ;;
"Boot hang") echo "a systemd boot ordering fix" ;;
"SSH access") echo "hardening a door a dev machine should not be opening" ;;
"DNS") echo "systemd-resolved" ;;
"Network address") echo "netplan, and a laptop moves between networks by design" ;;
"fail2ban") echo "brute-force protection for an exposed SSH port" ;;
"Unattended upgrades") echo "apt; macOS updates through Software Update" ;;
"Firewall") echo "ufw; macOS has its own application firewall" ;;
"Shell") echo "zsh is already the default, and tmux is a choice you make yourself" ;;
*) return 1 ;;
esac
}
# `${var,,}` (case-insensitive compare below) is bash 4+ too — this is the portable
# pre-4 substitute, not a style choice.
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
step() {
CURRENT_STEP="$1"
@@ -191,16 +211,19 @@ step() {
# which one it is. Twenty-six sections, one place.
declare -F report_section >/dev/null && report_section "$1"
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
echo ""
echo -e "${BOLD}── $1 ──${NC}"
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
SKIP_STEP=true
return
if [[ "${OS:-}" == "macos" ]]; then
local _skip_reason
if _skip_reason="$(macos_skip_reason "$1")"; then
echo ""
echo -e "${BOLD}── $1 ──${NC}"
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${_skip_reason}"
SKIP_STEP=true
return
fi
fi
if [[ -n "$ONLY_STEP" ]]; then
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
@@ -508,6 +531,24 @@ ask_officer_root() {
# both of which abort the step.
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
# Run a command as the created account, e.g. `run_as_owner git clone ...`.
#
# On Linux this whole script runs as root — refused otherwise, see the pre-flight check
# above — so `sudo -u "$USERNAME"` from root needs no password; sudo lets root become
# anyone freely. On macOS the script deliberately never escalates (Homebrew refuses to run
# as root), so the process already IS "$USERNAME", and re-invoking sudo for the SAME
# account asks for a real password — potentially many times in one run, dev.sh calls this
# shape roughly ten times — or fails outright on an account with no sudo rights at all.
# `dev.sh`'s git/bun/deno/claude/opencode/editor helpers all route through this rather than
# hand-rolling `sudo -H -u "$USERNAME" ...` each time, so macOS behaviour lives in one place.
run_as_owner() {
if [[ "${OS:-}" == "macos" ]]; then
"$@"
else
sudo -H -u "$USERNAME" "$@"
fi
}
# Run a block as the created user (login shell, inherits HOME)
as_user() {
sudo -u "$USERNAME" -i bash -c "$1"
+35 -11
View File
@@ -47,8 +47,8 @@ MACHINE_SETUP_DEV_LOADED=1
# has never been configured — the fresh machine this script exists for — reading
# the current value aborted the run before the section had printed anything.
# Missing a value is an answer here, not a failure.
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
git_get() { (cd / && run_as_owner git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && run_as_owner git config --global "$1" "$2"); }
# Is there anything configured at all?
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
@@ -102,7 +102,7 @@ install_oh_my_zsh() {
# failed died here, silently, because the output is redirected: the run just
# stopped after apt finished installing zsh, with nothing said. Observed on a
# fresh Hetzner VPS, 2026-08-14.
sudo -H -u "$USERNAME" sh -c \
run_as_owner sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
true
# Belt and braces: `|| true` above already makes the last command succeed, and
@@ -155,7 +155,19 @@ nvim_latest_version() {
#
# The old install is removed only after the download has succeeded, so a failed
# fetch leaves the working copy alone.
#
# This had NO macOS branch at all until it was caught here: unconditionally fetched
# nvim-linux-<arch>.tar.gz regardless of $OS, which 404s on a Mac. Not a crash — the
# caller's `if nvim_install; then … else warn …` catches the failure and reports it as
# ERRORS+=("Neovim: install failed") rather than aborting the run — but Neovim never
# actually landed. brew has a real, current formula, so that's the macOS path instead of
# reaching for a macOS release asset the way lazygit's fix did.
nvim_install() {
if [[ "${OS:-}" == "macos" ]]; then
brew install neovim
return
fi
local asset tarball dest
asset="$(nvim_asset)"
tarball="/tmp/nvim-linux-${asset}.tar.gz"
@@ -187,11 +199,11 @@ nvim_clone_config() {
local repo="$1" dest="${USER_HOME}/.config/nvim"
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
(cd / && run_as_owner git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
# The starter is a template, not something to track. Left in place for a
# config of the user's own, which they will want to keep pulling.
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
[[ "$repo" == *LazyVim/starter* ]] && run_as_owner rm -rf "${dest}/.git"
return 0
}
@@ -230,6 +242,18 @@ node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
install_node() {
local major="$1"
# NodeSource's setup script adds an apt repository — it is Debian/Ubuntu-only by name
# and by mechanism (checks for dpkg internally), so there is nothing to point it at on
# macOS. Homebrew's `node`/`nodejs` formula (both names resolve, confirmed) tracks only
# the current release rather than an arbitrary major the way NodeSource's per-major
# scripts do — same tradeoff this codebase already accepts for Docker and Xcode CLT on
# macOS: brew's own version, not a pinned one.
if [[ "${OS:-}" == "macos" ]]; then
pkg_install_now nodejs
return
fi
# NodeSource publishes one setup script per major. Checked before it is piped
# into a shell, because a 404 page piped to bash is a confusing way to fail.
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
@@ -358,7 +382,7 @@ ensure_bun_symlink() {
install_bun() {
local tag="${1:-}"
[[ -n "$tag" ]] || return 1
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
(cd / && run_as_owner bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
}
@@ -367,7 +391,7 @@ install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
install_deno() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
(cd / && run_as_owner bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
}
@@ -434,16 +458,16 @@ agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" ==
agent_version() {
local bin
bin="$(agent_path "$1")"
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
[[ -n "$bin" ]] && (cd / && run_as_owner "$bin" --version 2>/dev/null | head -1)
}
install_claude_code() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin claude)" ]]
}
install_opencode() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin opencode)" ]]
}
@@ -471,7 +495,7 @@ editor_candidates() {
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
# assignment from a function that exits non-zero aborts the run under `set -e`.
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
current_editor() { (cd / && run_as_owner bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
set_system_editor() {
local editor="$1" path
+24 -9
View File
@@ -82,6 +82,13 @@ LAST_SKIPPED=()
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
#
# ffmpeg is not a convenience here, it is a hard dependency of the file browser.
# Subtitles, audio extraction, audio track listing and video thumbnails all shell
# out to it, so on a machine without it those features fail at the moment somebody
# opens a folder — which reads as Officer being broken rather than as a package
# being absent. It was missing from this list until 2026-08-29 and the features
# worked anyway, because every machine that had been set up by hand already had it.
#
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
# moment a file lands instead of polling for it. Without it a watcher can only
# ask "is there anything new?" on a timer, which is both slower and more work —
@@ -108,24 +115,25 @@ pkgs_core() {
# broken rather than a package being absent.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils ffmpeg fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler radicale fail2ban
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler ffmpeg fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils radicale fail2ban
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils ffmpeg 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_*.
# brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale
#
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler ffmpeg
;;
esac
}
@@ -253,13 +261,20 @@ pkg_install() {
# passed by value cannot be told apart from an empty one once it has been through
# word splitting.
#
# Copies by name via eval rather than `local -n` — namerefs are bash 4.3+, and macOS's
# stock bash is 3.2. This is the idiom namerefs replaced: build the local array by
# expanding the CALLER's array name inside a double-quoted eval, which bash 3.2 has always
# supported. `$2`/`$3` are trusted call-site literals (`present`, `missing`), never
# user input, so the eval is not taking anything untrusted.
#
# Returns non-zero when there is nothing to do, or when the answer was no — in
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
# refusal so the summary does not claim work that never happened.
announce_plan() {
local label="$1"
local -n _present="$2"
local -n _missing="$3"
local -a _present _missing
eval "_present=(\"\${$2[@]}\")"
eval "_missing=(\"\${$3[@]}\")"
echo ""
info "${label} — installs what is missing, keeps what you already have"
+27 -4
View File
@@ -59,12 +59,35 @@ authorized_key_count() {
has_authorized_key() { (($(authorized_key_count) > 0)); }
# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd
# ignores an authorized_keys file that is group- or world-writable, and does so
# silently from the client's point of view — the login just fails.
# Everything that has to be true for sshd to use ~/.ssh at all. sshd's StrictModes
# (on by default, and nothing here turns it off) checks the HOME DIRECTORY too, not
# just .ssh — this used to check only .ssh, which is the shape of bug that is hardest
# to notice: an account whose home came from somewhere other than THIS script's own
# adduser call (cloud-init, a prior useradd, LDAP, or simply an account that already
# existed) can have a group- or world-writable home directory, and sshd refuses
# authorized_keys under it silently. The .ssh directory and the key both look
# completely correct — because they are — and nothing here used to say why the
# login still failed.
fix_ssh_permissions() {
local dir
local dir home mode
dir="$(user_ssh_dir)"
home="$USER_HOME"
if [[ -d "$home" ]]; then
# GNU stat's -c, tried first since this step only ever runs on Linux (MACOS_SKIP)
# — with a BSD fallback anyway rather than a call that silently returns nothing on
# whatever this is next tested against, the same trap officer-setup's layout.sh
# file_owner() hit for the same reason.
mode="$(stat -c %a "$home" 2>/dev/null || stat -f %Lp "$home" 2>/dev/null)"
# 0022 is the group-write and other-write bits together. Cleared rather than
# forced to a fixed mode — 0755 vs 0750 is a real choice some setups make on
# purpose, and this only removes the bits sshd actually refuses to see.
if [[ -n "$mode" ]] && ((8#$mode & 0022)); then
chmod go-w "$home"
warn "${home} was group/world-writable (${mode}) — sshd's StrictModes refuses authorized_keys under a writable home regardless of .ssh's own permissions; cleared"
fi
fi
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
chmod 700 "$dir"
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
@@ -245,7 +245,18 @@ tailscale_needs_logout() {
# Routing has to be on before this machine can forward anyone else's packets,
# whether as an exit node or as a subnet router. Written as a drop-in so it is
# visible as this script's doing.
# Both this and install_exit_node_tuning below are Linux-only — sysctl.d, systemd's
# networkd-dispatcher, ethtool NIC offload flags, none of which macOS has an equivalent
# concept for. Tailscale itself DOES support acting as an exit node from macOS (that's the
# `--advertise-exit-node` flag on `tailscale up`, untouched by this guard) — what's
# unavailable is specifically this forwarding-throughput tuning, which is also a much
# narrower loss on a Mac: the file's own MACOS_SKIP already treats a laptop advertising as
# an exit node as an edge case, not the common path a homelab box is.
enable_ip_forwarding() {
if [[ "${OS:-}" == "macos" ]]; then
warn "IP-forwarding tuning is Linux-only (sysctl.d) — skipping; the exit node still works, just untuned"
return 0
fi
cat >"$TS_EXIT_SYSCTL" <<'EOF'
# Written by machine-setup: required to forward traffic for other tailnet nodes,
# as an exit node or as a subnet router.
@@ -265,6 +276,10 @@ EOF
# once, because the settings are per-interface and do not survive the link going
# down and back up.
install_exit_node_tuning() {
if [[ "${OS:-}" == "macos" ]]; then
warn "NIC offload tuning is Linux-only (networkd-dispatcher, ethtool) — skipping"
return 0
fi
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
mkdir -p "$(dirname "$TS_DISPATCHER")"
+24
View File
@@ -48,7 +48,15 @@ tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
# The installers
# -----------------------------------------------------------------------------
# The installer script this used is literally named install_update_linux.sh — there is no
# upstream equivalent for macOS to swap in, so this is brew there instead of a raw fetch.
# All three tools are real Homebrew formulas (confirmed against the live catalogue), which
# also means macOS gets `brew upgrade` maintenance the raw-fetch path never had.
tool_install_lazydocker() {
if [[ "${OS:-}" == "macos" ]]; then
brew install lazydocker
return
fi
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
DIR=/usr/local/bin bash
}
@@ -57,7 +65,16 @@ tool_install_lazydocker() {
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
# two spellings ARCH uses, hence the mapping.
#
# The URL was also hardcoded to `_Linux_` — brew handles macOS instead of this needing a
# second literal-string branch, see tool_install_lazydocker above for why brew and not a
# Darwin asset name here.
tool_install_lazygit() {
if [[ "${OS:-}" == "macos" ]]; then
brew install lazygit
return
fi
local version asset url
case "$ARCH" in
amd64) asset="x86_64" ;;
@@ -82,7 +99,14 @@ tool_install_lazygit() {
# Quiet on purpose. The installer ends by printing how to add starship to bash,
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
# already writes the zsh config itself. Errors still come through.
#
# starship's own installer IS cross-platform (unlike the two above), but brew on macOS
# keeps it consistent with the other two tools and gets it `brew upgrade` maintenance.
tool_install_starship() {
if [[ "${OS:-}" == "macos" ]]; then
brew install starship
return
fi
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
}
+18 -10
View File
@@ -46,7 +46,9 @@ while [[ $# -gt 0 ]]; do
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
# all and errors immediately ("invalid option -- P"), confirmed live.
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
exit 0
;;
-h | --help)
@@ -114,15 +116,17 @@ info "Machine: ${OS_NAME} (${ARCH})"
info "Packages: ${PM:-none detected}"
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
# Everything below this line is written against apt and systemd. Detection above
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
# nothing implements them yet — and running the apt path on Arch would half-build
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
# list one entry at a time as each package manager grows a real path.
# Everything below this line is written against apt and systemd, with real macOS/brew
# branches threaded through where a Mac needs different behaviour (MACOS_SKIP for whole
# steps that are server-only, `$OS == macos` for the rest). Detection above also recognises
# pacman and dnf so THEIR branches have somewhere to hang, but nothing implements those yet
# — running the apt path on Arch would half-build a machine and stop somewhere unhelpful.
# Refuse clearly instead, and relax this list one entry at a time as each package manager
# grows a real path.
case "$PM" in
apt) ;;
apt | brew) ;;
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt and brew only, so far." ;;
esac
ask_machine_role
@@ -404,7 +408,9 @@ fi
step "System update"
if ! skip; then
mapfile -t UPGRADABLE < <(pkg_upgradable)
# `mapfile` is bash 4+; macOS's stock bash is 3.2. This is the portable substitute.
UPGRADABLE=()
while IFS= read -r _line; do UPGRADABLE+=("$_line"); done < <(pkg_upgradable)
echo ""
info "System update — upgrades installed packages to their latest versions"
@@ -2479,7 +2485,9 @@ EOF
echo " which is why git's own core.editor is deliberately not set: git"
echo " falls back to \$EDITOR, so setting it here covers everything."
echo ""
mapfile -t EDITORS < <(editor_candidates)
# mapfile is bash 4+; macOS's stock bash is 3.2.
EDITORS=()
while IFS= read -r _line; do EDITORS+=("$_line"); done < <(editor_candidates)
if ((${#EDITORS[@]} == 0)); then
warn "no editor found to offer — skipping"
else
+38 -6
View File
@@ -50,7 +50,9 @@ while [[ $# -gt 0 ]]; do
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
# all and errors immediately ("invalid option -- P"), confirmed live.
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
exit 0
;;
-h | --help)
@@ -143,6 +145,13 @@ else
echo " would have answered are asked below instead."
fi
# proxy.sh set PROXY_NET from $SETUP_DOCKER_NETWORK when it was SOURCED, before the answers
# file above could have populated that variable — so it needs re-deriving now the same way
# ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL are recovered below. Without this a customised bridge
# name from machine-setup silently would not carry over, and Proxy would default back to
# "services" regardless of what Docker was actually told to use.
PROXY_NET="${SETUP_DOCKER_NETWORK:-$PROXY_NET}"
# ── the account ──
#
# A remembered answer can go stale: the account it names may have been renamed or
@@ -211,8 +220,11 @@ fi
echo ""
info "What Officer needs from this machine"
mapfile -t MISSING < <(missing_tools)
mapfile -t MISSING_OPT < <(missing_optional_tools)
# mapfile is bash 4+; macOS's stock bash is 3.2, so this reads line-by-line instead.
MISSING=()
while IFS= read -r _line; do MISSING+=("$_line"); done < <(missing_tools)
MISSING_OPT=()
while IFS= read -r _line; do MISSING_OPT+=("$_line"); done < <(missing_optional_tools)
for t in "${REQUIRED_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
@@ -274,7 +286,9 @@ if ! skip; then
echo " To put data/ on a bigger volume later, symlink it — that is a"
echo " decision about storage rather than about how Officer is laid out."
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
# mapfile is bash 4+; macOS's stock bash is 3.2.
WRONG_OWNER=()
while IFS= read -r _line; do WRONG_OWNER+=("$_line"); done < <(layout_wrong_owner)
if ((${#WRONG_OWNER[@]} > 0)); then
echo ""
warn "these exist but do not belong to ${USERNAME}:"
@@ -391,6 +405,7 @@ if ! skip; then
info "Dependencies — bun install, as ${USERNAME}"
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
echo " sharp: $(sharp_loads && echo "loads (libvips $(sharp_version))" || echo 'does not load')"
echo ""
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
echo " so a package.json that disagrees with it fails rather than quietly"
@@ -398,12 +413,29 @@ if ! skip; then
echo ""
echo " node-pty has no Linux prebuild, so this compiles it from source"
echo " every time — which is what build-essential and python3 are for."
echo ""
echo " sharp is the opposite problem: it does not compile, it downloads a"
echo " libvips binary for this exact platform and architecture. So the"
echo " install can succeed and the module still not load, and the way that"
echo " shows up is a file browser drawing generic icons over a folder of"
echo " photographs. Loading it is the only check worth running."
if deps_installed && node_pty_built; then
ok "already installed, and node-pty is built"
if deps_installed && node_pty_built && sharp_loads; then
ok "already installed; node-pty built and sharp loads"
SUMMARY+=("Dependencies: already installed")
elif confirm "Install them?"; then
if install_deps; then
# Reported separately from node-pty rather than as one "native modules"
# verdict: they fail for different reasons and want different fixes, and a
# combined line sends somebody to rebuild the wrong one.
if ! sharp_loads; then
warn "installed, but sharp does not load"
echo " Image thumbnails in the file browser will silently fall back to"
echo " file icons. Usually the platform-specific binary did not resolve:"
echo " cd $(platform_dir) && bun install --force"
ERRORS+=("Dependencies: sharp does not load")
fi
if node_pty_built; then
ok "installed, node-pty built"
SUMMARY+=("Dependencies: installed")
+40 -2
View File
@@ -47,10 +47,16 @@ fail() {
ONLY_STEP="${ONLY_STEP:-}"
# `${var,,}` (case-insensitive --only compare below) is bash 4+. macOS's stock /bin/bash is
# 3.2 — confirmed live: "bad substitution" — and under this script's `set -e` that means
# `--only <step>` killed the ENTIRE run on macOS, not just the comparison. This is the
# portable pre-4 substitute.
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
step() {
CURRENT_STEP="$1"
if [[ -n "$ONLY_STEP" ]]; then
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
@@ -134,7 +140,39 @@ ask_required() {
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
# The bound address(es) of whatever is LISTENing on a TCP port — one per line, formatted
# like `127.0.0.1:5432` or `*:5432`. Used both to ask "is anything on this port at all" and
# "is it bound wide enough for a container to reach" (Postgres and the Proxy section ask
# opposite versions of that question).
#
# `ss` (iproute2) is Linux-only — no equivalent ships with it on macOS. `lsof` is the
# portable answer: present on every macOS install by default, and common enough on Linux
# (already a REQUIRED_TOOL nowhere here, so this still prefers `ss` there since that is the
# surface this was written and tested against) that branching by OS rather than picking one
# tool for both keeps each path exactly as capable as it always was.
listening_addrs() {
local port="$1"
if [[ "$(uname -s)" == "Darwin" ]]; then
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk -v p=":${port}\$" '$9 ~ p {print $9}'
else
ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4}'
fi
}
# Run something as the account that owns the install. Officer's files, its
# node_modules and its pm2 process list all belong to that account, not to root —
# a repository cloned as root is one the owner cannot pull.
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
#
# On Linux this script runs as root (that's the whole point of sudo -H -u here: dropping
# from root down to the owning account). On macOS it never escalates — officer-setup.sh
# refuses to even start under sudo there, "the account running this IS the owner" — so the
# process already IS $USERNAME, and re-invoking sudo for the SAME account asks for a real
# password on every single call (Dependencies, Schema, Build, Secrets, Services, Repository
# all go through this) or fails outright without sudo rights at all.
as_owner() {
if [[ "$(uname -s)" == "Darwin" ]]; then
(cd "${2:-/}" && bash -c "$1")
else
(cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1")
fi
}
+8 -3
View File
@@ -24,15 +24,20 @@ OFFICER_SETUP_BUILD_LOADED=1
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
# want at 3am.
push_schema() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
as_owner "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
}
# What tables the schema will create, read from the aggregator rather than
# guessed. This is what makes the section able to say what it is about to do.
#
# Portable ERE + sed rather than GNU grep's -P/lookbehind: BSD grep on macOS has no -P at
# all (confirmed live — errors immediately), and its 2>/dev/null here would have hidden that
# and reported "0 tables" on every macOS run instead, cosmetic but silently wrong.
schema_table_count() {
local dir
dir="$(platform_dir)/src/databases/officer_db/src"
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
grep -E "^export \* from '\./" "$dir/schema.ts" 2>/dev/null |
sed -E "s/^export \* from '\.\/([A-Za-z0-9_-]+)\/schema'.*/\1/" | while read -r f; do
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
done | awk '{s+=$1} END {print s+0}'
}
@@ -43,7 +48,7 @@ schema_table_count() {
# Not optional and not cosmetic: without it the server has no page to serve. It
# is gitignored, so a fresh clone never has one.
gen_index() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
as_owner "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
}
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
+26 -3
View File
@@ -31,14 +31,32 @@ env_exists() { [[ -f "$(env_file)" ]]; }
# One value out of an existing .env, without sourcing it — the file holds
# secrets and arbitrary shell would run as root.
#
# Checks the OFFICERDEV_-prefixed key first, matching the priority `OFFICERDEV_X ?? X`
# gives it at runtime — write_env ALWAYS writes prefixed keys (see below), so plain `env_get
# PORT` used to compare against a key that could never appear in a file this script wrote,
# and always came back empty. That silently defeated the resume block below it was written
# to fix, and the Environment step's own re-run showed the wrong default. Verified against a
# real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000], before this.
# Falls back to the bare key so an old, hand-written unprefixed file still works, same as the
# runtime does.
env_get() {
[[ -r "$(env_file)" ]] || return 0
awk -F= -v k="$1" '
$1 == k {
awk -F= -v k="$1" -v pk="OFFICERDEV_$1" '
$1 == pk {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
print v
exit
}
$1 == k && !found {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
bare = v
found = 1
}
END {
if (bare != "") print bare
}' "$(env_file)"
}
@@ -111,7 +129,12 @@ ENVF
# is any better a guess.
tailnet_hostname() {
local dns ip
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
# jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD
# grep at all (confirmed live — errors visibly), which used to leak straight to the
# terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is
# already an unconditional dependency elsewhere in this script (proxy.sh), so this is
# strictly a correctness improvement even setting the portability problem aside.
dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')"
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
dns="${dns%%.*}" # and we want the short name
if [[ -n "$dns" ]]; then
+8 -1
View File
@@ -39,6 +39,13 @@ layout_dirs() {
echo "$(layout_items_dir)"
}
# A file's owning username, portably: GNU stat (Linux) takes `-c %U`, BSD stat (macOS) has
# no `-c` at all and wants `-f %Su` instead — confirmed live, GNU form exits
# "illegal option -- c" under macOS's stat. Try GNU first since that is the common case,
# fall back to BSD rather than branching on an OS variable this file does not otherwise
# track.
file_owner() { stat -c %U "$1" 2>/dev/null || stat -f %Su "$1"; }
# Created owned by the account, because everything that writes into them runs as
# the account: the platform under pm2, the app store's compose files, the item
# store the agent authors into.
@@ -58,7 +65,7 @@ layout_wrong_owner() {
local dir
while read -r dir; do
[[ -d "$dir" ]] || continue
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
[[ "$(file_owner "$dir")" == "$USERNAME" ]] || echo "$dir"
done < <(layout_dirs)
}
+61 -3
View File
@@ -62,7 +62,7 @@ pg_client_installed() {
# 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
local major
major="$(pg_client_major)"
[[ -n "$major" ]] || {
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
@@ -74,6 +74,49 @@ install_pg_client() {
return 0
fi
if [[ "$(uname -s)" == "Darwin" ]]; then
install_pg_client_macos "$major"
return $?
fi
install_pg_client_linux "$major"
}
# Homebrew's `postgresql@<major>` matches the apt path's own logic: the version has to agree
# with the server's, not just be "a psql". It is keg-only — Homebrew never links more than one
# Postgres onto PATH by default, because a dev machine plausibly has several majors installed
# for different projects — so `brew install` alone does not make `psql` resolve. Symlinking
# only the four client binaries this platform actually uses, rather than `brew link --force`ing
# the whole keg, is the same caveat Homebrew's own postgresql formula prints; force-linking
# would silently steal `psql` from whatever other major was linked before this ran.
install_pg_client_macos() {
local major="$1" prefix
command -v brew &>/dev/null || {
warn "Homebrew is not installed — cannot install postgresql@${major}. Run machine-setup.sh first."
return 1
}
prefix="$(brew --prefix)"
brew install "postgresql@${major}" &>/dev/null || {
warn "brew install postgresql@${major} failed"
return 1
}
local bin
for bin in psql pg_dump pg_dumpall pg_restore; do
[[ -x "${prefix}/opt/postgresql@${major}/bin/${bin}" ]] || continue
ln -sf "${prefix}/opt/postgresql@${major}/bin/${bin}" "${prefix}/bin/${bin}"
done
pg_client_installed || {
warn "psql is not version ${major} after installing — check: brew list postgresql@${major}"
return 1
}
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed via Homebrew"
}
install_pg_client_linux() {
local major="$1" codename
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
[[ -n "$codename" ]] || {
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
@@ -121,7 +164,12 @@ pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{
# Is something already answering on the port? A Postgres the user runs their own
# way is a perfectly good answer, and finding out by failing to bind is not.
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
#
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) is the portable version, branching to
# `lsof` on macOS. Confirmed empirically to fail silently the old way: `ss` doesn't exist on
# macOS, its "command not found" was swallowed by `2>/dev/null`, and an empty grep result
# reported the port as free even when something WAS listening.
pg_port_in_use() { [[ -n "$(listening_addrs "$PG_PORT")" ]]; }
# Bound to loopback, deliberately, and the reason is worth keeping next to the
# line it explains.
@@ -189,9 +237,19 @@ ENVF
return 0
}
# Failure has to mean failure. The bare awk one-liner this replaced printed nothing and
# still exited 0 when the file existed but had no POSTGRES_PASSWORD= line — so the caller's
# `pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"` treated that as
# success with an EMPTY password, and built POSTGRES_URL with a blank credential instead of
# falling through to provision fresh. Only reachable via a hand-edited/corrupted .env, since
# write_pg_compose always writes the line — but "reuse a blank password" is the wrong
# direction to fail in even for an edge case that rare.
pg_password_from_env_file() {
[[ -r "$(pg_env_file)" ]] || return 1
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
local pw
pw="$(awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)")"
[[ -n "$pw" ]] || return 1
printf '%s' "$pw"
}
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
+20 -1
View File
@@ -32,6 +32,13 @@ load_machine_answers() {
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
# The bridge machine-setup told Docker to use — proxy.sh's PROXY_NET otherwise
# defaults to "services" independently and can only agree with this by coincidence.
# PROXY_NET is a top-level assignment evaluated when proxy.sh is SOURCED, which
# happens before this function is ever called — officer-setup.sh re-derives it from
# SETUP_DOCKER_NETWORK right after calling this, same pattern as ENV_PORT/
# ENV_PUBLIC_URL/POSTGRES_URL below it.
SETUP_DOCKER_NETWORK) if [[ -z "${SETUP_DOCKER_NETWORK:-}" ]]; then SETUP_DOCKER_NETWORK="$value"; fi ;;
esac
done <"$MACHINE_ANSWERS"
return 0
@@ -69,7 +76,19 @@ tool_why() {
# The account has to exist before anything is written to its home.
owner_exists() { id "$USERNAME" &>/dev/null; }
# `getent` is Linux/glibc — not present on stock macOS at all (confirmed: "command not
# found" on a real machine; it only appeared to work in earlier testing because this dev
# session's shell profile shims it, which a fresh install does not have). Reproduced live:
# this returned the WRONG, hardcoded `/home/<user>` fallback on a real macOS run just now,
# and `install -d` then failed on it outright ("Operation not supported") since /home isn't
# a real, writable path on macOS.
#
# `eval echo ~"$USERNAME"` is the portable replacement — tilde expansion resolves through
# whatever the OS's own user database is (NSS on Linux, Directory Services on macOS)
# without naming either. A nonexistent user leaves the tilde word unexpanded (`~name`
# rather than a path), which is how the fallback below still triggers for that case.
resolve_user_home() {
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
USER_HOME="$(eval echo ~"$USERNAME" 2>/dev/null)"
[[ "$USER_HOME" == "~"* ]] && USER_HOME=""
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
}
+34 -2
View File
@@ -58,7 +58,9 @@ proxy_ask() {
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
proxy_require_listening() {
local port="$1" listen
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) branches to `lsof` on macOS. Same
# silent-empty-result failure mode as postgres.sh's pg_port_in_use if this were left as-is.
listen="$(listening_addrs "$port" | head -1)"
[[ -n "$listen" ]] || {
warn "nothing is listening on port ${port} — start Officer first"
return 1
@@ -170,7 +172,15 @@ proxy_wait_for_dns() {
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
while ((elapsed < timeout)); do
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
# `getent` is glibc/Linux-only — absent on macOS (confirmed: "command not found" on a
# real machine), silently swallowed by 2>/dev/null, which would have spun this through
# its full 30-minute timeout regardless of whether the record actually resolved.
# `dscacheutil` is macOS's own equivalent, consulting the same system resolver.
if [[ "$(uname -s)" == "Darwin" ]]; then
got="$(dscacheutil -q host -a name "$domain" 2>/dev/null | awk '/^ip_address:/ {print $2; exit}')"
else
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
fi
if [[ "$got" == "$want" ]]; then
ok "${domain} resolves to ${want}"
return 0
@@ -194,8 +204,18 @@ proxy_ensure_network() {
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
# address does not exist yet and the WHOLE container fails to start, not just that port.
#
# Docker Desktop on macOS has no systemd unit to order — it is a GUI app the OS launches, not
# a service tailscaled could be declared a dependency of — and this account does not run as
# root there either, so `mkdir -p /etc/systemd/...` would fail on permissions rather than do
# something merely unnecessary. Same shape as machine-setup's MACOS_SKIP: skip with a reason
# rather than let a Linux-only action fail confusingly.
proxy_order_docker_after_tailscaled() {
[[ "$CHALLENGE" == "dns" ]] || return 0
if [[ "$(uname -s)" == "Darwin" ]]; then
ok "Docker Desktop has no systemd unit to order — skipping"
return 0
fi
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
[[ -f "$f" ]] && return 0
mkdir -p "$(dirname "$f")"
@@ -378,8 +398,20 @@ proxy_get_token() {
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
# upstream would need none of this, which is why container upstreams are preferable when
# there is a choice.
# `ufw` is Debian/Ubuntu — no macOS equivalent through this interface (pf/pfctl is a
# different rule syntax entirely, and the Application Firewall machine-setup's own
# MACOS_SKIP already documents for "Firewall" is per-APP, not per-subnet/per-port). Skipped
# rather than guessed at: Docker Desktop for Mac also runs containers inside its own VM, so
# bridge-to-host traffic does not cross the real host firewall the same way it does when
# Docker runs directly on a Linux kernel — an unverified pf rule here could be solving a
# problem macOS's Docker networking model does not actually have.
proxy_allow_bridge_to_host() {
local port="$1" subnet
if [[ "$(uname -s)" == "Darwin" ]]; then
ok "no ufw on macOS — Docker Desktop's own networking model handles bridge-to-host, skipping"
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
return 0
fi
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
ok "ufw already allows the bridge to reach port ${port}"
+23
View File
@@ -95,4 +95,27 @@ deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
# would not start.
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
# sharp is libvips, and it arrives as a PREBUILT BINARY chosen by platform and
# architecture — @img/sharp-linux-x64 and its libvips, or the arm64 pair, or the
# musl ones. bun picks them from the lockfile's optional dependencies.
#
# Which means the install can succeed and the module can still be unusable: a
# lockfile written on x64 carries the x64 binaries, and on a machine where the
# right optional package did not resolve, `import('sharp')` throws at the first
# call rather than at install time. Same failure shape as node-pty above, and the
# same reason to check it here — the symptom otherwise is a file browser drawing
# generic icons over a folder of photographs, which nobody reads as "a native
# module is missing".
#
# Loading it is the only test worth running. A directory being present proves
# nothing; sharp's own `versions.vips` proves libvips is there and bound.
sharp_loads() {
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => { if (!m.default.versions.vips) process.exit(1) }).catch(() => process.exit(1))\"" >/dev/null 2>&1
}
# What libvips sharp found, for the report. Empty when it does not load at all.
sharp_version() {
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => console.log(m.default.versions.vips)).catch(() => {})\"" 2>/dev/null | tr -d '\r'
}
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
+1 -1
View File
@@ -35,7 +35,7 @@ bootstrap_secret_store() {
# From the repo, because the module derives the install root as the parent of
# the working directory — the same rule as src/servers/data-path.ts.
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
as_owner "cd '$(platform_dir)' && bun --eval \"
const { getKey } = await import('officerdb/secret-store');
getKey('jwt');
\"" >/dev/null 2>&1 || return 1
+21 -11
View File
@@ -44,13 +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"
# Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry.
#
# It was a Docker service because the app store made everything one, not because Radicale needs a
# container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal
# server having a calendar is not an integration with something you already run; it is the baseline.
# Radicale needs `python3` and the `radicale` package, both installed by machine-setup.
"officer-caldav|bun|run src/servers/sidecar/caldav/index.ts"
# Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with
# memos and photos when it was slated to become a plugin — the only one of the three never extracted,
# so it sat mounted nowhere with its schema commented out.
@@ -62,6 +55,11 @@ CORE_PROCESSES=(
"officer-notify|bun|run src/servers/sidecar/notify/index.ts"
)
# Calendar and contacts are NOT here. They were core from 2026-08-19 until 2026-08-27, when caldav became
# a plugin: it declares its own sidecar, its own `radicale` dependency and its own two screens, and the
# generated ecosystem file gains an `officer-caldav` entry when it is installed. A fresh machine runs five
# processes instead of seven, and gets a calendar by installing one plugin.
write_ecosystem() {
local dest entry name script args
dest="$(ecosystem_file)"
@@ -101,24 +99,36 @@ FOOTER
}
pm2_start() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
}
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
pm2_save() { as_owner "pm2 save" 2>&1; }
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
# doing it — so this runs what it prints, which is the whole point of already
# being root here.
#
# `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument
# has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd
# unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo
# command"). That is real, separate work rather than a one-line swap, so this skips with a
# stated reason on macOS instead of pretending the systemd path could ever produce anything
# usable there — matches the same call this codebase already made for the Tailscale exit-node
# NIC tuning and the proxy's systemd docker-ordering fix.
pm2_enable_startup() {
if [[ "$(uname -s)" == "Darwin" ]]; then
warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet"
return 1
fi
local cmd
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
[[ -z "$cmd" ]] && return 1
eval "${cmd#sudo }"
}
# One line per process: name, status, restarts.
pm2_status_lines() {
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
as_owner "pm2 jlist" 2>/dev/null |
node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
let apps = []; try { apps = JSON.parse(s); } catch { }
+271
View File
@@ -0,0 +1,271 @@
# Frontend Apps
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
## UI Components
### shadcn/ui (Base)
Location: `src/workspaces/components/ui/`
Standard shadcn/ui components with Tailwind CSS. Import via:
```tsx
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
```
### Custom Components
Location: `src/workspaces/components/`
Built on top of shadcn primitives:
- `Avatar.tsx` - User avatars
- `Card.tsx` - Custom card wrapper
- `ColorPicker.tsx` - Color selection
- `DataTable/` - Table with sorting, filtering, pagination (see below)
- `Dialogs/` - Common dialog patterns
- `ErrorDialogs/` - Error display dialogs
- `MetricCard.tsx` - Stats display card
- `SearchInput.tsx` - Search with debounce
- `Select.tsx` - Enhanced select
- `Slider/` - Custom slider
- `Tabs/` - Enhanced tabs
### DataTable + useDataControl
A complete data table solution with sorting, filtering, and pagination.
**Pattern:**
```tsx
import { DataTable, useDataControl } from '@/components/DataTable';
function JobsLibrary() {
const { jobs } = useJobs();
const dataController = useDataControl<Job>(jobs || []);
return (
<>
{/* Wire search input to controller */}
<SearchInput
value={dataController.searchQuery}
handleSearch={dataController.setSearchQuery}
/>
<DataTable<Job>
dataController={dataController}
pageSize={10}
columns={[
{ field: 'id', label: 'ID', sortKey: 'id' },
{ field: 'type', label: 'Type', sortKey: 'type' },
{
field: 'status',
label: 'Status',
condition: view === 'All', // conditional column
format: ({ value, item }) => <Badge>{value}</Badge>
},
{
label: 'Actions',
format: ({ item }) => <ActionsCell job={item} />
},
]}
/>
</>
);
}
```
**useDataControl returns:**
- `data` - Current page of filtered/sorted data
- `rawData` - Original unfiltered data
- `searchQuery`, `setSearchQuery` - Search state
- `searchKeys`, `setSearchKeys` - Which fields to search
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
- `sortKey`, `sortDirection` - Parsed sort info
- `currentPage`, `changePage`, `pageCount` - Pagination
- `pageSize`, `setPageSize` - Items per page
- `setCustomSort` - Custom sort function
**Column options:**
- `field` - Key in data object
- `label` / `labelMobile` - Header text
- `sortKey` - Enable sorting on this column
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
- `condition` - Show/hide column: `boolean | () => boolean`
- `tooltip` - Header tooltip text
- `headerClassName` / `cellClassName` - Styling
**Features:**
- Auto-generates columns from data if not specified
- Resets to page 1 on search/sort change
- Search strips diacritics for accent-insensitive matching
- Built-in pagination bar (shows when data exceeds pageSize)
## Hooks
Location: `src/workspaces/hooks/src/`
### Data Fetching - `useClient`
HTTP client with auth token handling:
```tsx
const client = useClient();
const data = await client.get<User[]>("/users");
await client.post("/experiments", payload);
```
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
Auto-attaches Bearer token from localStorage/sessionStorage.
### Forms - `useForm`
Custom form hook (not react-hook-form):
```tsx
const { state, formRef, update, reset, isValid } = useForm<FormState>(
initialState,
validateFn
);
return <form ref={formRef}>...</form>;
```
Features:
- Auto-syncs form inputs with state via `name` attribute
- Handles checkboxes, radios, selects, number inputs
- Nested object support via data attributes
- Validation function support
### Other Hooks
- `useAuth/` - Authentication state and methods
- `useDebounce` - Debounced values
- `useDragAndDrop` - Drag and drop functionality
- `useFullscreen/` - Fullscreen API wrapper
- `useIsMobile` - Responsive breakpoint detection
- `useLocalStorageState` - Persisted state
- `useMounted` - Component mount status
- `usePopover` - Popover state management
- `useTimeout` - Timeout management
- `useTimer` - Interval-based timer
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
- `useJobs` - background jobs
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
## State Management
Uses React Query cache as both server and client state manager.
### Global State - `useGlobal`
Uses React Query cache as a global state store (no Context providers needed):
```tsx
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
// Any component using the same key shares state and reacts to changes
setValue("collapsed");
```
How it works:
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
- Automatic re-renders when state changes
- Visible in React Query DevTools
- Supports functional updates: `setValue(prev => ...)`
### URL Query State - `useQueryState`
Syncs state with URL query parameters:
```tsx
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
```
Features:
- Updates URL via `history.replaceState` (no page reload)
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
- `isGlobal = false`: local to component, uses `useState`
- Auto-rebuilds URL when pathname changes (preserves global query state)
- Type coercion based on `defaultValue` type
### Server State
Domain-specific hooks in `state/` directories wrap React Query:
```tsx
// src/workspaces/hooks/src/useJobs.ts
const { jobs, isLoading } = useJobs();
```
### When to Use What
| Scenario | Hook |
|----------|------|
| API data | Domain hooks (`useJobs`, `useModels`, …) |
| Shared UI state | `useGlobal` |
| URL-driven state (filters, pagination) | `useQueryState` |
| Component-only state | `useState` |
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
| Persisted to localStorage | `useLocalStorageState` |
## Event Handlers
**Always use `ev` for event parameters**, not `e`:
```tsx
// ✅ Good
onChange={(ev) => setName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSubmit();
}
}}
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
// ...
};
// ❌ Bad
onChange={(e) => setName(e.target.value)}
```
## React 19 Patterns
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
```tsx
// ✅ Good - allow null in ref type
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement | null>;
};
// ❌ Bad - will error when passing useRef result
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement>;
};
```
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
```tsx
import { useJobs } from 'hooks/useJobs';
type TopHeaderProps = {
manager: ReturnType<typeof useJobs>;
};
```
## Error Handling
**Toast notifications** via Sonner:
```tsx
import { toast } from "sonner";
toast.error("Something went wrong");
toast.success("Saved successfully");
```
API errors automatically trigger via `useClient.config.onError`.
## Further Reading
- `../../CLAUDE.md` — architecture and the frontend route conventions
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
- `../../CONVENTIONS.md` — component organisation and React patterns
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
+3 -268
View File
@@ -1,271 +1,6 @@
# Frontend Apps
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
## UI Components
### shadcn/ui (Base)
Location: `src/workspaces/components/ui/`
Standard shadcn/ui components with Tailwind CSS. Import via:
```tsx
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
```
### Custom Components
Location: `src/workspaces/components/`
Built on top of shadcn primitives:
- `Avatar.tsx` - User avatars
- `Card.tsx` - Custom card wrapper
- `ColorPicker.tsx` - Color selection
- `DataTable/` - Table with sorting, filtering, pagination (see below)
- `Dialogs/` - Common dialog patterns
- `ErrorDialogs/` - Error display dialogs
- `MetricCard.tsx` - Stats display card
- `SearchInput.tsx` - Search with debounce
- `Select.tsx` - Enhanced select
- `Slider/` - Custom slider
- `Tabs/` - Enhanced tabs
### DataTable + useDataControl
A complete data table solution with sorting, filtering, and pagination.
**Pattern:**
```tsx
import { DataTable, useDataControl } from '@/components/DataTable';
function JobsLibrary() {
const { jobs } = useJobs();
const dataController = useDataControl<Job>(jobs || []);
return (
<>
{/* Wire search input to controller */}
<SearchInput
value={dataController.searchQuery}
handleSearch={dataController.setSearchQuery}
/>
<DataTable<Job>
dataController={dataController}
pageSize={10}
columns={[
{ field: 'id', label: 'ID', sortKey: 'id' },
{ field: 'type', label: 'Type', sortKey: 'type' },
{
field: 'status',
label: 'Status',
condition: view === 'All', // conditional column
format: ({ value, item }) => <Badge>{value}</Badge>
},
{
label: 'Actions',
format: ({ item }) => <ActionsCell job={item} />
},
]}
/>
</>
);
}
```
**useDataControl returns:**
- `data` - Current page of filtered/sorted data
- `rawData` - Original unfiltered data
- `searchQuery`, `setSearchQuery` - Search state
- `searchKeys`, `setSearchKeys` - Which fields to search
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
- `sortKey`, `sortDirection` - Parsed sort info
- `currentPage`, `changePage`, `pageCount` - Pagination
- `pageSize`, `setPageSize` - Items per page
- `setCustomSort` - Custom sort function
**Column options:**
- `field` - Key in data object
- `label` / `labelMobile` - Header text
- `sortKey` - Enable sorting on this column
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
- `condition` - Show/hide column: `boolean | () => boolean`
- `tooltip` - Header tooltip text
- `headerClassName` / `cellClassName` - Styling
**Features:**
- Auto-generates columns from data if not specified
- Resets to page 1 on search/sort change
- Search strips diacritics for accent-insensitive matching
- Built-in pagination bar (shows when data exceeds pageSize)
## Hooks
Location: `src/workspaces/hooks/src/`
### Data Fetching - `useClient`
HTTP client with auth token handling:
```tsx
const client = useClient();
const data = await client.get<User[]>("/users");
await client.post("/experiments", payload);
```
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
Auto-attaches Bearer token from localStorage/sessionStorage.
### Forms - `useForm`
Custom form hook (not react-hook-form):
```tsx
const { state, formRef, update, reset, isValid } = useForm<FormState>(
initialState,
validateFn
);
return <form ref={formRef}>...</form>;
```
Features:
- Auto-syncs form inputs with state via `name` attribute
- Handles checkboxes, radios, selects, number inputs
- Nested object support via data attributes
- Validation function support
### Other Hooks
- `useAuth/` - Authentication state and methods
- `useDebounce` - Debounced values
- `useDragAndDrop` - Drag and drop functionality
- `useFullscreen/` - Fullscreen API wrapper
- `useIsMobile` - Responsive breakpoint detection
- `useLocalStorageState` - Persisted state
- `useMounted` - Component mount status
- `usePopover` - Popover state management
- `useTimeout` - Timeout management
- `useTimer` - Interval-based timer
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
- `useJobs` - background jobs
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
## State Management
Uses React Query cache as both server and client state manager.
### Global State - `useGlobal`
Uses React Query cache as a global state store (no Context providers needed):
```tsx
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
// Any component using the same key shares state and reacts to changes
setValue("collapsed");
```
How it works:
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
- Automatic re-renders when state changes
- Visible in React Query DevTools
- Supports functional updates: `setValue(prev => ...)`
### URL Query State - `useQueryState`
Syncs state with URL query parameters:
```tsx
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
```
Features:
- Updates URL via `history.replaceState` (no page reload)
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
- `isGlobal = false`: local to component, uses `useState`
- Auto-rebuilds URL when pathname changes (preserves global query state)
- Type coercion based on `defaultValue` type
### Server State
Domain-specific hooks in `state/` directories wrap React Query:
```tsx
// src/workspaces/hooks/src/useJobs.ts
const { jobs, isLoading } = useJobs();
```
### When to Use What
| Scenario | Hook |
|----------|------|
| API data | Domain hooks (`useJobs`, `useModels`, …) |
| Shared UI state | `useGlobal` |
| URL-driven state (filters, pagination) | `useQueryState` |
| Component-only state | `useState` |
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
| Persisted to localStorage | `useLocalStorageState` |
## Event Handlers
**Always use `ev` for event parameters**, not `e`:
```tsx
// ✅ Good
onChange={(ev) => setName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSubmit();
}
}}
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
// ...
};
// ❌ Bad
onChange={(e) => setName(e.target.value)}
```
## React 19 Patterns
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
```tsx
// ✅ Good - allow null in ref type
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement | null>;
};
// ❌ Bad - will error when passing useRef result
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement>;
};
```
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
```tsx
import { useJobs } from 'hooks/useJobs';
type TopHeaderProps = {
manager: ReturnType<typeof useJobs>;
};
```
## Error Handling
**Toast notifications** via Sonner:
```tsx
import { toast } from "sonner";
toast.error("Something went wrong");
toast.success("Saved successfully");
```
API errors automatically trigger via `useClient.config.onError`.
## Further Reading
- `../../CLAUDE.md` — architecture and the frontend route conventions
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
- `../../CONVENTIONS.md` — component organisation and React patterns
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
+7 -5
View File
@@ -78,8 +78,6 @@ export function App() {
entry keep working. Only DIRECTORIES live here — an open file stays `?view=`, since it is
what is on top of you rather than where you are. */}
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
@@ -91,13 +89,15 @@ export function App() {
{/* 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. */}
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
{installedPlugins.flatMap((plugin) => [
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
// The section pair, exactly as the core screens do it (`/soulseek/: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`}
key={`${plugin.screenId}-section`}
path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
@@ -107,7 +107,9 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/todos" element={<Dashboard.TodosScreen />} />
<Route path="/bots" element={<Dashboard.BotsScreen />} />
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { Bot, Check, Pencil, Plus, Trash2, X } from 'lucide-react';
import { Link, useNavigate, useParams } from 'react-router';
import { useClaudeSessions, type ClaudeSessionSummary } from 'state/useClaudeSessions';
/**
* The bots, as cards — the same object Command HQ gives a commander.
*
* ── Why not the chat session list ──
*
* A bot IS a chat underneath, and that is an implementation detail nobody using this should have to hold.
* The generic session list says "conversation, last touched at" because that is what a conversation is. A
* bot is a thing you name, check on, and eventually delete — so it gets a card with its name, its id, and
* whether it is watching, exactly as a commander does.
*
* ── The id pill ──
*
* A commander shows `#54`, its directory. A bot has no directory of its own — they share one — so its id
* is the first eight characters of its session, which is also what an `@mention` carries. Seeing the same
* eight characters in both places is the point: the pill is what you type to reference it.
*/
const shortId = (id: string) => id.slice(0, 8);
type CardProps = {
bot: ClaudeSessionSummary;
selected: boolean;
onRename: (id: string, title: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
};
const BotCard = ({ bot, selected, onRename, onDelete }: CardProps) => {
const navigate = useNavigate();
const [confirming, setConfirming] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(bot.title);
const commit = async () => {
const next = draft.trim();
setRenaming(false);
if (next && next !== bot.title) await onRename(bot.id, next);
};
return (
<div
className={`group relative flex flex-col gap-1.5 rounded-xl border bg-background p-3 transition-colors hover:border-primary/40 hover:bg-muted/40 ${
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
}`}
>
{/* The whole card is the link, with the controls layered above it — same arrangement as a commander
card, so a click anywhere opens the bot and the buttons still work. */}
{!renaming && <Link to={`/bots/${bot.id}`} className="absolute inset-0" aria-label={bot.title} />}
<div className="flex items-center gap-2 pr-12">
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
{renaming ? (
<form
className="z-10 flex min-w-0 flex-1 items-center gap-1"
onSubmit={(ev) => {
ev.preventDefault();
void commit();
}}
>
<input
autoFocus
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => ev.key === 'Escape' && setRenaming(false)}
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0.5 text-sm"
/>
<button type="submit" className="rounded p-0.5 text-primary" aria-label="Save name">
<Check className="size-3.5" />
</button>
</form>
) : (
<>
<span className="truncate text-sm font-medium">{bot.title || '(unnamed)'}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground" title={bot.id}>
#{shortId(bot.id)}
</span>
</>
)}
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{bot.messageCount} message{bot.messageCount === 1 ? '' : 's'}
</span>
<span title={bot.updatedAt}>{new Date(bot.updatedAt).toLocaleDateString()}</span>
</div>
{confirming ? (
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded-lg bg-background/95 p-0.5 shadow">
<button
onClick={() => {
void onDelete(bot.id);
// Leaving the route pointed at something that no longer exists would render an empty chat.
if (selected) void navigate('/bots');
}}
className="rounded p-1 text-destructive hover:bg-destructive/10"
title="Delete this bot"
>
<Check className="size-3.5" />
</button>
<button onClick={() => setConfirming(false)} className="rounded p-1 opacity-50 hover:opacity-100">
<X className="size-3.5" />
</button>
</div>
) : (
!renaming && (
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button
onClick={() => {
setDraft(bot.title);
setRenaming(true);
}}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Rename"
>
<Pencil className="size-3.5" />
</button>
<button
onClick={() => setConfirming(true)}
className="rounded p-1 text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 className="size-3.5" />
</button>
</div>
)
)}
</div>
);
};
export const BotsList = () => {
const { sessionId } = useParams();
const { sessions, isLoading, renameSession, deleteSession } = useClaudeSessions(null, {
chatContext: { context: 'bots' },
});
return (
<nav className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
</span>
{/* `/bots` with no id IS the new-bot state: an empty composer, and the first sentence arms it. */}
<Link to="/bots" title="New bot" className="text-muted-foreground hover:text-foreground">
<Plus className="size-4" />
</Link>
</header>
{isLoading ? (
<p className="p-3 text-xs text-muted-foreground">Loading…</p>
) : sessions.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
<Bot className="size-7 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No bots yet.</p>
<p className="text-[11px] leading-relaxed text-muted-foreground">
Say what you want watched — “notify me when x.com is down” — and it arms itself.
</p>
</div>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-auto p-2">
{sessions.map((bot) => (
<li key={bot.id}>
<BotCard bot={bot} selected={bot.id === sessionId} onRename={renameSession} onDelete={deleteSession} />
</li>
))}
</ul>
)}
</nav>
);
};
@@ -0,0 +1,59 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
import { useParams } from 'react-router';
import { useAuthoredLayout } from 'state/useAuthoredLayout';
import { defaultLayout } from './defaultLayout';
import { BotsList } from './BotsList';
/**
* `/bots` — one sentence arms a watch, and the conversation stays open to widen it.
*
* ── The same shape as Command HQ ──
*
* A narrow list on the left, the conversation on the right, and the selection in the URL as a route pair:
* `/bots` is the new-bot state, `/bots/:sessionId` is one of them. That is the convention
* `docs/navigation-audit.md` sets and the one Command HQ already follows, so a bot is linkable and a
* reload keeps the one you were reading.
*
* ── Why the panels are supplied here rather than registered ──
*
* Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's
* `officerdev/chat` derives its context from the workspace and would land on none of it — it would list
* and resume the caller's HOME, which is exactly what this screen did on its first outing. `components` is
* the prop for a screen that supplies its own chat panel, and it carries the header, so the picker lists
* bots rather than every chat on the machine.
*
* The instructions a bot runs under are `$DATA_PATH/<email>/bots/CLAUDE.md`, written by the server on
* every turn — `api/bots/brief.ts`.
*/
const CHAT_CONTEXT = { context: 'bots' } as const;
const BotsChat = ({ panelId }: { panelId: string }) => {
const { sessionId } = useParams();
return (
<div className="flex h-full min-h-0 flex-col">
{/* `sessionId ?? null` — null is "the screen chose a new chat", which is not the same as having no
opinion. On `/bots` with no id that is exactly right: an empty composer waiting for the sentence. */}
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} sessionId={sessionId ?? null} />
</div>
);
};
export const BotsScreen = () => {
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
return (
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
components={{
'bots-list': BotsList,
// No header picker: the cards on the left are the list, and a second one in the panel bar would be
// the same choice offered twice. Command HQ does not have one either, for the same reason.
'bots-chat': BotsChat,
}}
/>
);
};
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
// The bots down the left, the conversation on the right — the same division Command HQ makes, and for the
// same reason: the list is names and the right-hand side is the only thing anybody reads.
//
// No category pane. Commanders have one because a commander is a job somebody files; a bot is a sentence,
// and a filter over a handful of one-line watches would be furniture.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'bots-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'bots-list', appType: 'bots-list' }, size: 24 },
{ node: { type: 'panel', id: 'bots-chat', appType: 'bots-chat' }, size: 76 },
],
};
@@ -0,0 +1 @@
export { BotsScreen } from './BotsScreen';
@@ -1,27 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /calendar uses the Workspace/Panel system (like /command-hq): the calendar list on the left, the
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
// /api/caldav auth proxy, which holds no DAV credentials of its own.
//
// There is no :param route pair here and so no <Navigate> guard: a collection id is a DAV path with
// slashes in it, so the selection lives in ?collection= on this same route. An absent or unknown value
// resolves to the first collection inside the panels rather than by redirecting, because until the
// collection list has loaded there is no canonical URL to redirect to.
export const CalendarScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['calendar-nav', 'calendar-view'], fallback: 'calendar-view' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'calendar-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 },
{ node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 },
],
};
@@ -1 +0,0 @@
export * from './CalendarScreen';
@@ -1,21 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
// collection is a query param rather than a route segment.
export const ContactsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['contacts-nav', 'contacts-view'], fallback: 'contacts-view' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'contacts-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 },
{ node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 },
],
};
@@ -1 +0,0 @@
export * from './ContactsScreen';
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
<ImpersonationBanner />
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
@@ -109,10 +109,16 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
{item.label}
</span>
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
className="w-10 h-10 md:w-12 md:h-12 rounded-xl overflow-hidden flex items-center justify-center transition-all"
style={{
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
// does not paint it.
//
// `overflow-hidden` is what makes the ROUNDING apply to artwork. A `badge` tile insets
// its glyph, so the corners were never reached and nothing here looked wrong for as
// long as every plugin used one. `bare` fills the box edge to edge, so a logo on an
// opaque background kept its own square corners and sat in the dock as the one tile
// that had not been told the shape.
background: item.tile === 'bare' ? 'transparent' : item.color,
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
}}
@@ -142,22 +148,23 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
};
import {
Home,
MessageCircle,
FolderOpen,
Code,
LayoutGrid,
Monitor,
Globe,
Workflow,
Music,
Activity,
Radio,
Bot,
Code,
FolderOpen,
GitBranch,
Store,
Globe,
Home,
LayoutGrid,
MessageCircle,
Monitor,
Music,
Puzzle,
Radar,
Radio,
Send,
Store,
Workflow,
} from 'lucide-react';
/**
@@ -176,6 +183,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
// Beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing.
// The route and the screen have existed since bots did; there was simply never a tile, so the only way
// to reach /bots was to type it.
{ labelKey: 'nav.bots', to: '/bots', icon: Bot, color: '#34d399' },
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
@@ -187,17 +198,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure
// this file's comment above warns about, arrived at from the other direction.
//
// Artwork rather than glyphs since 2026-08-19: both were redrawn to the icon brief, so they carry
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
// Calendar and Contacts are NOT here. They were core tiles from 2026-08-19 until caldav became a
// plugin on 2026-08-27, and they come back through `dockItemsFromPlugins` — one tile per screen, each
// with its own artwork, filtered by the same `calendar` permission. A plugin contributing two tiles is
// what `screens` in a manifest is for.
// 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.
@@ -282,6 +286,8 @@ export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
// this list is what every install has and most people want visible; a calendar is now in that category.
// Contacts is deliberately NOT here — it is core and available in dock settings, but a second tile for
// the same sidecar in everyone's default dock is a crowded default rather than a useful one.
// `/calendar` stays in the default arrangement even though it is a plugin route now: it is installed by
// default and a fresh dock that silently dropped it would look like a downgrade.
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat', '/calendar'];
/**
@@ -143,7 +143,10 @@ const Row = ({
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
// `overflow-hidden` for the same reason as the dock: `bare` artwork fills the box, so without it
// the rounding is drawn and then painted over. The two have to agree — the same plugin appears in
// both, a few pixels apart.
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md"
style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
@@ -178,13 +178,9 @@ const SETTINGS_ITEMS: SettingsItem[] = [
icon: KeyRound,
},
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:dav',
labelKey: 'settings.sections.davSync.label',
to: '/settings/integrations/dav-app-passwords',
hintKey: 'settings.sections.davSync.hint',
icon: CalendarDays,
},
// DAV sync setup left with the caldav plugin on 2026-08-27 — it is a modal on the Calendar screen now,
// and Spotlight has no way to open a plugin's modal. `bun run docs:check` caught this one: it compares
// every settings route Spotlight offers against the sections that actually exist.
// Users — UserManagement
{
id: 'set:users',
@@ -18,11 +18,18 @@ import { useAuthoredLayout } from 'state/useAuthoredLayout';
// 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,
screenId,
panels,
layout,
}: {
appName: string;
/**
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
*
* A plugin with two screens has two of these, and they must differ: the id is both the layout
* persistence key and the owner of the panel allow-list below, so sharing one would have Calendar and
* Contacts persisting over each other's sizes and each accepting the other's panels.
*/
screenId: string;
panels: AppRegistryMeta[];
layout: LayoutNode;
}) {
@@ -32,7 +39,7 @@ export function PluginScreen({
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
const workspace = useAuthoredLayout(`screens/plugin/${appName}`, layout);
const workspace = useAuthoredLayout(`screens/plugin/${screenId}`, layout);
const allowed = panels.map((panel) => panel.key);
return (
@@ -1,6 +1,6 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { useAuthoredLayout } from 'state/useAuthoredLayout';
import { defaultLayout } from './defaultLayout';
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
@@ -11,7 +11,11 @@ import { defaultLayout } from './defaultLayout';
// 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);
// `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is ours and comes from source on
// every render, while only the sizes are the user's and persisted. Under the old hook the whole tree
// was stored, so the panels this screen ships today would never have reached anyone who had opened it
// before — they would still be looking at the list-and-preview this replaced.
const workspace = useAuthoredLayout('screens/plugins', defaultLayout);
return (
<div className="h-full w-full pt-2">
@@ -8,7 +8,9 @@ export const defaultLayout: LayoutNode = {
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 },
// 22/78, not 32/68. The left panel was a list of every plugin and needed the room; it is a list of
// seven tags now, and the grid on the right is what benefits from the width — one more card per row.
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 22 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 78 },
],
};
@@ -1,226 +0,0 @@
import type { FormEvent } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
//
// The generated password is returned by POST and never again — it is stored as an argon2 hash, so there
// is nothing to read back. That single fact drives the whole design of this screen: the new credential
// is shown in a panel that stays put until dismissed, with the server URL and username beside it,
// because the moment it disappears the only remedy is to revoke and mint another.
type DavPassword = {
id: number;
label: string;
hint: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
};
type Minted = { password: string; username: string; label: string };
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
export const DavAppPasswords = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.dav.copied', { what }));
} catch {
toast.error(t('settings.dav.copyFailed'));
}
};
const client = useClient();
const [passwords, setPasswords] = useState<DavPassword[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [label, setLabel] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [minted, setMinted] = useState<Minted | null>(null);
// The base the phone should be pointed at. Taken from the browser rather than configured: it is by
// definition the address that reached this page, which is the one that will work.
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const load = useCallback(() => {
client
.get<{ passwords: DavPassword[] }>('/dav/passwords')
.then((data) => setPasswords(data?.passwords ?? []))
.catch(() => {})
.finally(() => setIsLoading(false));
}, [client]);
useEffect(load, [load]);
const create = async (ev?: FormEvent) => {
ev?.preventDefault();
const trimmed = label.trim();
if (!trimmed || isCreating) return;
setIsCreating(true);
try {
const res = await client.post<{ password: string; username: string }>('/dav/passwords', { label: trimmed });
setMinted({ password: res.password, username: res.username, label: trimmed });
setLabel('');
load();
} catch {
toast.error(t('settings.dav.createFailed'));
} finally {
setIsCreating(false);
}
};
const revoke = async (id: number) => {
try {
await client.post(`/dav/passwords/${id}/revoke`, {});
toast.success(t('settings.dav.revoked'));
load();
} catch {
toast.error(t('settings.dav.revokeFailed'));
}
};
const remove = async (id: number) => {
try {
await client.delete(`/dav/passwords/${id}`);
load();
} catch {
toast.error(t('settings.dav.deleteFailed'));
}
};
if (isLoading) return null;
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
Calendar and contacts sync uses one password per device. A phone can't hold an Officer session, so it gets its
own credential — revocable on its own, without touching anything else.
</p>
{minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
Password for “{minted.label}” — shown once
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
revoke this entry and make another.
</p>
<div className="grid gap-2">
{[
{ field: 'Server', value: serverUrl },
{ field: 'Username', value: minted.username },
{ field: 'Password', value: minted.password },
].map(({ field, value }) => (
<div key={field} className="flex items-center gap-2">
<span className="w-20 shrink-0 text-xs text-duck-dark/50 dark:text-foreground/50">{field}</span>
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{value}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copy(value, field)}
title={t('settings.dav.copyField', { field })}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
In DAVx5 or iOS, choose “login with URL and username” and give it the server address above — not a full
calendar path. Discovery does the rest.
</p>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it
</Button>
</div>
)}
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={label}
onChange={(ev) => setLabel(ev.target.value)}
placeholder={t('settings.dav.placeholder')}
/>
</Label>
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
{t('settings.dav.create')}
</Button>
</form>
{passwords.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{t('settings.dav.none')}
</div>
) : (
<div className="grid gap-2">
{passwords.map((entry) => {
const revoked = !!entry.revokedAt;
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
className={`flex items-center gap-3 rounded-lg border p-3 ${
revoked
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
: 'border-duck-dark/15 dark:border-foreground/15'
}`}
>
<Smartphone className="h-4 w-4 shrink-0 text-duck-teal/70" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.label}
{revoked && <span className="ml-2 text-xs text-red-500">revoked</span>}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.hint}…</code>
{' · '}
{/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'}
</div>
</div>
{!revoked && (
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => remove(entry.id)}
title={t('settings.dav.delete')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -9,7 +9,6 @@ import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection }
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations';
@@ -43,13 +42,9 @@ const personalSections: SettingsSection[] = [
// descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />,
// },
{
key: 'dav-app-passwords',
icon: CalendarDays,
titleKey: 'settings.sections.davSync.label',
descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />,
},
// DAV app passwords left with the caldav plugin on 2026-08-27. A plugin cannot contribute a settings
// section — it ships panels — so the form is a modal on the Calendar screen, the same answer the email
// plugin reached for account management when it was extracted.
];
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
@@ -1,7 +1,17 @@
import { startImpersonation } from 'officerdev';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
import {
Copy,
Crown,
KeyRound,
Loader2,
RotateCcw,
SquareTerminal as TerminalIcon,
Trash2,
UserCog,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -128,6 +138,25 @@ export const UsersSection = () => {
}
};
/**
* Sign in as this person, for an hour.
*
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
* new password and a deletion, which are neither.
*/
const impersonate = async (user: ManagedUser) => {
setPendingId(user.id);
try {
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
// Swaps the token and reloads — nothing after this line runs.
startImpersonation(token);
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -263,6 +292,22 @@ export const UsersSection = () => {
</Button>
)}
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
offers it would be the one row where it cannot work. */}
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Sign in as ${user.email}`}
title="Sign in as this user for one hour"
onClick={() => void impersonate(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -1,12 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
export const TodosScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
};
@@ -1,8 +0,0 @@
import type { LayoutNode } from 'officerdev';
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'todo-board',
appType: 'todo-board',
};
@@ -1 +0,0 @@
export * from './TodosScreen';
@@ -11,8 +11,6 @@ export * from './Skills';
export * from './Tasks';
export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './CommandHQ';
export * from './SystemMonitor';
export * from './Activity';
@@ -23,4 +21,4 @@ export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
export * from './Bots';
+1
View File
@@ -463,6 +463,7 @@
"files": "Dateien",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Files",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Archivos",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Trabajos",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Fichiers",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Éditeur",
"jobs": "Jobs",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Ficheiros",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Trabalhos",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Filer",
"chat": "Chatt",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Jobb",
"terminal": "Terminal",
+231
View File
@@ -0,0 +1,231 @@
# Database Patterns
## Dropping and adding a column in the same push makes drizzle ASK
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
```
Is grants_postgres column in roles table created or renamed from another column?
❯ + grants_postgres create column
~ provisioning_script › grants_postgres rename column
```
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
error about a column that does not exist, several steps from the thing that failed.
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
a rollout over SSH has a terminal to answer with.
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
`bun db:push` as the check that it worked rather than the thing that does it:
```sql
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
```
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
## Overview
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
describing a different codebase.
```
src/databases/officer_db/
├── src/
│ ├── db.ts # the connection
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
│ ├── schema.ts # what db:push creates — see below
│ ├── types.ts # every type export (Select / Insert / extended)
│ ├── crypto.ts # at-rest encryption, one key per purpose
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
│ └── <feature>/
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
│ ├── schema.ts # its tables
│ └── queries.ts # everything that reads or writes them
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
```
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
query function is one file in one directory, rather than that file plus a hand-written list of every
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
`queries/` trees, where the two sides had drifted: four features were named differently on each side
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
query file at all, and `integrations` had no schema file.
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
Task Logs feature — see below.)
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
commented line there removes a table from the DATABASE without removing a line of code — every query
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
create only the core tables, with the plugin ones commented out until their plugin is installed.
## Schema changes use `push`, not migrations
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
as the source of truth, never those files.
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
them was gone, because nothing structural said they had become illegal.
```ts
const SERVER_PROVIDERS = ['google', 'apify'] as const;
check(
'ck_server_integrations_provider',
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
sql`${table.provider} IN (${sql.join(
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
);
```
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
database has drifted, instead of quietly accepting it. Clean the rows, then push.
### Composite keys: use `uniqueIndex`, and name any long foreign key
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
them against a schema that declares exactly the same name, columns and order, decides they differ, and
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
that could never be resolved by answering it.
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
adding one.
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
longer version and re-creates the constraint on every push.
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
follow.
The six, and where each lives:
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
the three-argument one, because there was nowhere else to put the declaration.
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
rather than the truncation one, and there is no index form to escape to because a primary key must be a
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
is a far more useful baseline than ten, because anything else really is your change.
**Rules that still apply:**
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
nothing — whatever is being re-created gets re-created next push regardless.
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
uniqueness rule would break the upserts that depend on it.
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
branch it takes at a truncate prompt.
4. **Read the plan before applying it:**
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
all. Anything else is your change.
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
## Type naming
```ts
// types.ts — inferred from the schema, never hand-written
export type UserSelect = typeof Schema.users.$inferSelect;
export type UserInsert = typeof Schema.users.$inferInsert;
// The bare name is the hydrated shape, when a table has relations worth carrying
export type User = UserSelect & {
passkeys: PasskeySelect[];
};
```
Organise `types.ts` by domain with section comments, mirroring the schema files.
## Queries
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
types from `../types`:
```ts
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys } from '../schema';
import type { UserSelect } from '../types';
export async function getUsers(): Promise<UserSelect[]> { … }
```
Every query is exported from `src/index.ts`, which is the only surface callers use.
## Importing
```ts
// ✅ types from the types subpath
import type { User } from 'officerdb/types';
// ✅ queries, schema and drizzle helpers from the package root
import { getUserById, eq } from 'officerdb';
// ❌ never reach into src/schema or src/queries directly
// ❌ never hand-write a type a table can infer
```
In app code import from `'types'`, which re-exports the database types. Only server and database code
imports `officerdb/types` directly.
## Conventions
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
(`chat_session_events` is the example — its id is also the replay cursor).
- `timestamp('…', { withTimezone: true })`, always.
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
than cast.
+3 -228
View File
@@ -1,231 +1,6 @@
# Database Patterns
## Dropping and adding a column in the same push makes drizzle ASK
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
```
Is grants_postgres column in roles table created or renamed from another column?
❯ + grants_postgres create column
~ provisioning_script › grants_postgres rename column
```
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
error about a column that does not exist, several steps from the thing that failed.
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
a rollout over SSH has a terminal to answer with.
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
`bun db:push` as the check that it worked rather than the thing that does it:
```sql
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
```
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
## Overview
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
describing a different codebase.
```
src/databases/officer_db/
├── src/
│ ├── db.ts # the connection
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
│ ├── schema.ts # what db:push creates — see below
│ ├── types.ts # every type export (Select / Insert / extended)
│ ├── crypto.ts # at-rest encryption, one key per purpose
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
│ └── <feature>/
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
│ ├── schema.ts # its tables
│ └── queries.ts # everything that reads or writes them
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
```
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
query function is one file in one directory, rather than that file plus a hand-written list of every
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
`queries/` trees, where the two sides had drifted: four features were named differently on each side
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
query file at all, and `integrations` had no schema file.
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
Task Logs feature — see below.)
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
commented line there removes a table from the DATABASE without removing a line of code — every query
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
create only the core tables, with the plugin ones commented out until their plugin is installed.
## Schema changes use `push`, not migrations
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
as the source of truth, never those files.
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
them was gone, because nothing structural said they had become illegal.
```ts
const SERVER_PROVIDERS = ['google', 'apify'] as const;
check(
'ck_server_integrations_provider',
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
sql`${table.provider} IN (${sql.join(
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
);
```
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
database has drifted, instead of quietly accepting it. Clean the rows, then push.
### Composite keys: use `uniqueIndex`, and name any long foreign key
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
them against a schema that declares exactly the same name, columns and order, decides they differ, and
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
that could never be resolved by answering it.
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
adding one.
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
longer version and re-creates the constraint on every push.
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
follow.
The six, and where each lives:
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
the three-argument one, because there was nowhere else to put the declaration.
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
rather than the truncation one, and there is no index form to escape to because a primary key must be a
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
is a far more useful baseline than ten, because anything else really is your change.
**Rules that still apply:**
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
nothing — whatever is being re-created gets re-created next push regardless.
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
uniqueness rule would break the upserts that depend on it.
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
branch it takes at a truncate prompt.
4. **Read the plan before applying it:**
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
all. Anything else is your change.
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
## Type naming
```ts
// types.ts — inferred from the schema, never hand-written
export type UserSelect = typeof Schema.users.$inferSelect;
export type UserInsert = typeof Schema.users.$inferInsert;
// The bare name is the hydrated shape, when a table has relations worth carrying
export type User = UserSelect & {
passkeys: PasskeySelect[];
};
```
Organise `types.ts` by domain with section comments, mirroring the schema files.
## Queries
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
types from `../types`:
```ts
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys } from '../schema';
import type { UserSelect } from '../types';
export async function getUsers(): Promise<UserSelect[]> { … }
```
Every query is exported from `src/index.ts`, which is the only surface callers use.
## Importing
```ts
// ✅ types from the types subpath
import type { User } from 'officerdb/types';
// ✅ queries, schema and drizzle helpers from the package root
import { getUserById, eq } from 'officerdb';
// ❌ never reach into src/schema or src/queries directly
// ❌ never hand-write a type a table can infer
```
In app code import from `'types'`, which re-exports the database types. Only server and database code
imports `officerdb/types` directly.
## Conventions
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
(`chat_session_events` is the example — its id is also the replay cursor).
- `timestamp('…', { withTimezone: true })`, always.
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
than cast.
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
@@ -17,6 +17,8 @@ export type CatalogueEntry = {
platform: string;
label: string;
summary: string;
/** What it is for. Always present, `[]` for a plugin that declares none, so callers need no guard. */
tags: string[];
color: string;
tile?: 'badge' | 'bare';
iconUrl?: string;
@@ -53,6 +55,9 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
platform: m.platform,
label: m.label,
summary: m.summary,
// Normalised to an array here rather than at every call site: the column is nullable because it
// arrived after the rows did, and null vs [] is a distinction no consumer of this wants to carry.
tags: Array.isArray(m.tags) ? (m.tags as string[]) : [],
color: m.color ?? '#94a3b8',
...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}),
...(m.iconUrl ? { iconUrl: m.iconUrl } : {}),
@@ -85,6 +85,20 @@ export const pluginMetadata = pgTable(
/** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */
icon: text('icon'),
/**
* What the plugin is for — the store's filter, and the only field here that exists for BROWSING
* rather than for installing.
*
* jsonb like the shapes below it, and for the same reason: it is read as a unit and rendered as a
* unit. A join table would buy filtering in SQL, which matters at a scale a hand-curated catalogue of
* twenty-one is nowhere near — and it would cost a migration every time the vocabulary changes, which
* is exactly the thing expected to change.
*
* Nullable because it arrived after the rows did. Null and `[]` mean the same thing here: no filter
* shows it.
*/
tags: jsonb('tags'),
permissions: jsonb('permissions').notNull(),
osDependencies: jsonb('os_dependencies'),
/** `{ api, schema, sidecar, web }` — what the plugin ships. */
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
name: string | null;
categoryId: number | null;
plugin: string | null;
pluginConfig: Record<string, string> | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherArmedAt: Date | null;
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
referencePaths: commandHqSessions.referencePaths,
name: commandHqSessions.name,
categoryId: commandHqSessions.categoryId,
plugin: commandHqSessions.plugin,
pluginConfig: commandHqSessions.pluginConfig,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherArmedAt: commandHqSessions.watcherArmedAt,
@@ -85,7 +89,16 @@ const SESSION_ROW_COLUMNS = {
createdAt: commandHqSessions.createdAt,
};
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
export type NewCommander = {
userId: number;
name: string;
referencePath: string;
categoryId?: number | null;
/** The plugin whose template this was made from, or null for one somebody made themselves. */
plugin?: string | null;
/** The owner's answers to that template's inputs. Null when there was no template. */
pluginConfig?: Record<string, string> | null;
};
/**
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
@@ -109,6 +122,8 @@ export async function createCommander(input: NewCommander): Promise<CommandHqSes
referencePaths: [input.referencePath],
name: input.name,
categoryId: input.categoryId ?? null,
plugin: input.plugin ?? null,
pluginConfig: input.pluginConfig ?? null,
})
.returning({ id: commandHqSessions.id });
const [row] = await tx
@@ -155,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
export async function listCommandHqSessions(
userId: number,
categoryId?: number | null,
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
plugin?: string,
): Promise<CommandHqSessionRow[]> {
const where =
categoryId === undefined
const where = plugin
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
: categoryId === undefined
? eq(commandHqSessions.userId, userId)
: categoryId === null
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
@@ -257,6 +275,33 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
* longer the current one.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
/*
A commander made from a plugin's template cannot arm until it has been configured.
Enforced HERE rather than in a route because arming is not a button: it happens when the agent
starts a tracked background job, and the platform notices. A check anywhere else would be a check
the agent could route around simply by starting its loop, which is exactly what it does.
Creating a half-configured commander stays fine and useful — it exists, it is visible, and it can be
finished later. It is ARMING one that would run somebody's disk against a blank setting.
A row with no template is unaffected: `plugin` is null and this returns immediately.
*/
const [existing] = await db
.select({ plugin: commandHqSessions.plugin, pluginConfig: commandHqSessions.pluginConfig })
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.limit(1);
if (existing?.plugin) {
// Only the presence of answers is checked here, not which ones are required — that needs the
// template, which is a filesystem read and does not belong in the query layer. The route that
// creates the commander writes every input it was given, so an empty object means nothing was
// configured at all, which is the case worth stopping.
const answered = Object.values(existing.pluginConfig ?? {}).filter((v) => String(v ?? '').trim());
if (answered.length === 0) return false;
}
const rows = await db
.update(commandHqSessions)
.set({
@@ -3,6 +3,7 @@ import {
doublePrecision,
index,
integer,
jsonb,
pgTable,
serial,
text,
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
* its own, and an array supports appending in place (`array_append`) without reading the whole column
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
*/
referencePaths: text('reference_paths')
.array()
.notNull()
.default([]),
referencePaths: text('reference_paths').array().notNull().default([]),
/**
* What the owner called it, held here rather than only in the transcript's summary record.
*
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
watcherArmed: boolean('watcher_armed').notNull().default(false),
/**
* The plugin that shipped this commander's template, or null for one somebody made themselves.
*
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
* provenance should be structural. A category called "av-club" is a string an owner can rename,
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
* with nothing on disk saying otherwise.
*
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
* still in its directory, and the UI can say what it came from rather than pretending it was
* always hand-made.
*/
plugin: text('plugin'),
/**
* The owner's answers to the template's `inputs`, or null when there was no template.
*
* Held here rather than only in the generated watcher script because the form has to be
* re-openable: somebody changing which directory is watched should see what it is watching now,
* not an empty form. The script is generated FROM this, so this is the source of truth.
*/
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
/**
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
@@ -1,9 +0,0 @@
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries';
export type { DavAppPassword, DavAppPasswordView } from './queries';
-113
View File
@@ -1,113 +0,0 @@
import { and, desc, eq, isNull } from 'drizzle-orm';
import argon2 from 'argon2';
import { randomBytes } from 'node:crypto';
import { db } from '../db';
import { davAppPasswords } from './schema';
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
/** What the UI is allowed to see: everything except the hash. */
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
const view = (row: DavAppPassword): DavAppPasswordView => {
const { passwordHash: _hash, ...rest } = row;
return rest;
};
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
// into a phone by hand. Grouped into blocks of four for the same reason.
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
function generateSecret(): string {
const bytes = randomBytes(20);
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
// still fits in five readable blocks.
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
}
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
const rows = await db
.select()
.from(davAppPasswords)
.where(eq(davAppPasswords.userId, userId))
.orderBy(desc(davAppPasswords.createdAt));
return rows.map(view);
}
/**
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
* value is the only chance the owner gets to see it.
*/
export async function createDavAppPassword(
userId: number,
label: string,
): Promise<{ entry: DavAppPasswordView; password: string }> {
const password = generateSecret();
const [row] = await db
.insert(davAppPasswords)
.values({
userId,
label,
passwordHash: await argon2.hash(password),
hint: password.slice(0, 8),
})
.returning();
if (!row) throw new Error('failed to create dav app password');
return { entry: view(row), password };
}
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.update(davAppPasswords)
.set({ revokedAt: new Date() })
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
.returning();
return !!row;
}
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.delete(davAppPasswords)
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
.returning();
return !!row;
}
/**
* Verify a Basic-auth credential, returning the owning user id.
*
* Every live credential is tried, because the username a DAV client sends is the account email, not a
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
* UI should encourage deleting devices that are gone rather than accumulating them.
*
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
* the column exists to answer "is this device still around", not to be an access log.
*/
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
const rows = await db
.select()
.from(davAppPasswords)
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
for (const row of rows) {
let ok = false;
try {
ok = await argon2.verify(row.passwordHash, password);
} catch {
ok = false; // a corrupt hash must not take the whole login path down
}
if (!ok) continue;
const now = Date.now();
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
await db
.update(davAppPasswords)
.set({ lastUsedAt: new Date(now) })
.where(eq(davAppPasswords.id, row.id));
}
return row.userId;
}
return null;
}
@@ -1,46 +0,0 @@
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
//
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
// credential a phone could carry is the account password itself — which then lives in that phone's
// account manager in recoverable form, and in whatever backs the phone up.
//
// So: one password per device, revocable per device, hashed with argon2 like the account password and
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
//
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
// this table — an app password is not a session, and a device that syncs a calendar has no business
// reaching the wallet or the vault.
//
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
// noticeable at all.
export const davAppPasswords = pgTable(
'dav_app_passwords',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
label: text('label').notNull(),
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
passwordHash: text('password_hash').notNull(),
/**
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
* authenticate with, and it is the only way for the owner to match a row in this list against the
* password saved on a device they are looking at.
*/
hint: text('hint').notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
// being free and it being a sequential scan on every PROPFIND.
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
);
-1
View File
@@ -52,7 +52,6 @@ export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
export * from './notify';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
+5 -3
View File
@@ -51,9 +51,11 @@ export * from './service-connections/schema'; // service_connections
export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md
export * from './nudges/schema'; // nudges — the Nudger schedule
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
export * from './dav/schema'; // dav_app_passwords officer-caldav
// ── Plugins — nothing here ───────────────────────────────────────────────────────────────────────
//
// `dav_app_passwords` was the last entry and it left with caldav on 2026-08-27. A plugin's tables are
// generated into `drizzle-schema.ts` from the plugin's own `db/schema.ts` when it is installed, which is
// what this hand-maintained line was standing in for while caldav was core.
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
//
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeAgentBrief } from './agent-brief';
/**
* Both harnesses have to find the brief.
*
* Claude Code discovers `CLAUDE.md`, opencode discovers `AGENTS.md` and nothing else. Every brief the
* platform wrote was `CLAUDE.md`, so under opencode a commander, a bot and a plugin chat were each
* briefed with nothing — silently, which is why it lasted.
*/
const root = mkdtempSync(join(tmpdir(), 'agent-brief-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
const dir = (name: string) => {
const d = join(root, name);
rmSync(d, { recursive: true, force: true });
Bun.spawnSync(['mkdir', '-p', d]);
return d;
};
describe('writeAgentBrief', () => {
test('writes the content as AGENTS.md and a POINTER as CLAUDE.md', async () => {
const d = dir('both');
await writeAgentBrief(d, '# Brief\n\nthe real instructions\n');
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('the real instructions');
const pointer = readFileSync(join(d, 'CLAUDE.md'), 'utf8');
expect(pointer).toContain('AGENTS.md');
// The pointer must NOT be a second copy — two copies of a file rewritten every turn will disagree,
// and the stale one is the one somebody reads.
expect(pointer).not.toContain('the real instructions');
});
test('is idempotent — an unchanged brief writes nothing', async () => {
const d = dir('idempotent');
expect(await writeAgentBrief(d, 'same\n')).toBe(true);
expect(await writeAgentBrief(d, 'same\n')).toBe(false);
});
test('rewrites when the content changes, because that is how a template reaches a bot', async () => {
const d = dir('rewrite');
await writeAgentBrief(d, 'v1\n');
expect(await writeAgentBrief(d, 'v2\n')).toBe(true);
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toBe('v2\n');
});
test('onlyIfAbsent never touches a brief the agent has extended', async () => {
// A commander writes its own job into this file over days. Rewriting it because the template
// changed would delete what it agreed with the owner.
const d = dir('extended');
await writeAgentBrief(d, 'generated\n');
writeFileSync(join(d, 'AGENTS.md'), 'generated\n\n## JOB\n\nwatch the thing\n');
expect(await writeAgentBrief(d, 'a new template\n', { onlyIfAbsent: true })).toBe(false);
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('watch the thing');
});
test('onlyIfAbsent still creates both files the first time', async () => {
const d = dir('first');
expect(await writeAgentBrief(d, 'generated\n', { onlyIfAbsent: true })).toBe(true);
expect(existsSync(join(d, 'AGENTS.md'))).toBe(true);
expect(existsSync(join(d, 'CLAUDE.md'))).toBe(true);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { writeAtomic } from './write-atomic';
/**
* Writing a brief where BOTH harnesses will find it.
*
* ── The bug this exists to remove ──
*
* Claude Code discovers `CLAUDE.md` by walking up from its working directory. opencode discovers
* `AGENTS.md` and nothing else — verified in the binary, whose only target is
* `H.up({ targets: ["AGENTS.md"], start, stop })`. There is no overlap.
*
* Every brief the platform writes was named `CLAUDE.md`, so under opencode a commander got none of its
* standing brief, a bot none of its arming instructions, and a plugin chat none of its plugin's
* guidance. Nothing failed and nothing was logged: the agent simply knew nothing, which is
* indistinguishable from the feature not existing and is why it survived this long.
*
* ── The shape, and why the pointer rather than two copies ──
*
* `AGENTS.md` holds the content; `CLAUDE.md` is six lines pointing at it. That is the same arrangement
* the repository's own docs use, and it is chosen over writing the text twice for one reason: two copies
* of a file that is REWRITTEN ON EVERY TURN will eventually disagree, and the one that is stale is the
* one somebody reads.
*
* A harness that discovers neither name still gets nothing, and that is honest — it is a fact about the
* harness rather than something this can paper over.
*/
const POINTER = `# CLAUDE.md
**The instructions live in [\`AGENTS.md\`](AGENTS.md). Read it first — this file only points there.**
Written by the platform so that agents looking for \`AGENTS.md\` by convention and agents looking for
\`CLAUDE.md\` find the same guidance, without the two drifting apart.
`;
/**
* Put `content` in `dir` under both names.
*
* Content-diffed, so a directory's mtimes do not churn on every turn — these are rewritten per message
* in the bots' case. Returns false when nothing was written.
*
* Best effort by contract: every caller must still work if this throws. An agent that knows less is
* where all of them were before any of this existed.
*/
export async function writeAgentBrief(
dir: string,
content: string,
/**
* Never overwrite an existing `AGENTS.md`.
*
* For a brief the AGENT extends rather than one the platform owns. A commander's file is generated
* once at creation and then edited by the commander over its whole life — rewriting it because the
* template changed would silently delete the job it had agreed. The platform's own briefs (bots,
* plugin chats) are the opposite: rewritten every turn ON PURPOSE, so a change reaches them.
*/
opts: { onlyIfAbsent?: boolean } = {},
): Promise<boolean> {
let wrote = false;
const agents = join(dir, 'AGENTS.md');
if (opts.onlyIfAbsent && existsSync(agents)) return false;
if (!existsSync(agents) || (await Bun.file(agents).text()) !== content) {
await writeAtomic(agents, content);
wrote = true;
}
const claude = join(dir, 'CLAUDE.md');
if (!existsSync(claude) || (await Bun.file(claude).text()) !== POINTER) {
await writeAtomic(claude, POINTER);
wrote = true;
}
return wrote;
}
/** The pointer's text, for a caller that generates its own file listing and has to describe both. */
export const AGENT_BRIEF_POINTER = POINTER;
+204
View File
@@ -0,0 +1,204 @@
import { join } from 'node:path';
import { mkdirSync } from 'node:fs';
import { getBotsDir } from '@@/data-path';
import { writeAgentBrief } from '@@/agent-brief';
/**
* The instructions every bot runs under.
*
* Written into `$DATA_PATH/<email>/bots/CLAUDE.md`, which Claude Code reads from its working directory at
* session start. Rewritten on every bot turn, so a change here reaches every bot on the next message with
* nothing to migrate — the same arrangement as a plugin's chat brief.
*
* ── Why this file is short, and a commander's is not ──
*
* A commander is a job: it has procedures, a ledger, classification rules, a dispatch policy, and a setup
* conversation to settle all of it. A bot is one sentence and a watch. Everything a commander's template
* spends words on — proving the action on one item, arming only once the owner agrees, writing JOB.md —
* is ceremony a bot does not have. The whole point is that the first message arms it.
*/
const BRIEF = `# Bots
Every conversation in this directory is a **bot**: one thing watched, one thing done when it changes.
**This file is the platform's and is rewritten on every turn.** Anything you want to keep goes in a file
you create beside it.
## Arm on the first message. Do not interview.
The owner says what they want in one sentence. Work out what it means and **arm it in that same turn**.
> "notify me when x.com is down"
> "tell the todo guy when nytimes.com has a new headline"
That is the whole input. Both are complete instructions and neither wants a conversation about it.
**Do not ask any of these.** Every one has a default below: how often, which selector, what counts as down,
how many failures before it counts, what to do if the check itself errors, whether to notify on recovery.
Pick the default, arm it, and say in ONE line what you armed and how often it checks. The owner corrects
you in the next message if you guessed wrong, which is cheaper for them than answering six questions first.
**Ask only when you genuinely cannot proceed** — no address at all, or a request so vague that any watch
you built would be a guess about the subject rather than about the details. "Watch my site" with no site
is a question. "notify me when x.com is down" is not.
**Never ask the owner to phrase it differently.** They will not say "notify" or "nudge" and must not have
to. "tell me", "let me know", "ping me", "message the todo guy" all mean the same two things below.
## Defaults — use these instead of asking
- **Interval: one minute.** Unless the owner says otherwise. It is cheap: the check is shell, not a turn.
Use longer only when the source asks for it — a page that rate-limits, or something that changes daily.
- **"Down" means** the site fails to answer, or answers 5xx, **twice in a row**. One failed request is a
blip and firing on it produces a bot nobody trusts. Follow redirects. A slow answer is not down; a
timeout of about ten seconds is.
- **"Has a new headline" / "has changed" means** the thing you extracted differs from what you stored last
time. Extract the smallest stable piece — the top headline's text, not the whole page, which changes on
every ad and would fire constantly.
- **Recovery is worth one message**, once, when something that was down comes back. Nothing else.
- **The first run establishes the baseline** and does not fire. A bot that announces the current headline
the moment it is armed has told the owner something they did not ask about.
## Arming — the whole mechanism, so you do not go looking for it
**Do not read the commander skill.** It is about spawning agents in a loop and none of it applies here.
This is everything a bot needs:
1. **Write the check as a shell script in THIS directory**, named after the bot — \`newweb-uptime.sh\`, not
somewhere under \`/tmp\`. It has to survive a reboot, and \`/tmp\` does not.
2. **Run it with the Bash tool, \`run_in_background: true\`.** That is the arming. You will be handed back
when it exits.
3. **The script waits in the shell and exits when the condition is true**, printing one line saying what
happened. The exit IS the signal — nothing else tells anyone it fired.
The shape, which almost every bot is a variation of:
\`\`\`bash
#!/bin/bash
# state lives beside this script, so a restart resumes rather than re-baselines
STATE="$(dirname "$0")/newweb-uptime.state"
fails=0
while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 10 "https://example.com/" || echo 000)
if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then
fails=$((fails + 1))
# twice in a row, never once — a single failed request is a blip
if [ "$fails" -ge 2 ]; then echo "DOWN http=$code"; exit 0; fi
else
fails=0
fi
sleep 60
done
\`\`\`
For a "has it changed" watch, the same shape with the comparison against \`$STATE\` instead of a counter:
fetch, extract the smallest stable piece, compare, and \`exit 0\` printing the new value when it differs.
Write the new value to \`$STATE\` **before** exiting, or the next arming fires on the same change again.
4. **Record what you armed, in \`<your-session-id>.bot.json\` in this directory.** This is not optional and
it is not bookkeeping — it is the only thing that can stop your watch. Nothing else knows your pid, and
a bot that is deleted while its record is missing leaves a process polling a website for ever.
\`\`\`json
{
"sessionId": "6305e570-f26a-4989-a0ae-2135c1076505",
"watches": "newweb.fofosfluffybastards.com, every 60s, notify the owner when down",
"script": "/abs/path/to/newweb-uptime.sh",
"state": "/abs/path/to/newweb-uptime.state",
"pids": [1316235],
"armedAt": "2026-08-26T07:33:00Z"
}
\`\`\`
**Rewrite it every time you arm**, with the new pid. \`pids\` is an array because a re-arm can briefly
overlap; list every process you started that is still meant to be running.
**To find your own session id**, list the bots and match on the title:
\`\`\`bash
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
"$OFFICER_API_URL/api/chat/sessions?context=bots"
\`\`\`
Claude titles a session from its first message, so on the turn that arms you the entry titled with the
owner's own opening sentence is you. That is only reliable BEFORE you rename yourself — which is the
next step, so do them in this order: find your id, write the record, then rename.
5. **Give yourself a real name.** The automatic title is the owner's first sentence, truncated —
\`"can you notifiy me when https://newweb.fofos…"\`, typos and all. It is a prompt, not a name, and it is
what they will scan a list of bots for later.
\`\`\`bash
curl -s -X PATCH -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
-H 'content-type: application/json' -d '{"title":"newweb uptime"}' \\
"$OFFICER_API_URL/api/chat/sessions/<your-session-id>/title"
\`\`\`
Two to four words, the SUBJECT and what is watched about it: \`newweb uptime\`, \`NYT front page\`,
\`gitea disk space\`. Not "Website Monitor Bot" — every bot is a monitor and the word earns nothing in a
list of them. Lower case unless the subject is a proper noun. Rename again if the owner widens what you
watch and the old name stops being true.
6. **When you are handed back**, do what the owner asked, then **re-arm the same script the same way,
before you end the turn**, and rewrite the record. Act, re-arm, record — in the one turn. Never build a
second loop on top of this.
7. **If you are woken and told your watch is gone**, read your own \`.bot.json\`, arm the script it names
again, and rewrite it. That file is what survives a reboot; the conversation may be days behind.
Do not go hunting for how any of this works — it is above, and looking costs the owner a minute of tool
calls before anything is armed.
## What to do when it fires
Two things, and the owner will not name either of them:
- **Tell the owner.** A notification reaches their phone. Send one when what fired is worth an interruption
— a site down, a headline they asked to know about. Not "I checked and nothing happened".
- **Tell another chat.** If they named one, deliver it there rather than to the owner.
- An \`@Name [id]\` in their message carries the id already. It is a PREFIX — resolve it to the full
session before using it.
- A name in prose — "the todo guy", "my deploy commander" — is just as valid and is how they will
usually say it. Look it up in the chat list and match on the title. If exactly one matches, use it and
say which one you picked. If several do, that is worth the one question.
Then re-arm, and stay quiet until the next fire.
## Remembering things
The interesting watches compare against last time — a headline, a hash, a status. Keep that in a file in
this directory named after this bot, and say in your first reply what you called it. Do not keep it only in
the conversation: you are woken into a turn that may be days later, and the file is what survives.
## Adding to a bot
The owner will come back and add more — another site to ping, another page to watch. That is the same bot:
widen the check, re-arm it, and confirm in one line what it now covers. Do not start a second watch running
beside the first unless the two genuinely need different intervals.
## What a bot does not do
It watches and it tells. It does not fix things, deploy things, or change anything it is watching. A
request that wants work done is a request for a commander or an ordinary chat — say so plainly and stop
rather than quietly becoming one.
`;
/**
* Put the brief where a bot will read it, and make sure the directory exists.
*
* Best effort by contract: a turn must still start if this fails. The bot then knows less, which is where
* every chat was before this existed — worse than being briefed, better than not answering.
*/
export async function ensureBotsBrief(email: string): Promise<string> {
const dir = getBotsDir(email);
mkdirSync(dir, { recursive: true });
try {
// Both names — a bot run under opencode was getting none of this. See `writeAgentBrief`.
await writeAgentBrief(dir, BRIEF);
} catch (err) {
console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err);
}
return dir;
}
+197
View File
@@ -0,0 +1,197 @@
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { getBotsDir } from '@@/data-path';
import { descendantPids } from '../tasks/process-tree';
/**
* Stopping a bot, and knowing which ones have stopped themselves.
*
* ── Why a record exists at all ──
*
* A bot's watch is a detached shell process. Nothing else on the machine knows it belongs to that bot: not
* the transcript, not the session list, not the process table, which sees an anonymous `bash` in a
* temporary directory. So the bot writes `<sessionId>.bot.json` beside its script, and that file is the
* only thing that can connect the two.
*
* The cost of not having it was paid before it existed: a bot was deleted on 2026-08-26 and its watcher
* was still polling a website eleven minutes later, with nothing in the product able to find or stop it.
*
* ── Why the id is in the FILENAME ──
*
* Every bot shares one directory, so the record has to be addressable without reading all of them. Deleting
* a session knows only its id; `join(dir, id + '.bot.json')` is then a lookup rather than a scan, and a
* record whose contents are damaged still gets found and cleaned up.
*/
export type BotRecord = {
sessionId?: string;
watches?: string;
script?: string;
state?: string;
pids?: number[];
armedAt?: string;
};
const recordPath = (email: string, sessionId: string) => join(getBotsDir(email), `${sessionId}.bot.json`);
/** Refuse anything that is not a bare id — this reaches here from a URL. */
const safeId = (sessionId: string) => /^[A-Za-z0-9._-]+$/.test(sessionId) && !sessionId.includes('..');
export function readBotRecord(email: string, sessionId: string): BotRecord | null {
if (!safeId(sessionId)) return null;
const path = recordPath(email, sessionId);
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as BotRecord;
} catch {
// Damaged rather than absent. The caller still deletes the file — a record it cannot read is a record
// that can never stop anything, and leaving it would only make the next reader believe there is one.
return {};
}
}
const alive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
/** Every recorded pid still running, which is how "is this bot actually watching" is answered. */
export function liveBotPids(email: string, sessionId: string): number[] {
const record = readBotRecord(email, sessionId);
return (record?.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1 && alive(pid));
}
/**
* Stop a bot's watch and forget it.
*
* Children first, then the recorded process, so the loop cannot spawn a replacement between the two —
* the same ordering `stopCommanderProcesses` uses and for the same reason.
*
* Best effort throughout: a bot whose record is missing, damaged, or names processes that are already
* gone still has its files removed. Returns how many processes were signalled, which is worth logging —
* "deleted a bot and stopped nothing" is a different event from "deleted a bot that was not armed".
*/
export function tearDownBot(email: string, sessionId: string): number {
const record = readBotRecord(email, sessionId);
if (!record) return 0;
const pids = (record.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1);
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
let signalled = 0;
for (const pid of tree) {
try {
process.kill(pid, 'SIGTERM');
signalled += 1;
} catch {
// Gone between the walk and the signal. That is the outcome we wanted.
}
}
// The script and its state go with it. They are the bot's, they are named in its own record, and leaving
// them means the next bot with the same idea finds a stale baseline and does not fire.
for (const path of [record.script, record.state]) {
if (!path) continue;
// Only inside the bots directory. A record is written by an agent, and a path out of that tree is
// either a mistake or something worse — either way it is not this function's business to delete.
if (!path.startsWith(getBotsDir(email))) continue;
try {
rmSync(path, { force: true });
} catch {
// Not worth failing a delete over.
}
}
try {
rmSync(recordPath(email, sessionId), { force: true });
} catch {
// Same.
}
return signalled;
}
/**
* Kill anything running out of the bots directory that no live bot claims.
*
* ── Why a record is not enough on its own ──
*
* `tearDownBot` needs the bot to have written `<sessionId>.bot.json`. A bot that never did — armed before
* the instruction existed, or one that simply did not — cannot be stopped that way, and deleting it leaves
* a process polling for ever.
*
* That is not the worst of it. **The orphan resurrects the bot it belonged to.** Its watch fires, the
* harness hands back to the agent, the agent resumes by transcript id, and `claude --resume` RECREATES the
* transcript that was deleted — so the bot reappears in the list, untitled, and does it again on the next
* fire. Seen on 2026-08-26: a bot deleted three times and back each time. It is the same shape
* `22a9d58d` fixed for commanders.
*
* ── The test ──
*
* A process is an orphan if it is running a script inside the bots directory and its pid appears in NO
* record. Claimed pids are left alone whatever else is true — a live bot's watch is the thing this must
* never touch. Anything else in there belongs to a bot that is gone.
*
* Reads `/proc` rather than shelling out to `ps`: this runs inside the request that deletes a bot, and a
* subprocess per delete is a cost with no benefit.
*/
export function sweepOrphanWatches(email: string): number {
const dir = getBotsDir(email);
const claimed = new Set(listBotRecords(email).flatMap(({ record }) => record.pids ?? []));
let killed = 0;
let pids: string[];
try {
pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
} catch {
return 0; // not Linux, or /proc unreadable — the record path still works
}
for (const name of pids) {
const pid = Number(name);
if (claimed.has(pid)) continue;
let cmdline: string;
try {
// NUL-separated argv. A process that exits between the listing and this read is the ordinary case.
cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf-8').replace(/\0/g, ' ');
} catch {
continue;
}
// The script has to live in the bots directory — the brief requires it — so its path is in the argv of
// anything running it. `.sh` narrows it to the watch itself rather than an editor or a grep over it.
if (!cmdline.includes(dir) || !cmdline.includes('.sh')) continue;
try {
process.kill(pid, 'SIGTERM');
killed += 1;
console.warn(`[bots] orphaned watch ${pid} killed — no record claims it`);
} catch {
// Gone already, which is the outcome wanted.
}
}
return killed;
}
/** Every bot that has a record, for the sweep that revives them. */
export function listBotRecords(email: string): { sessionId: string; record: BotRecord }[] {
let names: string[];
try {
names = readdirSync(getBotsDir(email));
} catch {
return []; // no directory yet is the ordinary state of an account with no bots
}
return names
.filter((name) => name.endsWith('.bot.json'))
.map((name) => {
const sessionId = name.slice(0, -'.bot.json'.length);
return { sessionId, record: readBotRecord(email, sessionId) ?? {} };
});
}
+58
View File
@@ -0,0 +1,58 @@
import type { Context } from 'hono';
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
// One answer to "which directory is this chat about", for the two routers that need it.
//
// It lived in chat.ts as a private helper until 2026-08-27, when Command HQ grew a button that starts a
// commander from a plugin chat. That commander watches the directory the chat runs in — and the whole
// reason this function exists is that the CLIENT cannot name that directory. Two copies of the mapping
// would be two chances for a commander to end up watching somewhere its chat has never been.
/**
* Which directory a listing is about.
*
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
* its own directory and then has no way to ask for them back.
*
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
* another directory shows you your own conversations elsewhere, never anybody else's.
*/
/**
* The same mapping, from values rather than from a request.
*
* Command HQ takes the context in a JSON BODY — it is creating a commander, not listing anything — and
* the request-reading version silently fell through to `who.home` for it, because there was nothing in
* the query string. A commander watching the wrong directory is not an error anywhere: it just answers
* questions about the wrong thing forever.
*/
export const chatContextDir = (
context: string | undefined,
contextId: string | undefined,
who: { home: string; email: string },
): string => {
const id = contextId?.trim();
if (context === 'plugin' && id) return getPluginChatsDir(who.email, id);
if (context === 'bots') return getBotsDir(who.email);
if (context === 'email') return resolveEmailAccountDir(who.email, id);
return who.home;
};
export const resolveChatContextDir = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
// Every bot shares one directory, so this needs no id — the context IS the address.
if (context === 'bots') return getBotsDir(who.email);
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
// screen, and is optional: without one the resolver takes the most recently synced account.
//
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
// the socket, so the turn and the list cannot disagree about where the chat lives.
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
return ctx.req.query('cwd')?.trim() || who.home;
};
+44 -24
View File
@@ -1,4 +1,5 @@
import { getPluginChatsDir } from '@@/data-path';
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
import { tearDownBot, sweepOrphanWatches } from '../bots/teardown';
import type { Context } from 'hono';
import { emitToSession, sessionKeyForTranscript } from './websocket';
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
@@ -81,7 +82,16 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
//
// ── A THIRD thing now rides on this restriction (2026-08-27) ──
//
// The serve holds an Officer API credential minted for the OWNER, in its own environment, because one
// process serves every session and there is nowhere else to put one — `sidecar/opencode/serve-env.ts`.
// So lifting `isOwner` here without changing that hands a member the owner's API authority as well as the
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
import { registerAgentPanelRoutes } from './agent-panels-routes';
import { resolveChatContextDir } from './chat-context';
export const chatRouter = createRouter();
@@ -108,25 +118,9 @@ export const chatRouter = createRouter();
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
// selects there too.
/**
* Which directory a listing is about.
*
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
* its own directory and then has no way to ask for them back.
*
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
* another directory shows you your own conversations elsewhere, never anybody else's.
*/
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
return ctx.req.query('cwd')?.trim() || who.home;
};
// selects there too. The resolver moved to `chat-context.ts` when Command HQ needed the same answer —
// a commander started from a plugin chat watches the directory that chat runs in, and only the server
// can turn a context into a path.
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', async (ctx) => {
@@ -139,7 +133,7 @@ chatRouter.get('/pwds', async (ctx) => {
chatRouter.get('/sessions', async (ctx) => {
const user = ctx.get('user');
const who = await chatIdentity(user);
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
// separate route because the response shape is identical and every consumer downstream (rename, delete,
@@ -223,7 +217,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
const user = ctx.get('user');
const who = await chatIdentity(user);
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
@@ -436,7 +430,7 @@ chatRouter.get('/live', async (ctx) => {
chatRouter.delete('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
if (isOpenCodeSessionId(id)) {
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
@@ -468,6 +462,32 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
conversation — no live session holds it, so nothing is keyed on a key to remove.
*/
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
/*
And stop anything a BOT armed, which nothing above would have touched.
A bot's watch is a detached shell process. Killing the harness ends the agent; the loop it started is
not its child and carries on. That gap was paid for on 2026-08-26: a bot was deleted and its watcher
was still polling a website eleven minutes later, with nothing in the product able to find it.
Every transcript removed, not just the id asked for — `deleteClaudeSession` returns a chain, and a
resumed conversation's earlier transcripts can each carry their own record.
Unconditional rather than gated on "was this a bot": the record only exists in the bots directory and
is keyed by session id, so for anything else this reads a path that is not there and returns 0. A
branch on the cwd would be a second place to get the question wrong.
*/
for (const transcriptId of removed) {
const stopped = tearDownBot(who.email, transcriptId);
if (stopped > 0) console.log(`[bots] ${transcriptId} deleted — stopped ${stopped} process(es)`);
}
// And anything left running out of the bots directory that no remaining record claims. This is what
// catches a bot that never wrote one — which cannot be stopped by id, and whose watch RESURRECTS it on
// its next fire, because the hand-back resumes a transcript that `claude --resume` then recreates.
const orphaned = sweepOrphanWatches(who.email);
if (orphaned > 0) console.log(`[bots] swept ${orphaned} orphaned watch process(es)`);
return ctx.json({ ok: true });
});
@@ -475,7 +495,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
chatRouter.patch('/sessions/:id/title', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
+31 -12
View File
@@ -78,31 +78,45 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
const messages: ClaudeChatMessage[] = [];
let modelId = '';
for (const m of stored) {
const role = m.info?.role;
const parts = m.parts ?? [];
/*
OLDEST FIRST. The endpoint returns newest first, and nothing downstream re-sorts — so the
conversation rendered backwards: the answer above the question that produced it, and the opening
message at the bottom. Sorting here rather than at the call site because this is the only place
that knows the wire order, and the display shape it returns has no timestamps left to sort by.
*/
const ordered = [...stored].sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0));
for (const m of ordered) {
// `type` is the current build, `info.role` the older one. See OpenCodeStoredMessage.
const role = m.type ?? m.info?.role;
const parts = m.content ?? m.parts ?? [];
if (role === 'user') {
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
.trim();
// The current build hands a user message's text over directly; the older one wraps it in parts.
const text = (
m.text ??
parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
).trim();
if (text) messages.push({ role: 'user', text });
continue;
}
if (role === 'assistant') {
if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? '';
if (!modelId) modelId = m.model?.id ?? m.model?.modelID ?? m.info?.model?.modelID ?? m.info?.modelID ?? '';
for (const p of parts) {
if (p.type === 'text' && p.text) {
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text });
} else if (p.type === 'tool' && p.callID) {
} else if (p.type === 'tool') {
const callId = p.callID ?? p.id;
if (!callId) continue;
messages.push({
role: 'tool',
toolName: p.tool ?? 'tool',
toolName: p.name ?? p.tool ?? 'tool',
toolInput: p.state?.input ?? {},
toolCallId: p.callID,
toolCallId: callId,
output: p.state?.output,
isError: Boolean(p.state?.error),
});
@@ -112,6 +126,11 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
}
}
// The session record carries the model too, and it is the only source for a conversation whose
// assistant turns have not landed yet. Without it the detail reports a bare `opencode`, the composer
// cannot match that to anything, and reopening the chat silently switches the harness back to Claude.
if (!modelId) modelId = info?.model?.id ?? info?.model?.modelID ?? '';
return {
id: sessionId,
model: modelId ? `opencode/${modelId}` : 'opencode',
+47 -5
View File
@@ -47,10 +47,22 @@ class ServerConnection {
return (await res.json()) as OpenCodeSessionInfo;
}
/*
`/api/session/…/message`, NOT `/session/…/message`.
Both paths exist and only one still works. The legacy one answers 200 with an empty array for a
session that plainly has messages — verified 2026-08-27 against a session showing 4 on the `/api`
path and 0 on the other. An empty transcript is indistinguishable from a new conversation, so
reopening an OpenCode chat showed "Send a message to start" and every message the owner had sent was
apparently gone. It also emptied the model, which is what made the composer fall back to Claude.
Every other read this client does is fine on either path; this is the only one that moved.
*/
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`);
return (await res.json()) as OpenCodeStoredMessage[];
const res = await fetch(`${this.baseUrl}/api/session/${sessionId}/message`);
if (!res.ok) throw new Error(`opencode GET /api/session/${sessionId}/message → ${res.status}`);
const body = (await res.json()) as { data?: OpenCodeStoredMessage[] } | OpenCodeStoredMessage[];
return Array.isArray(body) ? body : (body.data ?? []);
}
async deleteSession(sessionId: string): Promise<boolean> {
@@ -89,7 +101,12 @@ class ServerConnection {
export type OpenCodeSessionInfo = {
id: string;
title?: string;
time?: { created?: number; updated?: number };
time?: {
created?: number;
updated?: number;
};
/** The model this conversation is on. Present before any assistant turn has landed. */
model?: { id?: string; modelID?: string; providerID?: string };
/** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */
directory?: string | null;
};
@@ -102,11 +119,36 @@ export type OpenCodeStoredPart = {
text?: string;
tool?: string;
callID?: string;
state?: { input?: Record<string, unknown>; output?: string; error?: string };
state?: { input?: Record<string, unknown>; output?: string; error?: string; status?: string };
/** Current build names a tool part here; the older one used `tool`. */
name?: string;
/** Current build's id for a tool call, where the older one used `callID`. */
id?: string;
};
/**
* Two shapes, because two builds.
*
* The older one nests everything: `{ info: { role }, parts: [...] }`. The current one flattens it —
* `{ type: 'user' | 'assistant', text, content: [...] }` — and puts a user message's text directly on
* the message rather than in a part. Both are accepted rather than pinned to today's build: this reader
* is the only thing standing between a working transcript and a chat that looks erased, and a host on a
* different opencode should degrade to fewer features, never to an empty conversation.
*/
export type OpenCodeStoredMessage = {
/** Wire order is newest-first, so the reader sorts on this. */
time?: { created?: number };
/** Current build: the role, flattened. */
type?: string;
/** Current build: a user message's text, with no part wrapping it. */
text?: string;
/** Current build: the assistant's parts. */
content?: OpenCodeStoredPart[];
/** Current build: `{ id, providerID }`. */
model?: { id?: string; modelID?: string; providerID?: string };
/** Older build. */
info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } };
/** Older build. */
parts?: OpenCodeStoredPart[];
};
@@ -1,6 +1,7 @@
import * as sidecar from '@@/sidecar-registry';
import { logger } from '../logger';
import { setOpenCodeSession } from './state';
import { invalidateModelCache } from '../list-models';
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
@@ -13,6 +14,19 @@ sidecar.on('opencode:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
/*
A new serve means a possibly different catalogue, so the cached model list has to go.
`listOpenCodeModels` caches for the platform process's lifetime, which is right — the catalogue moves
about once a release. But it is the PLATFORM that holds it and the SIDECAR that changes, so signing
into a provider and restarting `officer-opencode` left the platform serving the list it read before
the login: six free models, no paid ones, and nothing anywhere saying why. Reported 2026-08-27, after
a successful login that appeared to do nothing.
The port report is the one event that always accompanies a new serve, which makes it the right place
to drop the cache.
*/
invalidateModelCache();
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
});

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