A sidecar already gets a port and nothing here changes that: it binds 0, the
kernel picks, it announces the number. That is right for what it is - a private
detail between two processes, loopback, re-chosen every restart, and nothing
outside Officer needs it.
A port something ELSE connects to is a different thing. A plugin serving a
website has to be reachable from nginx, which means the number must survive a
restart and be knowable by whoever writes the proxy configuration. Ephemeral
fails both, which is why the first site plugin was going to hardcode 3011 and
hope.
So: `extraPorts: [{ name: 'site', preferred: 3011 }]` in a manifest, allocated at
install, persisted in plugin_ports, and delivered as PORT_SITE in the sidecar's
pm2 environment. Environment rather than the registration socket because a socket
answer arrives after the process has already had to decide what to bind, and pm2
is the only thing that can tell a process something before it starts.
What the platform guarantees is the part per-plugin config could never do: no two
plugins are ever handed the same number. That is a unique index rather than a
convention, because a collision is not a duplicate row - it is two services
fighting over a listener where the loser fails to bind and the winner is whoever
started first.
`preferred` is a wish, not a claim. A plugin usually arrives after the thing that
connects to it, and moving that configuration is work the platform can spare
somebody; but a preference that could fail an install would be a claim wearing a
softer word.
Allocations survive uninstall and are released only by purge. The number is
written down where this database cannot see it, so handing it to a different
plugin because this one was briefly removed points that configuration at the
wrong service.
Pm2App gained `env`, emitted as JSON rather than hand-quoted: a port is a number
today, but this is the seam where a path arrives eventually, and one apostrophe
in a value produces a file PM2 cannot parse - which takes every process on the
machine down, not just the plugin's.
Verified: stable across calls, distinct across plugins, released cleanly,
honours a preference, and a plugin declaring no ports gets no env key at all.
issue #55 and PR #56 both closed unmerged. two reasons, and the first
one is ours: the PR wired one of three clipboard writes and left
Inspector and PromptPanel writing directly, so it added a public surface
without delivering the thing it was for. the issue text named all three
sites and the PR wired one.
the second is theirs and fair — embedding is not on their roadmap and it
is a single-user need so far.
what survives is worth more than the merge would have been: they said
the adapter design is sound and they would start from it, and named what
a complete version needs — the Canvas extraction plus every export path.
that is a specification for the fork.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
not decided. the card carries numbers so the decision is not made from a
readme — which is what produced the first, wrong estimate.
the finding: next.js is not actually there. one import in 14,822 lines
and it is type-only, so there is nothing to strip. the fork line then
falls on a file boundary — page.tsx and lib/ai.ts are ours (~3,600
lines), the rest vendors (~11,200), and the split is meaningful rather
than convenient. buildPrompt/effectivePrompt are pure functions, so the
export point is a call rather than a bridge.
against it: 37 commits, three days old, 22 of them landed today. two
authors. 'vendor and track upstream' assumes an upstream that has
settled and this one has not started.
so the cheapest next step is an issue asking upstream for an explicit
seam — depend rather than fork. the answer changes what gets built.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An image goes into the message as base64 and STAYS there: every later turn re-sends every earlier
picture. So a screenshot is not paid for once, it is paid for on every turn for the rest of the
session.
Past 1568px on the long edge the model never sees the extra pixels — Anthropic downscales server-side
before it looks — so the width was carried up the wire, counted, and thrown away. Measured on one day
of one session: 25 screenshots, 10 over the line, 53,300 tokens of image data, 23% of it buying
nothing.
The ORIGINAL is still uploaded and still lands on disk untouched, so an agent that needs the detail can
Read it at full size. That is the right split: the conversation carries what is worth re-sending every
turn, the disk keeps what was captured.
Not re-encoded when it is already under the ceiling — a PNG round-tripped through a canvas is often
bigger than the one the screenshotter wrote.
This is a cost and size fix and is NOT confirmed as the cause of the "image too large" errors. What
was ruled out: no image today was near a per-image limit (largest 0.66 MB, 1662x2044), and the two
that failed were 679x869 and 1464x1407 — small. The remaining suspect is cumulative, which this
reduces without proving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three mechanisms where there should be one. The catalogue was filled by hand, and each host separately
polled twenty-one repositories with git to work out whether a release existed — a second source of
truth for a fact one place already knew. Shipping photos 2026.09.03.02 correctly and watching alpha
not notice for ten minutes is what started this.
The timer. scripts/marketplace-cron.sh, every minute, on the machine that publishes. Safe now only
because the previous commit made the sync read tags instead of working trees; over the old one this
would have published somebody's afternoon on a schedule. Nothing is written unless the release commit
differs from the one already published, so a tick with nothing to do is one query, one fetch per repo
and no writes — about 2.7s. One timestamped summary line per tick regardless, because a fresh timestamp
is the only thing that separates "nothing to publish" from "the timer died"; a named line only for a
plugin that moved.
The badge. `updateable` now compares the tag at HEAD with the tag the catalogue says is newest —
difference, not ordering, since parsing 2026.08.27.01 works right up until somebody tags v2 and then
fails silently. refreshRemote() and its two-minute window are gone with the polling they existed to
paper over. The git rule stays as the fallback for a plugin the store cannot speak for: an unregistered
checkout, or an unreachable store. That case now reads whatever refs the last pull left, which is the
accepted cost of not making every machine poll every repository forever.
fetchCatalogue()'s cache went from five minutes to one. It only drew tiles before; now the wait for a
release to appear would have been the sync's minute plus its five.
Also: the Update button names its destination whenever a release is known, not only on `new-release`.
An untagged checkout reports `untagged` and used to lose the tag with it, so the button said "Update"
and the owner had to guess where it landed — the marketplace knows, even for a checkout that has never
fetched it.
Verified: forced a stale row, the timer republished it inside 60s and logged one line. Badge decisions
checked against the live catalogue for seven plugins, including the two shapes that matter — at a tag
the store agrees with, and untagged with a release known only to the store. 9 release tests pass, 100%
of release.ts covered, four of them new and all against real repositories.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sync read `platform/plugins/` — the directories this machine has installed — and imported each
manifest off the working tree. So publishing meant "get officer-dev's checkout to the release, then
run the script", and nothing showed when that had not happened. Live proof at the time of writing:
av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26 were all in
the store, and none of the four has ever been tagged.
It is also why the job could not go on a timer. A cron over it published whatever was in a working
tree, on a schedule.
Now every registered plugin is mirrored bare into ~/.cache/officer-marketplace/mirrors and read AT ITS
NEWEST TAG — manifest, README and file list all out of `git cat-file`. No working tree in the path, so
publishing something uncommitted stops being a mistake anyone can make.
docs/marketplace.md proposed reading manifests over Gitea's API, which needed a sandbox or a committed
manifest.json. Git gives the same guarantee with neither, and the trust model is unchanged: we import
our own repositories' code. Third-party submissions still need one of those two.
The list of plugins now comes from the `plugins` registry table, which the docs always called the
authoritative half and which was in fact a by-product of what happened to be installed. That is what
lets `finances` be published without being installed here, and it spans orgs — fofos-admin lives under
fofos/, which an org listing would have missed. Adding one is `--add <clone url>`.
A plugin with no tag is left alone and named in the report, not deleted. It has made no release, so
there is nothing true to say about it; dropping its row would be a decision about somebody's plugin
dressed up as a refresh.
Also: `release_tag` on plugin_metadata, because the tag is the fact when a manifest version disagrees
with it; the sync warns on a mismatch. And icon URLs pin to the tag — `/raw/HEAD/` served whatever was
on the default branch, via a 303, so a plugin could show one icon and ship another.
Verified: 21/25 published from a release, 4 named as untagged, all 14 tag-pinned icons answer 200.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The way this gets used is: ship a plugin, open the page, look for the badge. Ten
minutes of that is indistinguishable from the feature not working — a release
tagged at 12:42 was invisible at 12:45 because the last fetch was at 12:37, which
is correct and no comfort.
Bounded by the page being open: nothing fetches when nobody is looking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/<route>` and `/<route>/:section` are what every plugin has always had, and
fifteen of them read useParams().section. Replacing the pair with a splat took
that parameter away from all of them at once: the screens still mounted and every
one lost its selection, which is worse than a 404 because nothing says so.
The splat stays, for depth. React-router ranks a static or dynamic segment above
it, so a plugin with one segment gets `section` again and only deeper paths fall
through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
releaseState compares the tag at HEAD with the newest tag on origin/<branch> — a
remote-tracking ref, and nothing refreshed it. Tags arrived at clone and at the
fetch pullPlugin does, so you only learned an update existed BY UPDATING.
alpha had photos at 2026.08.27.01, last fetched 27 August, comparing this
morning's release against a snapshot from before it existed. Truthfully at the
latest, and useless.
refreshRemote fetches in the background: the request answers from the refs it has
and the next one is current. Rate-limited per checkout, one at a time, silent on
failure — the list still has a true answer without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the manifest route table. Declaring a plugin's URLs to the platform put
the platform in the middle of a decision that is not its own, and it was more
code than what it replaced.
One splat where there were two routes. A splat matches zero segments, so the
bare route still works, and the plugin reads its own position off the location.
The platform owns where a plugin starts and nothing after it — the same line
mountPrefix already draws around its API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every screen got `/<route>` and `/<route>/:section` and nothing deeper, so a
section had to live in the query string. A screen may now name its routes in
the manifest, and give one its own arrangement of the screen's panels.
Absent, the old pair is unchanged — which is every plugin but fofos-admin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A nested workspace wants the same bargain PluginScreen makes: structure from source every render, only
sizes and zoom persisted. `useDashboardState` was the only export, and it persists the whole tree —
right for a dashboard somebody arranged, wrong for a layout its author ships, because the structure
would freeze at whatever it was the first time a screen was opened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An arrow beside the name, and the target in its tooltip.
`stat` follows a symlink, so the listing has always described what the link POINTS AT and never that
there was a link in the way. That is right for behaviour — a link to a folder must still say
`directory` so opening it works — and it is why `stat` cannot answer this question at all.
`readdir({ withFileTypes: true })` can, without a second syscall per entry: the kind comes off the
directory entry itself. `readlink` is asked only for the entries that are links, which in an ordinary
folder is none of them.
A BROKEN link now gets a row, and did not before. `stat` failed on it, the entry returned null, and it
disappeared from the listing — nothing said it was there, nothing could delete it, and the folder
quietly had fewer things in it than `ls` showed. It is drawn in the destructive colour with its dead
target in the tooltip.
The target is stored exactly as written rather than resolved. `../originals` moves with the folder and
`/home/…/originals` does not, and rewriting one into the other would hide that difference at the
moment somebody is looking in order to understand it.
An arrow, not a badge or a different icon: the icon says what the thing IS, and a symlink to a folder
is still a folder to whoever uses it. What the arrow adds is the indirection — which is what explains
why deleting it freed no space, or why two places show the same file.
Verified against real links: to a file, to a directory, absolute, and broken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, one line apart, both silent.
The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.
Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.
JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing set `color-scheme`, so the browser drew every control it renders itself as if the page were
light. On a dark theme a <select>'s dropdown opened as a white sheet with the page's own pale text on
it — unreadable, and unreachable by any utility class, because that list is painted by the browser and
is not in the document.
`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with no
styleable interior: date and colour pickers, the spinner on a number input, and default scrollbars
where a theme has not replaced them.
The `dark` class decides it, because that is the signal that exists — 203 of the 231 themes set
applyDarkClass, and the provider puts it on <html> beside data-theme, so the two always agree about the
same element. Both are specificity 0-1-0 and themes import after this file, so a theme that disagrees
can say so. This is a default, not a decision taken away.
Explicit colours on select/option/optgroup underneath, because color-scheme gives the browser's generic
dark rather than the theme's. Where a browser honours them — Firefox, Chrome on Linux and Windows — the
list becomes the theme's own popover surface. Where it does not, Safari and Chrome on macOS use a
genuinely native menu and the scheme carries it, which is a great deal better than white on white.
Base-layer rules, so a utility still wins and `bg-transparent` on a select behaves as it did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Responsive images 404'd in every preview, and did it silently.
The attribute rule matches a whole attribute value, so it could never have handled srcset — that is a
comma-separated LIST of "url descriptor" pairs, and prefixing it as one value would mangle the first
descriptor onto the first URL. It did not match srcset at all.
Silently is the operative word. An <img> with a srcset also has a src, and the src WAS prefixed — but a
browser given a valid srcset chooses from it and never looks at src. So the markup read correctly, one
attribute pointed at the platform root, and the image was just missing. Found by a logo that would not
load in the fofos layout preview.
imagesrcset is rewritten too. That is the one on <link rel="preload" as="image">, easy to miss and
expensive to miss: a preload that 404s costs a request and leaves the real image undiscovered until the
body is parsed, which is the opposite of why anybody adds one.
Six tests, including the case that found it — src prefixed, srcset not, image missing — and a
protocol-relative candidate left alone, which is the rule everywhere else in this file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous fix put `min-w-0` on the scroll container, which was one level too low to stop anything.
DialogContent is a grid; the element that expands is its DIRECT child, and between the two sat a
`flex flex-col gap-3` wrapper with no constraint at all. It grew to fit the title and took the card out
of the dialog with it, exactly as before.
Found by mapping the element chain from DialogContent down rather than reasoning about which element
"should" be the one — the wrapper does not appear in any of the code you read while thinking about the
card, which is why two attempts missed it.
Also confirmed the previous fix was genuinely live before blaming it: the served chunk on fofos carried
the new class and not the old one. The code was right and the diagnosis was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A long video title pushed the modal wider than itself and ran out of the card. The title had `truncate`
and its parents had `min-w-0 flex-1`, and none of it bit.
DialogContent is a GRID. A grid item defaults to `min-width: auto` — it refuses to shrink below its
content — so the list container expanded past the dialog's own `max-w-lg`, the card grew with it, and
`truncate` never had a width to truncate against. Every constraint below that point was measured against
a box that had already given up.
`82d24a8f` made the card a column, which was a real fix for a real half of this: the title, thumbnail
and button used to share a line and the title won. But a column still cannot be narrower than the
widest thing the grid above it will allow, so the fix held only until a title long enough came along.
Worth knowing beyond this dialog: every DialogContent in the app is that same grid, so any dialog
holding unbreakable content has the same trap waiting. Fixed here rather than in the shared component,
because changing the layout of every dialog in the platform to fix one modal is not a change to make
without asking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One commit — 537cc29c, fixing what the 2026.08.30.01 rollout found on fofos: a plugin declaring
`@tiptap/pm` was reported as a failed install. Nothing was broken on disk. That package has no root
entry point by design — it is tiptap's single pinned copy of ProseMirror and you import
`@tiptap/pm/state`, never the bare name — so the checker asked a question it could not answer.
Worth its own release rather than waiting: `bun run plugins:update` exits 1 on fofos every time until
this lands, so the plugin step of every future deploy fails there and reads as a new problem each time.
No schema change and no dependency change, with the two commands that prove it in the plan. docs:check
green. Only fofos has fofos-admin installed, so everywhere else this is a no-op in behaviour.
── Why .03 ──
2026.08.30.02 was tagged against a commit that was not on master: the rollout's own report had just been
pushed and the release was cut without pulling first. The branch push was rejected and the tag was not,
so a tag pointing off-branch existed for about ten seconds. The watcher claimed the version inside that
window, and a claim is permanent by design — so .02 is spent and carries a claim with no .done, which is
what "attempted and did not succeed" should look like.
No host moved. Every one stayed on 816f8695 throughout.
The lesson is in the ordering, and it is now the practice: pull, then push the branch, then tag only
once the branch has landed. Pushing both in one command means a rejected branch still publishes a tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fofos-admin failed its resolvability check on fofos — @tiptap/pm has no root
entry point, so the install was perfect and the checker was wrong. Fixed by
537cc29c, which is one commit past this tag and therefore deployed nowhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every declared package. That is
the right question for almost all of them and unanswerable for a few.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no "." — it exists only as
subpaths. You import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name Bun
correctly says it cannot, and this read that as a failed install.
It cost a deploy. fofos-admin declares `@tiptap/pm` because tiptap needs it present; the install was
perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin was
reported as failed on every host that has it.
The strong check stays first, because it is the one that catches a package which installed but cannot
be loaded on this machine — `sharp` resolving to a platform-specific binary is the shape it was written
for. Only a package that fails it is asked the weaker question: is it there. Present-but-unimportable
still fails, since it never reaches the second line.
Verified on the host rather than reasoned about: of the five packages fofos-admin declares, four
resolve and @tiptap/pm alone does not, and it is present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file browser's clipboard moves to the server, files can be dragged between windows, and plugins
reach further into the file browser.
No schema change and no dependency change — checked rather than assumed, and the plan records the two
commands that prove it. The clipboard needs no migration: it is a key in `user_state`, a JSONB column
that already exists, written through the same route the dock and the view mode already use.
Eleven commits since 2026.08.29.04, one of which is that release's report.
Surveyed all seven target hosts while writing the plan. Every one answered and every one is clean.
`fofos` sits ahead of the previous tag and behind this one — it has been hand-pulled all day while the
fofos plugin work was written against it — and that commit is an ancestor of this tag, so its
fast-forward is ordinary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`loading` swaps the whole grid for a spinner. That is right when arriving somewhere new and wrong when
re-reading a folder already on screen — and every refresh went through the same path: after a paste,
after a delete, and now every time a window comes forward. On a folder of a hundred files that is a
flash and a lost scroll position, to replace information that was already correct.
The spinner is now for a folder that cannot yet be shown: nothing listed, or a different folder than
the one listed. Re-reading the same folder leaves what is there until better information arrives, so
the only visible change is whatever actually changed.
`shownPath` is what separates the two cases. From inside `refresh` they are identical — it is called
the same way by a navigation and by a refresh — and they need opposite treatment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cutting in one window and pasting in another moves the file on disk, and the window it left went on
listing it until somebody pressed refresh. The clipboard was the obvious half of "these two windows
disagree"; the listing is the half that matters more, because a file that is not there any more is a
lie rather than a stale label.
Same trigger as the clipboard, for the same reason: coming back to a window is exactly when its
contents need to be true, and never while nobody is looking at it.
`refresh` is held in a ref because it is rebuilt on every render — in the dependency array it would
tear down and re-add both listeners several times a second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A custom MIME type on the DataTransfer survives between tabs and windows of the SAME browser, which is
the case somebody actually has: two Officer windows side by side, a folder in each. So a row carries
`application/x-officer-files` with its paths, a folder accepts the drop, and the file moves.
What it cannot do is cross different browsers, or exchange with Finder — dragging out to the desktop
needs a Chrome-only API and only works outward. Worth knowing before expecting it.
`Files` is deliberately untouched. That is the type the OS puts on a real file drag and the existing
upload path keys off it; a drag from inside carries only ours, so the two never meet. The container's
drop handler checks for ours FIRST, because a drag from inside has no `Files` and would otherwise fall
through to "read the dropped files", find none, and do nothing at all.
Dragging also writes the clipboard, which is cheap now that it is per-user server state: if a drop
lands somewhere that cannot read the payload, the paths are still held and a paste finishes the job.
The drag is a gesture over the same mechanism rather than a second one beside it.
Dragging one of several selected rows takes the whole selection; dragging an unselected row takes just
that one. Dropping a folder into itself or into its own child is refused here rather than by the
server, and the comparison uses a trailing separator so /a/bc is not read as living inside /a/b.
`onDragOver` calls preventDefault, which is the single line whose absence makes a drop target look
dead with no error anywhere. `dropEffect` is set by the target rather than the source, because only
the target knows whether it can accept — the cursor says "move" over a folder and "no" over a file,
which is the only feedback a drag has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was `useState`, so it existed in one tab and died with it. Copying in one window and pasting in
another — the obvious way to move a file between two folders you can both see — did nothing, and a
reload lost a pending cut with no sign that it had.
`user_state` is a per-user JSONB row that already exists for this kind of thing, and `useUserState`
already reads and writes it through React Query with an optimistic local update. So the clipboard is
one key in it: no new table, no new route, no socket.
Per user rather than per browser, deliberately. Cutting on the desktop and pasting on the phone is the
same gesture across two screens, and there is no reading of "my clipboard" where the two should
disagree.
── Other windows have to notice ──
USER_STATE is held with `staleTime: Infinity`, which is right for a dock layout and wrong for
something another window can change while this one is idle. It is invalidated on `focus` and on
`visibilitychange` — the second because a background tab brought forward by switching tabs fires only
that one in some browsers.
Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into the
other window means focusing it first, so the refresh happens exactly when it is needed and never while
nobody is looking.
── And it says what it is holding ──
An icon and a toast were enough while the clipboard died with the tab: you had just pressed the key,
so you knew what was on it. Now somebody can arrive at a folder with three files pending from
yesterday and paste them without ever having been told. So the toolbar says how many and whether they
will be copied or moved, and offers a way to let go of them. Invisible persistent state is how a file
gets moved by accident.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`run` is an event handler outside React, so it cannot call `useClient` — and a
bare fetch gets a 401, since this API authenticates with a bearer token rather
than a cookie and only the hook holds it.
So the browser hands one over in the context. Without it, the only way to call
Officer's own API from an action was to reimplement the token handling, which is
the kind of thing that works on one page and silently stops when the token
rotates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Create Gallery" was in the bundle and not in the menu. The prop was typed on
both ends and carried by neither: FileItem builds `menuProps` field by field, so
declaring a prop does not make it arrive — and it was not destructured in
FileItem at all, only in the menu component below it.
Two halves of the same mistake, and the symptom is silent in both directions:
the action existed, applied, and was never asked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tasks already reach that menu and are right for "do this to these files" — a
script, run as the owner, reported through the job system, needing no frontend.
They are the wrong shape for anything that continues in the interface. "Create
Gallery" from a folder is not a job to watch; it is a name to type and a screen
to land on, and expressing that as a script would mean a job whose only output is
somewhere to go next.
So a plugin exports `fileActions` from `web/file-actions.ts` and the generated
module collects them. Presence on disk is the declaration, exactly as panels.ts
and layout.ts already are — the manifest says nothing, because an action carries
its own label and decides for itself whether it applies.
They are registered by injection rather than imported by the browser.
Plugins.gen.tsx is generated INTO the app, and the file browser lives in a
workspace package that app depends on; importing it there would point the
dependency backwards and make the package unusable outside Officer's frontend.
The selection is resolved by FileGrid, which has it, and FileItem is handed
ready-made items. An action receives the whole selection when the right-clicked
item is part of one and just that item otherwise — what every file manager does;
acting on a selection the click was not part of is the surprising alternative.
An action whose `appliesTo` throws hides itself rather than breaking the menu for
everything else on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A prop, not a second component. `onPickFile` turns opening a file into handing it
back rather than opening a viewer; folders still navigate, because choosing a
file means getting to it.
Building a picker separately would mean reimplementing a listing, a breadcrumb,
sorting, thumbnails, upload and keyboard handling — all of it free to drift from
the real browser, and none of it better. fofos-admin needs one to choose media
and it is the first of several, so this belongs here rather than in the plugin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installing tiptap for fofos-admin took the whole plugin screen down: "Cannot
read properties of null (reading 'useRef')", both panels, nothing rendering.
Two Reacts. The platform has 19.2.4; `@tiptap/react` declares React as a PEER,
bun installs peers, and 19.2.8 landed in plugins/node_modules where it wins
resolution for everything underneath. React holds its hook dispatcher in module
state, so a second instance is not a duplicated dependency — it is a broken one,
and the error names nothing that would lead anybody here.
Easy to reach by accident, since nobody asks for React. It arrives underneath
something that does.
So platform-owned packages are stripped from the merged list AND deleted after
the install, and the second is the one that does the work: no plugin declares
React, so there is nothing in the list to strip. Deleting afterwards is crude and
deterministic, which is the right trade for a failure whose symptom is every
panel on a screen refusing to render.
Stripped rather than pinned. Matching the platform's version would still be two
copies, and two copies is the entire problem — bun resolves up the tree, so
removing them is what makes a plugin use the one already loaded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
openseo is the first: it fronts something that already ships an MCP server, and
the choice was to reimplement that vocabulary as Officer tools or hand the real
one over. Reimplementing means a second copy that goes stale, and it fails
silently — a tool exists upstream that Officer cannot see. Handing it over costs
one manifest field.
What a wrapper would have bought — authentication, logging, refusal — is already
true of the path, because the agent reaches it through Officer's own API with
Officer's own gate in front. Wrapping would buy it twice.
The manifest declares a PATH and never a URL, and that is the safety property
rather than a convention: mcp-host.json carries OFFICER_AUTH_TOKEN, which signs
as the owner, so a manifest able to name an arbitrary host would be a manifest
able to post the owner's credential anywhere. A path can only reach this machine.
Installed AND enabled, since a disabled plugin's routes are not mounted and
offering the agent a tool list that 404s is worse than offering nothing. A
plugin that cannot be read loses its tools for the session rather than stopping
the agent from starting.
`generateMcpConfig` becomes async because it now asks the database. Awaited, not
fire-and-forget: setMcpConfigPath is what the manager reads when it spawns, so a
config arriving later is an agent running without its plugin tools and no sign
of why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file preview becomes a carousel, zoom starts working again on every plugin
screen, and a platform event bus arrives with thumbnails warmed on arrival.
No schema change and no dependency change — verified against the range rather
than assumed, and the plan says so explicitly so neither step gets improvised.
Six of the seven hosts sit exactly on the previous tag. fofos does not: it has
been hand-pulled all day while this work was written against it, and its commit
is an ancestor of the tag, so it fast-forwards like any other host with fewer
commits to travel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chasing "Minesweeper is laggy" ended somewhere else entirely. Press-to-paint is
29ms median at x11vnc's socket and 29ms through the sidecar relay, but 35-200ms+
in the browser — so every bit of the variance is added by the one hop between,
which is officer.
Officer answering a trivial 401 on /api/health: median 1ms, p90 2ms, max 490ms.
summarizeTranscript full-reads and JSON.parses an entire transcript to build a
session-list row; timed against the largest here (196 MB, 70,137 entries) that
is 323ms to read plus 541ms to parse — 864ms of blocked loop per call. It is
cached on mtime, and a LIVE session appends every few seconds, so the cache
misses exactly when the agent is busy.
Filed rather than fixed: it is a hot path shared by every surface, and the right
shape (incremental parse keyed on size, versus moving summaries off the request
path) is an architecture call. The file already contains the argument for the
incremental version — "Claude only ever appends".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
`useAuthoredLayout` keeps the author's structure and re-applies only what the
user owns. It knew about sizes and nothing else, so `zoom` — which lives on the
panel node — was rebuilt from source on every render and lost.
The failure was invisible rather than merely unsaved. `setValue` re-derived a
tree identical to the one it already held, the no-write guard dropped it as a
no-op, and the header went on reading 100%. Every plugin screen, `/bots` and
`/plugins` had a zoom control that took the click and did nothing.
Collect and graft zoom alongside sizes. An absent panel id means the user has no
opinion, because `setZoom` deletes the field on reset rather than writing 1 —
that is what keeps Reset zoom reachable on a screen whose author ships a zoom of
their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the thumbnail, the title and the Download button shared one row. a long title — which
on YouTube is most of them — competed with the button for the width and won, running
past the card's own border.
`truncate` was already on the title and could not help: it only bites when the box is
actually bounded, and a flex sibling that is free to be pushed out means it never is.
adding more min-w-0 would have been guessing at that; splitting the row removes the
competition.
so the card is a column now: thumbnail and text on the top row, the action on its own
below. the title has the whole width to truncate against, and the button has a row it
cannot be squeezed out of — which also means it stays put as titles change length,
rather than the layout shifting per result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
my own fix from an hour ago caused this. it reset a boolean in an effect on `src`,
and that has a race which a CACHED image wins every time:
render with the new src -> browser has it already, fires onLoad -> loaded = true
effect runs (after paint) -> loaded = false
onLoad never fires again -> spinner forever
so the files that stuck were exactly the ones that were fastest, which is why it
read as random.
now it is DERIVED rather than reset: state holds which src has loaded, and `loaded`
is that compared to the current one. false the instant `src` changes, true the moment
that src's own load fires, and no window where the two can be written out of order.
there is nothing left to race.
zoom, rotation and pan stay in an effect. nothing else writes them during a load, and
being one render late is invisible — the hazard was only ever on the flag the browser
also writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
stepping through the carousel looked like the picture lagged a beat behind the
thumbnail strip. the strip was right; the picture was stale.
the renderer already had a spinner and an opacity gate — `loaded` simply was never
CLEARED when `src` changed. that did not matter while a viewer showed one file for
its whole life: the flag went true once and stayed. with a carousel it is the whole
bug, because the gate stays open and the browser goes on painting the previous
photograph until the next has decoded.
zoom, rotation and pan reset with it. they are a view of ONE image, and carrying 3x
and ninety degrees onto the next photograph puts somebody in the corner of a picture
they have not seen yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
arrows, a thumbnail strip, and left/right/escape on the keyboard — in the viewer
itself, so BOTH hosts get it: the Files screen and any panel embedding it.
IT LISTS THE FOLDER ITSELF rather than being handed a collection. the viewer is given
one path and is reached by two screens that get there differently — a link, a search
result, the grid. listing here means neither host has to pass anything and the strip
is right however the file was opened.
THE HOST DECIDES HOW TO WRITE THE STEP, because the two genuinely disagree: the Files
screen puts a NAME in `?view=` relative to the folder in its pathname, an embedded
panel puts an absolute path. so `onNavigate` is a callback rather than the viewer
setting a param, and a host that offers none simply gets no carousel — which is the
right default for one that cannot express it.
the strip uses the FILE BROWSER'S thumbnails: same route, same cache, same 256 the
grid asks for. so a folder already browsed has a warm strip, and a HEIC works here
because it works there rather than being a second thing to teach about formats.
`getThumbUrl` joins getRawUrl and getRenderUrl as a shared builder, with the token an
<img> cannot send as a header.
directories and archives are excluded. opening an archive EXTRACTS rather than views,
so a strip you step through would offer a step that does something else entirely.
the keyboard listener ignores inputs, textareas and contenteditable — the viewer can
be editing a file, and stealing arrow keys from a text area makes the editor unusable.
it renders nothing when the folder holds fewer than two viewable files: a strip
containing the thing already on screen, beside two arrows that do nothing, is worse
than no strip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
the folder param was written with `replace`, on my argument that browsing is not
navigation worth a history entry. that was wrong, and the URL itself gives it away:
the moment a folder is ADDRESSABLE it is a place, and Back meaning "the folder I was
just in" is what anybody expects of a place.
it also matches /files, which pushes. an embedded browser behaving differently from
the full one is a difference nobody can see a reason for — and would be discovered
by trying it, which is exactly how this was.
reading needed nothing: `currentPath` is derived from the param, so a Back that
rewrites the query moves the browser with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
`?media=/officerdev/data/tatudoemcampo@gmail.com/FofosWebsite/media/originals/ABOUT+US`
became `?media=ABOUT+US`.
the length is the smaller half. the absolute form put THE ACCOUNT'S EMAIL ADDRESS in
every link — so a URL sent to a colleague, or pasted into a chat, carried the owner's
address and the shape of their disk with it. relative, it says nothing about the
machine or who owns it.
it also makes the jail hold BY CONSTRUCTION rather than by comparison. the value is
joined to `basePath`, so there is no "outside" left to check for — only `..` could
climb out, and a `..` segment is refused outright. the previous version compared a
caller-supplied absolute path against a prefix, which is the weaker shape and the one
that goes wrong when somebody later "simplifies" it.
round-trip and tampering both exercised against the real base path before committing:
base, a folder with a space, a nested folder with a non-ascii name, then `../../etc`,
`GALLERY/../../..` and `/etc/passwd` — refused, refused, contained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
the previous commit added the parameter to the signature, documented it, threaded it
through FileBrowserApp — and never used it. seven lines of prose about a feature
that did not exist, committed, deployed, and reported as working.
that one fact explains all three things the owner reported: the query string never
changed because nothing wrote it; refreshing "still went to the right place" because
panelId memory was doing what it has always done; and the preview broke because the
version before it passed `urlPath`, which writes `?view=<name>` — resolvable only
when the folder is in the pathname, which in a panel it is not.
the edit failed silently because I matched on four-space indentation in a file that
uses two, and I did not check. third time today. this one asserts the replacement
changed the file and fails loudly if it did not.
what it does now, for real: current folder read from the named param, written back
with `replace`, base folder expressed as the ABSENCE of the param, and a param
pointing outside `basePath` ignored rather than obeyed — it comes from a URL somebody
can edit, and the jail is the point of an embedded browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
`urlPath` was the wrong tool and I reached for it because it was there. it owns the
URL's PATHNAME — reads the folder out of `location.pathname`, navigates to
`/files/<folder>` — which is correct for the Files screen, because that screen IS
that route, and catastrophic in a panel: opening a folder in the fofos Media section
took the whole app to /files.
so an embedder gets its own mechanism rather than a shared one bent to fit.
`pathParam` names a SEARCH param and never touches the pathname. three sources of
the current folder now, mutually exclusive by construction: the pathname for /files,
a named query param for an embedder, component state for an ordinary panel.
two details that are not obvious and would each be a bug:
a param naming a folder OUTSIDE `basePath` is ignored rather than obeyed. it arrives
from a URL somebody may have edited, and the jail is the whole promise of an
embedded browser — a shared link must not be a way through it.
the base folder is the ABSENCE of the param, not a value. so a link to the top of an
embedded browser is the plain screen URL, and nothing accumulates in the query string
for having done nothing.
written with `replace`, because browsing folders is not navigation anybody wants
twenty back-button presses of.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
`rootLabel` on FileBrowserApp, through to the breadcrumb. it defaults to the base
folder's own name, which is right for /files and wrong for an embedder: a panel
scoped to `<root>/media/originals` is called Media, and "originals" is an
implementation detail of how that plugin lays out a directory. the person looking at
it never chose that word and has no reason to recognise it.
one prop rather than the plugin drawing its own breadcrumb — which is the same trade
as embedding the browser at all: upload, delete, thumbnails and the context menu
come from one maintained place, and this is the small thing that was missing to make
that honest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
TWO REPORTS, ONE MEASUREMENT WRONG AND ONE SHAPE WRONG.
the rows looked vertically stacked because the card OVERFLOWED its row. GRID_ROW_HEIGHT
was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text —
about 148 — so each card ate the 12px gap and the next row touched it. the horizontal
gap was fine, which is exactly what made it read as a styling oddity rather than a
number being wrong.
the height is now derived from the card instead of guessed: a 96px image band, `py-2`
around a 20px name and a 16px meta line, inside a 2px border. 152. guessing is how it
came to be 130.
THE PICTURE NOW FILLS THE TILE. it was a fixed `h-16 w-20` box, so a landscape photo
sat in the middle with air either side and a portrait one shrank to nothing. `w-full`
with the card clipping means every tile reads as the same shape whatever the
photograph is — which is the whole reason a grid of pictures is easier to scan than a
grid of names.
so the card lost its padding and gained `overflow-hidden`: the image reaches the
edges and the card does the clipping. the padding moved to the text block, the only
part that ever wanted it. the image itself is not rounded — the card clips, and
rounding both would show a sliver of card colour inside each corner.
a folder icon sits in the same 96px band, so a folder and a photograph are the same
shape and the rows line up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
a closed, named list of events — `files:uploaded`, `archive:extracted`,
`files:placed` — each carrying absolute paths and an owner. NOT an event bus in the
open sense: `emit(anyString, anything)` becomes an API nobody can change, because
the moment a plugin listens for a string that string is a contract with no list
saying so. a union means adding one is a deliberate edit and removing one fails to
compile.
TWO GUARANTEES, AND THEY ARE THE WHOLE DESIGN. emit never blocks: it schedules and
returns, so an upload does not wait for a subscriber thumbnailing a HEIC. and a
subscriber cannot fail the operation that triggered it — the upload is written and
answered before anybody is told, so a plugin that is down, slow or throwing gets a
line in the log. tested both ways, sync and async, plus that one failing listener
does not stop the next.
PLUGINS SUBSCRIBE THROUGH THE MANIFEST, same shape as `fileTypes`: `hooks: {
'files:uploaded': '/v1/on-upload' }`. resolved at EMIT time rather than at boot,
because plugins are installed, updated and disabled while the server runs and a
registration made at startup describes a machine that has since changed. nothing
here names a plugin.
CORE IS THE FIRST SUBSCRIBER, and that is deliberate rather than a shortcut. the
thumbnail cache belongs to the platform — its key, its route — so a plugin doing
this work would produce files the browser never looks at. the warmer calls the same
`buildThumbnail` the request path calls, which means HEIC still warms through
av-club via the fileTypes claim, without this file knowing that plugin exists.
AND A SINGLE-FLIGHT MAP, which the warmer made necessary. two callers now want the
same thumbnail as a matter of course: the warmer starts on upload and the browser
asks a moment later. measured before the fix — four concurrent requests plus the
warmer each decoded the same 4032x3024 HEIC and took a slot from a limit of four,
which is a warmer competing with the person waiting rather than helping them. after:
all five share one build, 5.5s once, then 130ms.
verified end to end: upload returns in 56ms, four concurrent requests all 200 in a
single 5.5s build, cached 130ms. 1679 tests, docs:check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043