`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
the message was never wrong, which is worth saying first: Shift+Enter puts a real
`\n` in the textarea, and a real `\n` is stored, sent, and given to the agent. what
loses it is MARKDOWN'S OWN RULE — inside a paragraph a single line ending is
whitespace, and only a blank line starts a new one. "one\ntwo" rendering as
"one two" is correct CommonMark. it is right for prose and wrong for a chat box,
where somebody pressing Shift+Enter is asking for a line.
so the fix is at RENDER time, not on the way to disk. appending two spaces to every
line as a message is saved would edit what somebody typed, and every consumer
downstream — the agent, a transcript, an export — would carry trailing whitespace
nobody asked for to satisfy a display concern.
this is what `remark-breaks` does, and it is not in the lockfile. `remark-mentions`
beside it already records why that is not a reason to add one: installs are frozen,
and a dependency for a short tree walk is a deliberate lockfile change for no
benefit. same shape, same file, one more local plugin.
IT ONLY TOUCHES TEXT NODES, and that is the property that makes it safe rather than
a precaution. by the time this runs the tree is parsed, so a newline inside a fence
is the VALUE of a `code` node and never a `text` node — fenced code, inline code and
tables cannot be reached. tested, not assumed.
empty segments are dropped, so a trailing newline gives ONE break rather than a
break followed by an empty text node — which renders as a blank line and is the
usual way this ends up double-spacing everything after it.
7 tests: the break itself, several breaks, the trailing-newline case, code and
inline code untouched, text without newlines unchanged, and nesting — emphasis,
links and mentions all wrap text, so the walk has to go in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
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
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
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
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
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
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
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
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
`/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
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
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
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
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
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>
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
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>
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>
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
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
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