Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 816f869535 release 2026.08.30.01
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>
2026-08-30 11:13:11 +01:00
pastilhasandClaude Opus 5 d058cd9258 re-reading a folder does not blank it
`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>
2026-08-30 11:09:31 +01:00
pastilhasandClaude Opus 5 dfe837dfd0 a window re-reads its folder when it comes forward
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>
2026-08-30 11:05:36 +01:00
pastilhasandClaude Opus 5 a53c07d71f files can be dragged between windows
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>
2026-08-30 10:58:38 +01:00
pastilhasandClaude Opus 5 e3071f5d50 the file browser's clipboard lives on the server
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>
2026-08-30 10:55:24 +01:00
pastilhasandClaude Opus 5 601849be11 a file action gets an authenticated client, because it cannot build one
`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>
2026-08-29 21:18:52 +01:00
pastilhasandClaude Opus 5 1f75fb19af forward pluginActions into menuProps, or the menu never sees it
"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>
2026-08-29 21:11:18 +01:00
pastilhasandClaude Opus 5 a6d4a18592 a plugin can put an action in the file browser's context menu
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>
2026-08-29 20:59:20 +01:00
pastilhasandClaude Opus 5 e756007551 the file browser can be a file picker
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>
2026-08-29 20:30:22 +01:00
pastilhasandClaude Opus 5 9719e36723 a plugin may not bring its own React
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>
2026-08-29 20:19:35 +01:00
pastilhasandClaude Opus 5 912ecd04f7 a plugin can hand the agent its own MCP server
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>
2026-08-29 18:53:22 +01:00
pastilhasandClaude Opus 5 dd8c7fd7bd deploy 2026.08.29.04: 7 of 7 hosts, officer-dev skipped as the host it ran on
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:30:07 +01:00
pastilhasandClaude Opus 5 e9b71522ed release 2026.08.29.04
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>
2026-08-29 14:20:27 +01:00
pastilhasandClaude Opus 5 73a9b0bf51 officer blocks its event loop re-parsing a live 196 MB transcript
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
2026-08-29 13:10:20 +01:00
pastilhasandClaude Fable 5 882cb67f7a todo: openseo as a plugin candidate, checked against the repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 13:06:00 +01:00
pastilhasandClaude Opus 5 2738f799f2 zoom is the user's, so an authored layout has to graft it back
`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>
2026-08-29 10:45:30 +01:00
pastilhasandClaude Opus 5 82d24a8feb the download card is a column, so the title stops escaping it
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
2026-08-29 09:43:28 +01:00
pastilhasandClaude Opus 5 3d15f9fc8e the spinner stops sticking on cached images
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
2026-08-29 09:35:42 +01:00
pastilhasandClaude Opus 5 990078efce a new image is a new load, so the old one stops being shown
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
2026-08-29 09:32:12 +01:00
pastilhasandClaude Opus 5 53f3ca33b5 the file preview becomes a carousel
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
2026-08-29 09:28:38 +01:00
pastilhasandClaude Opus 5 eaecf563ff Back and Forward move between folders
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
2026-08-29 09:15:36 +01:00
pastilhasandClaude Opus 5 321cac2e80 the folder param is relative to the base, and stops leaking an email
`?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
2026-08-29 09:11:45 +01:00
pastilhasandClaude Opus 5 0ea4b822ae pathParam is actually implemented now
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
2026-08-29 09:08:40 +01:00
pastilhasandClaude Opus 5 1da58e5231 an embedded browser can put its folder in a query param
`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
2026-08-29 09:01:41 +01:00
pastilhasandClaude Opus 5 cad0ea3c9c the embedded file browser can name its own root crumb
`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
2026-08-29 08:50:30 +01:00
pastilhasandClaude Opus 5 03638a7f62 grid tiles get their gap back, and the picture fills the tile
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
2026-08-29 08:29:03 +01:00
pastilhasandClaude Opus 5 000df7fe45 platform events, and thumbnails warmed the moment files arrive
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
2026-08-29 08:20:35 +01:00
pastilhas 39c1122651 deploy report 2026.08.29.03 — seven hosts, and the tag held while master moved 2026-08-29 08:13:31 +01:00
pastilhasandClaude Opus 5 44f16fedee Shift+Enter renders as a line break
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
2026-08-29 08:07:54 +01:00
pastilhasandClaude Opus 5 03fbb1189f release 2026.08.29.03 — the todo board leaves, a plugin can claim a file type
no schema change and no dependency change, and the plan says so with the two
commands that prove it rather than the claim alone. so there is no db:push step and
no bun install step, and the plan says neither should be improvised. ten commits,
all code.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  command_hq_sessions
    + plugin         text   null
    + plugin_config  jsonb  null

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SETUP, both halves:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 22:09:59 +01:00
108 changed files with 5392 additions and 675 deletions
+1 -1
View File
@@ -243,7 +243,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
never grantable at any level). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
+6 -5
View File
@@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**107 open, 32 closed**, across 34 themes.
**108 open, 33 closed**, across 35 themes.
| Theme | Open | Closed |
| --- | --- | --- |
@@ -40,12 +40,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 6 | 4 |
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 0 |
| [Known bugs](todo/known-bugs/) | 7 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
@@ -70,13 +71,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-08-29` **Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it** — [known-bugs/officer-blocks-its-event-loop-parsing-transcripts](todo/known-bugs/officer-blocks-its-event-loop-parsing-transcripts.md)
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
- `2026-08-27` **OpenSEO — a strong plugin candidate, checked rather than assumed** — [integrations/openseo-a-plugin-candidate](todo/integrations/openseo-a-plugin-candidate.md)
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
+98 -35
View File
@@ -122,6 +122,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"sounds": "workspace:*",
@@ -365,7 +366,7 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
@@ -443,31 +444,43 @@
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
@@ -475,11 +488,13 @@
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
@@ -2135,7 +2150,7 @@
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -2147,7 +2162,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -2419,6 +2434,20 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-linux-arm/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-linux-arm64/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-linux-x64/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-linuxmusl-arm64/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-linuxmusl-x64/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2519,6 +2548,8 @@
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -2557,6 +2588,8 @@
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"node-pty/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
@@ -2597,35 +2630,23 @@
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
"sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
"sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
"sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
"sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
"sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
"sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
"sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
"socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -2727,6 +2748,46 @@
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ=="],
@@ -2899,6 +2960,8 @@
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"react-email/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
"wif/bs58check/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.28.01 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout, and it
was already at the tagged commit.
Every host moved from `91147b40` (`deploy/2026.08.27.02`) to `9c8af9d4` (`deploy/2026.08.28.01`) by
`git merge --ff-only` onto the TAG. Every host started from the same commit, so the fleet was coherent
before this release and is coherent after it. No host was dirty in tracked files, none diverged, none
needed a skip.
This is the first release rolled out under the new watcher loop. The rollout procedure is the same one
that ran the previous nine; what was unproven was the loop around it.
## Per host
| Host | HEAD before | HEAD after | Restarted | Route probe |
| --- | --- | --- | --- | --- |
| `alpha` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9000) |
| `fofos` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
| `macbookpro` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9010) |
| `macbruno` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
| `production-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 2→3, online | **401** (9010) |
| `edge-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 3→4, online | **401** (9000) |
| `deadpool-rezio-bridge` | `91147b40` | `9c8af9d4` | officer 20→21, online | **401** (9000) |
Every restarted process came back `online` with `restart_time` moved. Nothing was left `errored` or
`stopped` on any host.
## The verification, per host
The plan's check is an unauthenticated probe of the new route: `401` proves it is mounted, `404` would
mean the bundle did not move. Run on each host against that host's own port, taken from its `.env` and
not from habit:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
All seven answered **401**. None answered 404, so the frontend bundle and the route moved everywhere.
Three hosts — `fofos`, `production-pertento-bridge`, `edge-pertento-bridge` — answered `000`
(connection refused) on the first probe and `401` seconds later. That is `bun start` still coming up,
not a fault: `pm2` returns as soon as it has respawned the process, before the server binds. A probe
fired immediately after a restart is testing the wrong moment. The probes here retried until the port
answered.
## No schema change — checked, not assumed
The plan claimed no schema change, no dependency change and no plugin change. That claim was
**re-derived before the first host was touched**, not taken on trust:
```
git diff --name-only deploy/2026.08.27.02..deploy/2026.08.28.01^{}
```
Ten files: two `deploy/*.md`, `package.json`, and six under `src/` — a route, a router line, two
frontend files, a workspace app and its index. No `schema.ts`, no drizzle config, no `bun.lock`,
nothing under `plugins/`.
So there is no schema verification section in this report, because there was nothing to verify. That is
the honest answer rather than an omission.
## Steps the plan deliberately did NOT include, and which were NOT run
- **`bun db:push` was not run on any host.** The plan forbids it and gives the reason: it prints
`Changes applied` even when it applies nothing, so it is not evidence, and on this fleet it is not
free — it re-applies a default on `command_hq_sessions.reference_paths` everywhere, and drops and
re-adds `pk_music_now_playing` on `alpha` and `edge-pertento-bridge`. Nothing in this release touches
a schema, so running it would have been churn.
- **`bun run plugins:update` was not run on any host.** No plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
- **`officer-claude-code` was not restarted anywhere.** It ends every live agent session on the host and
nothing in this release needs it.
- **`officer-opencode` was not restarted anywhere.** This release changes nothing it reads at boot.
The consequence worth stating: only `officer` moved on each host. Unlike `2026.08.27.02`, where the
plugins step bounced every sidecar, nothing else on the fleet restarted during this rollout.
## Version agreement
The tag `deploy/2026.08.28.01` is annotated and dereferences to `9c8af9d4`. `package.json` at that
commit reads `2026.08.28.01`. They agree, so there was no question of which tree to deploy.
## Say what was not done
- **`officer-dev` was skipped entirely** — not pulled, nothing restarted. It is the machine this
rollout ran on, and restarting `officer-claude-code` there would have ended the session mid-rollout.
It is already at `9c8af9d4`, so its code is current; what it has not had is a restart of `officer`.
**That is the owner's to do, deliberately, as the last step.** Until then `officer-dev` is running the
previous release's process against the new code on disk.
- **`fofos` carries an untracked `scripts/chk.ts`.** Left alone. An untracked file is a note, not a
reason to skip a host, and a fast-forward could never have touched it.
- **The eight non-Officer processes on `production-pertento-bridge` were not touched.** `Dashboard`,
`Tracking`, `Uptime` and five `Crons *` were all `online` with unchanged `restart_time` after the
rollout — checked, not assumed. The restart named `officer` alone.
- **No host needed a retry for SSH.** `deadpool-rezio-bridge`, which refused once during the
`2026.08.27.02` survey, answered first time. `macbookpro` was awake.
- **Nothing was rolled back and nothing needed repair.** No host failed, so no half-applied state
exists anywhere.
+122
View File
@@ -0,0 +1,122 @@
# 2026.08.29.01 — sharp becomes infrastructure
**This release changes what a machine must have.** Every other deploy this month was code; this one adds
a dependency with a native binary and a system package, so `bun install` is a required step rather than
an optional one, and there are two things to verify per host that no previous plan asked for.
Read the whole file before starting a host. The per-host section at the bottom is not optional detail —
one host needs a command none of the others do.
## What is in it
- **File browser thumbnails.** Images and videos draw a picture in both grid and list. New endpoint
`GET /api/file-browser/thumb`, cached under each user's `cache/thumbs/`.
- **sharp is now a declared dependency** (`^0.35.3`, resolving 0.35.4). It does the images; ffmpeg keeps
the video. `package.json` and `bun.lock` both changed.
- **ffmpeg is now declared in machine-setup's package list.** It was always a hard dependency of the file
browser — subtitles, audio extraction, audio tracks — and was never in the list. Existing hosts were
not provisioned by the new script, so this release checks rather than assumes.
- Marketplace: install a plugin from a git URL; Marketplace overview section; nav restyle.
- Dock and Spotlight clip bare plugin artwork to the tile shape.
- The platform-skill generator emits an identifier for `name` again, so `docs:check` is green.
## Schema: UNCHANGED. Do not run `db:push`.
Verified, not assumed:
```bash
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
```
returns nothing. There is no table, no column and no constraint in this release. **No `bun db:push`
step exists in this plan and none should be improvised.** If `bun run plugins:update` reports that a
plugin moved, say so in the report — do not push a schema to cover it.
## Every host — the commands
Run these after the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin
--tags`, `git merge --ff-only deploy/2026.08.29.01`, `bun run plugins:update`), in this order, in the
checkout from PM2's `pm_cwd`.
### 1. Install — REQUIRED, and it is the point of this release
```bash
bun install
```
The lockfile is frozen, so this resolves from `bun.lock` and nothing else. It should report installing
around 14 packages — sharp and its platform-specific libvips binaries.
**A failure here stops this host.** Do not continue to the restart, and do not reach for `--force` to get
past it: if the lockfile and `package.json` disagree, that is the freeze doing its job and it wants a
human.
### 2. Prove sharp actually loads — the check that matters
```bash
bun -e "import('sharp').then(m => console.log('libvips', m.default.versions.vips)).catch(e => { console.error('SHARP FAILED:', e.message); process.exit(1) })"
```
Expect `libvips 8.18.6`.
**This is a separate step from the install on purpose.** sharp does not compile — it downloads a prebuilt
binary chosen by platform and architecture. So `bun install` can succeed and the module still be
unusable, and the symptom is not an error anywhere: it is a file browser drawing generic icons over a
folder of photographs, which nobody reads as "a native module is missing".
A host where this fails is a **failed host**. Report it, move on. `bun install --force` is the usual
repair and it is a human's to run, not this rollout's.
### 3. ffmpeg must be present
```bash
command -v ffmpeg && ffmpeg -version | head -1
```
Video thumbnails need it, and so do three file-browser features that predate this release. Surveyed
2026-08-29: present on every host **except `macbruno`** — see below.
Absent and not covered by a per-host command below → report the host as deployed-with-a-caveat, name
ffmpeg, and do not install it yourself. Adding a system package to a machine is not something a rollout
improvises.
### 4. Restart
```bash
pm2 restart officer
```
**`officer` only.** Nothing else in this release touches a sidecar. Never
`pm2 restart ecosystem.config.cjs`.
### 5. Verify it came back
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Then, for this release specifically:
```bash
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<PORT>/api/health
```
`200` or `401` are both healthy — the port is per host, in `docs/servers.md` (9010 on `macbookpro` and
`production-pertento-bridge`, 9000 elsewhere).
## Per host
| Host | Extra command | Note |
| --- | --- | --- |
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here. Do not pull, do not restart. Name it as skipped. |
| `alpha` | — | x86_64, ffmpeg present. |
| `fofos` | — | **Already at this code and verified** — it was the test host on 2026-08-29 before the tag. Bring it to the tag anyway so its HEAD matches the fleet; `bun install` will be a no-op. 1.9GB box: expect the install to be the slowest part. |
| `macbookpro` | — | macOS/arm64, port **9010**. A laptop — unreachable is a SKIP with a note, not a failure. |
| `macbruno` | `brew install ffmpeg` **before step 4** | macOS/arm64, login `brunoalcadapadezmonteirodaconceicao`. **The only host without ffmpeg.** Without it, image thumbnails still work — sharp does those — and video ones do not, along with subtitles and audio extraction. If brew is missing or the install fails, deploy anyway and report the caveat: nothing in this release regresses without ffmpeg, it simply does not gain the video half. |
| `production-pertento-bridge` | — | x86_64, port **9010**. |
| `edge-pertento-bridge` | — | x86_64. Node v24 here where the fleet is v22; sharp ships binaries for both and this was not a problem for the install on any other host, but if step 2 fails anywhere it is most likely here. |
| `deadpool-rezio-bridge` | — | x86_64, login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `bun install` result, the libvips version step 2 printed, whether ffmpeg
was found, and `officer` back online with a moved `restart_time`.
The two lines worth having in the summary that no previous report needed: **which hosts print a libvips
version**, and **which hosts have ffmpeg**. Those are the two facts this release is actually about.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.29.01 — deploy report
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
## The two facts this release is about
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
v24) as the most likely failure; it was not one.
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
outstanding on any host.
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
| --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
## Schema
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
taking its word:
```
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
```
returns nothing. No table, column or constraint in this release, so the "check the database by query"
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
release.
## The one deviation, and why
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
`bun run plugins:update` exited 1 with:
```
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
```
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
release is about to add.
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
does not make.
## What else moved
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
restart officer`, per host, by name.
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
restart. Nothing there was named or touched.
## Notes, not problems
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
cannot touch it. No host had tracked changes; no work was discarded anywhere.
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
ffmpeg is on the restarted process's PATH. Verified, not assumed.
## For the owner
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
not necessarily present for the running process. Restart it deliberately when you are happy with the
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
dependency.
+119
View File
@@ -0,0 +1,119 @@
# 2026.08.29.02 — a plugin can ship a commander
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
for each host.
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
where the standard `plugins:update` ran before the plan's install.
## What is in it
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
before it can arm.
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
- Marketplace, file browser and Convert Image work from earlier today rides along.
## Schema — CHANGED. `db:push` is required.
```
command_hq_sessions
+ plugin text null which plugin's template made this commander
+ plugin_config jsonb null the owner's answers to that template's inputs
```
Verified as the whole of it:
```bash
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
# → src/databases/officer_db/src/command-hq/queries.ts
# src/databases/officer_db/src/command-hq/schema.ts
```
`queries.ts` is code, not schema. The only structural change is the two columns above.
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
create-then-constrain here, and no `--force` is needed or permitted.
## Every host — the commands
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
`pm_cwd`:
### 1. Back the database up first
```bash
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
```
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
### 2. Push the schema
```bash
bun db:push < /dev/null
```
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
### 3. VERIFY BY QUERY. The exit code is not the answer.
```bash
bun -e "
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
const r = await db.execute(sql\`select column_name from information_schema.columns
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
process.exit(0)"
```
Must print exactly:
```
plugin plugin_config
```
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
### 4. Restart
```bash
pm2 restart officer
```
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
### 5. Check it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are both healthy.
## Per host
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
| `alpha` | Nothing special. |
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
| `production-pertento-bridge` | Port **9010**. |
| `edge-pertento-bridge` | Nothing special. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
the verification query**, and `officer` back online with a moved `restart_time`.
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
thing this release actually changes about a machine.
+74
View File
@@ -0,0 +1,74 @@
# 2026.08.29.02 — deploy report
**7 of 7 target hosts deployed. `officer-dev` skipped by design.** Every host prints
`plugin plugin_config` — the two new `command_hq_sessions` columns exist on all eight databases.
All eight checkouts were at `58f50a52` (the `2026.08.29.01` tag) before this rollout, and all are at
`78bf63c2` (`deploy/2026.08.29.02`) now. No host was ahead, none diverged, none was dirty.
This was the first release under a new watcher loop. The rollout itself behaved as written.
## Per host
| Host | HEAD before → after | plugins:update | backup | db:push | verification query | officer | health |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — **SKIPPED** | — | — | — | `plugin plugin_config` | not restarted | — |
| `alpha` | `58f50a52` → `78bf63c2` | 15/15, exit 0 | 12,309,974 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (9000) |
| `fofos` | `58f50a52` → `78bf63c2` | 5/5, exit 0 | 2,792,097 B | exit 0 | `plugin plugin_config` | 26 → 27 restarts, online | 401 (9000) |
| `macbookpro` | `58f50a52` → `78bf63c2` | no plugins installed | 5,179,143 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (**9010**) |
| `macbruno` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 86,480 B | exit 0 | `plugin plugin_config` | 6 → 7 restarts, online | 401 (9000) |
| `production-pertento-bridge` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 187,488 B, **second attempt** | exit 0 | `plugin plugin_config` | 4 → 5 restarts, online | 401 (**9010**) |
| `edge-pertento-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 5,916,050 B | exit 0 | `plugin plugin_config` | 5 → 6 restarts, online | 401 (9000) |
| `deadpool-rezio-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 92,979 B | exit 0 | `plugin plugin_config` | 22 → 23 restarts, online | 401 (9000) |
`401` is the healthy answer from `/api/health` behind the session gate, as the plan says.
## What the release was for, confirmed on the host that matters
`fofos` carries `av-club`, and the commander template it now ships is on disk:
`plugins/av-club/commanders/convert-on-arrival/COMMANDER.md`, plugin checkout at `5b2fe03`
("Convert on arrival — the first plugin-shipped commander"). The plugin arrived through the standard
`bun run plugins:update` step, as the plan predicted — nothing in this plan installed it.
## What did not go to plan
**`production-pertento-bridge` failed its backup on the first attempt and the host stopped there.**
The plan's `pg_dump` is Ubuntu's 16.15; that host's Postgres is 18.3, and `pg_dump` refuses a newer
server outright:
```
pg_dump: error: aborting because of server version mismatch
pg_dump: detail: server version: 18.3; pg_dump version: 16.15
```
The host was left correct but half-done: code at the tag, plugins updated, **no `db:push` and no
restart**, so the running process was still serving the old code against the old schema. Nothing was
broken by stopping there.
Postgres on that host runs in a docker container (`postgres:18.3-alpine`), so a version-matched client
was already present. The backup was retaken with `docker exec postgres pg_dump` — same dump, correct
tool, read-only — and the plan then continued unchanged from step 2. Result above.
**This is a per-host detail worth writing down**, because it will recur on every release whose plan
backs the database up: the plan's `pg_dump` line is host-portable only where the client is at least as
new as the server. `production-pertento-bridge` is the one host where it is not.
**`fofos` health read `000` immediately after the restart** and `401` fifteen seconds later. The check
was racing a cold start, not finding a fault. The health check now retries for up to thirty seconds
before it reports a number.
**`fofos` has one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could not have
disturbed it.
## Skipped
`officer-dev` — the rollout ran on it. Its checkout was already at `78bf63c2` and its database already
carries both columns (verified by the same query: `plugin plugin_config`). **Its `officer` process was
not restarted**, so it is running the previous build. The owner restarts it deliberately:
```
pm2 restart officer
```
`docs/servers.md` lists nothing under "Deliberately skipped" today, and no host was unreachable —
`macbookpro` was awake.
+85
View File
@@ -0,0 +1,85 @@
# 2026.08.29.03 — the todo board leaves, and a plugin can claim a file type
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.29.02..deploy/2026.08.29.03 | grep -iE 'schema|/db/|databases' # nothing
git diff --stat deploy/2026.08.29.02..deploy/2026.08.29.03 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
Ten commits, all code.
## What is in it
- **`/todos` is gone from the platform.** It is now the `kanban` plugin, in the marketplace. The
`todos` permission, the route, the screen and the board were all removed.
- **A plugin can claim a file type.** A manifest may declare `fileTypes`; the file browser then offers
thumbnails and a full-size render for formats the platform itself cannot read. av-club claims
`heic`/`heif`, so on a host with av-club installed, iPhone photographs get pictures instead of file
icons and open in the viewer.
- Thumbnails retry rather than giving up, and list and grid ask for the same size.
- The directory picker moved into the platform so more than one plugin can use it.
## A behaviour change to expect, and it is deliberate
**`/todos` now 404s.** Anyone with it bookmarked gets nothing. The board is the `kanban` plugin, which
is in the marketplace and installed nowhere by default — that is the intended state, not an oversight.
`role_permissions` rows naming `todos` are now orphaned. Harmless: absence denies, and the registry no
longer has the key. **Do not clean them up as part of this rollout** — it is a data edit nobody asked
for, and a row naming a permission that does not exist grants nothing.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the whole rollout this time (dirty check,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.03`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
### Verify it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
### And check the file browser still lists
This release touched the listing route, so one call per host is worth it:
```bash
bun -e "
const { sign } = await import('./src/servers/jwt');
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
process.exit(0)"
```
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
## Per host
Surveyed 2026-08-29 while writing this.
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
| `alpha` | No av-club, so no HEIC support — expected, not a fault. |
| `fofos` | **The only host with av-club installed**, and already at this commit and verified: 6 HEICs thumbnail and one renders at full size. Bring it to the tag so its HEAD matches the fleet. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
| `edge-pertento-bridge` | Did not answer the survey while this plan was written. It may simply have been busy; treat it as an ordinary target and report what you find. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`, and the `ls` check's output.
Nothing in this release needs a database query, a backup or an install. If you find yourself running
one, stop — the plan is wrong and that is worth reporting rather than working around.
+51
View File
@@ -0,0 +1,51 @@
# 2026.08.29.03 — deploy report
**7 of 7 target hosts succeeded. `officer-dev` skipped, as required.**
Tag `deploy/2026.08.29.03` → `03fbb118`, matching `package.json`. Plan:
[`deploy/2026.08.29.03.md`](2026.08.29.03.md). Every host ended at `03fbb118`.
Rolled out one host at a time, first release under a new watcher.
## Per host
| Host | HEAD before | HEAD after | `plugins:update` | `officer` restart_time | health | `file-browser/ls` |
| --- | --- | --- | --- | --- | --- | --- |
| `alpha` | `78bf63c2` | `03fbb118` | 15/15 | 4 → 5 | 401 | 200, 76 entries |
| `fofos` | `21665ed0` | `03fbb118` | 5/5 | 45 → 46 | 401 | 200, 38 entries |
| `macbookpro` | `78bf63c2` | `03fbb118` | none installed | 4 → 5 | 401 (:9010) | 200, 62 entries |
| `macbruno` | `78bf63c2` | `03fbb118` | 1/1 | 7 → 8 | 401 | 200, 41 entries |
| `production-pertento-bridge` | `78bf63c2` | `03fbb118` | 1/1 | 5 → 6 | 401 (:9010) | 200, 53 entries |
| `edge-pertento-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 6 → 7 | 401 | 200, 29 entries |
| `deadpool-rezio-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 23 → 24 | 401 | 200, 49 entries |
Every host: tracked tree clean, `git merge --ff-only deploy/2026.08.29.03` fast-forwarded, `officer`
came back `online` with a moved `restart_time`, and no process on any host was left off-line. Ports
were read from each checkout's own `.env` rather than assumed. `401` on `/api/health` is healthy per
the plan.
## What was NOT done
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. The rollout ran there, inside
`officer-claude-code`; restarting would have ended it mid-deploy. It is already at `03fbb118`, so no
code is missing — only its `officer` process still runs the previous build. The owner restarts it
deliberately.
- **No `bun db:push`, no `bun install`, no SQL.** The plan states there is no schema and no dependency
change, and says improvising either is itself the error. Nothing was queried because nothing was
changed. **This release has no schema verification, by design.**
- **Orphaned `todos` rows in `role_permissions` left alone**, as the plan instructs. Absence denies.
## Worth knowing
- **A commit landed on `master` mid-rollout.** `origin/master` reached `44f16fed` while hosts 4–7 were
fetching. No host took it: every merge targeted the tag, not the branch, so the fleet is uniformly at
`03fbb118`. `44f16fed` is undeployed and rides the next release. This is the 2026-08-23 failure not
happening.
- **`plugins:update` moved more than `officer`.** It restarts sidecars itself — around twelve on
`alpha` — and added and started one each on `production-pertento-bridge` (gitea),
`edge-pertento-bridge` (offscale) and `deadpool-rezio-bridge` (email).
- **`fofos` has an untracked `scripts/chk.ts`.** Left in place. Untracked files are a note, not a skip.
- **`edge-pertento-bridge` needed no special handling.** `docs/servers.md` warns it may be far behind
after its outage; it was already at `deploy/2026.08.29.02`. That warning is now stale.
- **`/todos` now 404s on all seven hosts.** Intended — the board is the `kanban` plugin, installed
nowhere by default.
+90
View File
@@ -0,0 +1,90 @@
# 2026.08.29.04 — the file preview becomes a carousel, and zoom starts working again
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 | grep -iE 'schema|/db/|databases' # nothing
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
Sixteen commits; two of them are notes and one is the previous release's report.
## What is in it
- **The file preview is a carousel.** It lists the folder it was opened from, shows thumbnails along the
bottom, and steps with the arrow keys or the carets. Escape closes it.
- **Zoom works on plugin screens, `/bots` and `/plugins` again.** `useAuthoredLayout` grafts back only
what the user owns, and it knew about sizes and not about zoom — so every click was collected by
nothing, compared equal, and dropped. The control rendered, took the click, and did nothing.
- **An image no longer sticks.** Stepping to a new picture showed the previous one until the new one
decoded, and the first fix for that lost a race against cached images and left the spinner up forever.
Both are gone: loaded state is derived from which `src` actually loaded.
- **A platform event bus**, and thumbnails warmed the moment files arrive. `files:uploaded`,
`files:placed` and `archive:extracted` are emitted; a plugin may subscribe through its manifest. No
plugin subscribes yet, so the dispatch path is unexercised in production — worth knowing if something
odd appears near an upload.
- **An embedded file browser can put its folder in a query parameter**, keep its own root crumb, and
Back and Forward move between folders.
- Shift+Enter renders as a line break in chat. Grid tiles have their gap back and the picture fills the
tile. The download card's title stops escaping it.
## Nothing here changes behaviour anyone has to be warned about
No route is removed, no permission changes, nothing 404s that did not before. If a host comes back and
something is missing, that is a fault rather than an intention — report it.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.04`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
### Verify it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
### And check the file browser still lists
This release touched the file browser and the viewer heavily, so one call per host:
```bash
bun -e "
const { sign } = await import('./src/servers/jwt');
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
process.exit(0)"
```
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
## Per host
Surveyed 2026-08-29 while writing this. Every host was clean (`--untracked-files=no`) and six of the
seven sit exactly on `deploy/2026.08.29.03` (`03fbb118`).
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
| `alpha` | On `03fbb118`, clean. Ordinary target. |
| `fofos` | **On `2738f799`, not on the tag** — it has been hand-pulled all day while this work was written against it. That commit is an ancestor of the tag, so the fast-forward is ordinary; it simply has fewer commits to travel. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note, not a failure. |
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
| `edge-pertento-bridge` | Answered the survey this time, on `03fbb118` and clean. Ordinary target. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`, and the `ls` check's output.
Nothing in this release needs a database query, a backup or an install. If you find yourself running
one, stop — the plan is wrong, and that is worth reporting rather than working around.
+76
View File
@@ -0,0 +1,76 @@
# 2026.08.29.04 — deploy report
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
Every host went `03fbb118` → `e9b71522` except `fofos`, which started further back at `2738f799` and
travelled further. All eight rows below end on `e9b71522`, the commit `deploy/2026.08.29.04` points at.
Nothing failed. Nothing was left half-applied. No host was unreachable — `macbookpro` was awake.
## What ran, per host
The same four steps everywhere: tracked-only dirty check, `git fetch origin --tags`,
`git merge --ff-only deploy/2026.08.29.04`, `bun run plugins:update`, then `pm2 restart officer` and
nothing else. The plan asked for no `bun install` and no `bun db:push`, and neither was run.
| Host | HEAD before | HEAD after | plugins | `officer` restarts | health | `ls` |
| --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | **SKIPPED — the rollout ran here** | — | — | — |
| `alpha` | `03fbb118` | `e9b71522` | 15/15 | 5 → 6 | 401 | 200, 77 entries |
| `fofos` | `2738f799` | `e9b71522` | 5/5 | 93 → 94 | 401 | 200, 39 entries |
| `macbookpro` | `03fbb118` | `e9b71522` | none installed | 5 → 6 | 401 (9010) | 200, 62 entries |
| `macbruno` | `03fbb118` | `e9b71522` | 1/1 | 8 → 9 | 401 | 200, 41 entries |
| `production-pertento-bridge` | `03fbb118` | `e9b71522` | 1/1 | 6 → 7 | 401 (9010) | 200, 53 entries |
| `edge-pertento-bridge` | `03fbb118` | `e9b71522` | 2/2 | 7 → 8 | 401 | 200, 29 entries |
| `deadpool-rezio-bridge` | `03fbb118` | `e9b71522` | 2/2 | 24 → 25 | 401 | 200, 49 entries |
`plugins:update` exited 0 on every host that has plugins. `401` on `/api/health` is healthy — the plan
says so and every host gave the same answer.
## The file browser check
This release rewrote the file browser and the viewer, so the plan asked for one signed
`/api/file-browser/ls` call per host. **Seven hosts, seven `200`s**, each with a plausible entry count
for that machine. The counts differ because the homes differ; none was zero and none was a 500.
Signed as user id 1 with that host's own owner, which is not the same address everywhere —
`conceiba@gmail.com` on `macbruno` and `brunorezio@gmail.com` on `deadpool-rezio-bridge`. Read from each
host's own `users` table rather than assumed.
## Schema
**No schema verification was performed, because this release changes no schema.** The plan states that
and shows the two `git diff --name-only` commands behind the claim. No `bun db:push` step exists in it
and none was improvised.
The plugin sidecars did apply their own schemas during `plugins:update` — `music` on `alpha`,
`fofos-admin` and `proxy-manager` on `fofos`, `proxy-manager` on `macbruno`, `offscale` on
`edge-pertento-bridge`, `email` on `deadpool-rezio-bridge`. Each printed its own
`schema: applied … (N table(s) verified)`. That belongs to the plugin updater, not to this release.
## More restarted than `officer`
`plugins:update` restarts a sidecar whenever it moves a plugin, before the plan's own restart. Naming
only `officer` would understate what moved, so: `officer-music` on `alpha`, `officer-av-club` on
`fofos`, `officer-gitea` on `production-pertento-bridge`, `officer-gitea` and `officer-offscale` on
`edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. Every one came back `online`.
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched.
## What I did not do
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It already sits on this code, and
restarting it would have ended the session writing this report. The owner restarts it deliberately,
last, once happy with the rest.
- **`fofos` has an untracked `scripts/chk.ts`.** Untracked is not dirty; the fast-forward could not touch
it and it was left exactly where it was. A note, not a skip.
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell — its `~/.npmrc` has a
`globalconfig`/`prefix` setting nvm objects to. It is noise, it broke nothing, and it is not this
release's doing. Worth clearing at some point so it stops appearing in deploy output.
- **No `--force` anywhere**, no dependency install, no database write.
## One thing worth knowing
The plan flags a **platform event bus** — `files:uploaded`, `files:placed`, `archive:extracted` — that no
plugin subscribes to yet. The dispatch path therefore ships unexercised. If something odd shows up near an
upload on any of these hosts, that is the first place to look.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.30.01 — the file browser's clipboard moves to the server, and plugins reach further in
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 | grep -iE 'schema|/db/|databases' # nothing
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
Eleven commits; one of them is the previous release's report.
The clipboard is per-user state and it needs no migration — it is a key in `user_state`, a JSONB column
that already exists, written by the same `PATCH /user/state` the dock and the view mode already use.
## What is in it
- **The file browser's clipboard lives on the server.** It was `useState`, so it existed in one tab and
died with it: copying in one window and pasting in another did nothing, and a reload lost a pending cut
with no sign that it had. It is now a key in `user_state`, per user rather than per browser — cutting on
a desktop and pasting on a phone is one gesture across two screens.
- **Files can be dragged between windows.** A custom MIME type on the DataTransfer survives between tabs
and windows of the same browser, so a row can be dragged from one Officer window into a folder in
another. It cannot cross different browsers or exchange with Finder; that is a property of the platform,
not a gap to report.
- **A window re-reads its folder and the clipboard when it comes forward**, so the window a file was cut
from stops listing it. And **re-reading no longer blanks the list** — the spinner is now for a folder
that cannot yet be shown, so a refresh after a paste or a delete leaves the rows and the scroll position
where they were.
- **A plugin can put an action in the file browser's context menu**, and gets an authenticated client to
use in it. Three commits, because the first two shipped the registration without forwarding it into the
menu, and then without a way to call the API.
- **The file browser can be a file picker** — `onPickFile` turns opening a file into handing it back,
rather than a second component that reimplements a listing, a breadcrumb and a sort.
- **A plugin can hand the agent its own MCP server**, declared in its manifest.
- **A plugin may not bring its own React.** `npmDependencies` strips and then deletes `react` and
`react-dom` after install: a plugin's peer copy landing in `plugins/node_modules` gives the app two
Reacts and every hook throws.
## Nothing here changes behaviour anyone has to be warned about
No route is removed, no permission changes, nothing 404s that did not before. The clipboard is additive:
a browser that has never set one reads `null` and behaves exactly as before.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.30.01`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
### Verify it came back
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
### And check the file browser still lists
This release touched the file browser heavily — the listing loader, the clipboard and the row markup —
so one call per host:
```bash
bun -e "
const { sign } = await import('./src/servers/jwt');
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
process.exit(0)"
```
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
## Per host
Surveyed 2026-08-30 while writing this. Every host answered, and every one is clean
(`--untracked-files=no`).
| Host | HEAD at survey | Note |
| --- | --- | --- |
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
| `alpha` | `e9b71522` | Exactly on the previous tag. Ordinary target. |
| `fofos` | `601849be` | **Ahead of the previous tag, behind this one.** It has been hand-pulled all day while the fofos plugin work was written against it. That commit is an ancestor of this tag, so the fast-forward is ordinary — it simply travels fewer commits. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
| `macbookpro` | `e9b71522` | macOS, port **9010**, a laptop. Answered the survey; if it has slept since, unreachable is a SKIP with a note, not a failure. |
| `macbruno` | `e9b71522` | macOS, login **`brunoalcadapadezmonteirodaconceicao`**. |
| `production-pertento-bridge` | `e9b71522` | Port **9010**. |
| `edge-pertento-bridge` | `e9b71522` | Ordinary target. |
| `deadpool-rezio-bridge` | `e9b71522` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`, and the `ls` check's output.
Nothing in this release needs a database query, a backup or an install. If you find yourself running one,
stop — the plan is wrong, and that is worth reporting rather than working around.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.28.01",
"version": "2026.08.30.01",
"private": true,
"type": "module",
"workspaces": [
@@ -160,6 +160,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"sounds": "workspace:*",
+26 -1
View File
@@ -60,9 +60,34 @@ function coreProcesses(): Pm2App[] {
const [name, script, args] = line.split('|');
if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`);
return { name, script, args, cwd: PLATFORM_DIR, watch: false };
});
})
.filter((app) => !DISABLED.has(app.name));
}
/*
Core processes this host does not want, from `OFFICER_DISABLED_SIDECARS` in `.env`.
A core process is core because the platform's own features depend on it — which is why the list is a
fixed array and not a setting. But "the platform depends on it" is not "every host runs it": fofos is a
1.9 GB machine serving a website, and `officer-opencode` sat there costing 456 MB of that for a harness
nobody on it uses. It froze hard twice, with no OOM line in the journal — which is what memory
exhaustion looks like when there is not enough headroom left to kill something cleanly.
Deliberately NOT a database setting. This file is regenerated during a plugin install, so a host that
has dropped a process needs it to stay dropped across one. `.env` is read at generation time and is
per-host, which is exactly the scope of the decision.
Naming something the platform genuinely needs will break that host — `officer` itself, or
`officer-pty` if anyone uses a terminal. Not validated, because which processes are safe to drop
depends on what the host is for, and this script cannot know that.
*/
const DISABLED = new Set(
(process.env.OFFICER_DISABLED_SIDECARS ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
);
/** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */
async function pluginEntries(): Promise<Pm2App[]> {
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
+11 -1
View File
@@ -60,7 +60,17 @@ export async function renderPlatformSkill(): Promise<string> {
const out = (s = '') => lines.push(s);
out('---');
out('name: Officer platform');
/*
`name` is an IDENTIFIER, not a label: lowercase-hyphenated and equal to the directory, per both
harnesses' rules. The human name is `title`.
This generator emitted `name: Officer platform` and was missed when c24859c fixed every skill by
hand — so the file on disk was right and running this script put the old shape back. `docs:check`
then failed on a line count that nobody could fix by regenerating, which is the worst place for a
drift check to point.
*/
out('name: platform');
out('title: Officer platform');
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
out('description: >-');
+14 -7
View File
@@ -69,14 +69,21 @@ const git = (dir: string, args: string[]): string | null => {
/*
Which plugins directory to publish FROM.
There are two, and they are not the same thing. `platform/plugins/` holds what this machine has
INSTALLED — updated by `plugins:update`, which is a git pull, and therefore always at whatever version
was last installed. `officer-dev/plugins/` holds every repo in the org, cloned for working on, and is
where a release is actually made.
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
Publishing from the install directory means the catalogue advertises the version this machine happens
to be running rather than the version that was released. That is exactly what happened on 2026-08-27:
21 plugins were tagged and the store went on showing 1.0.0.
Publishing from it means the catalogue advertises the version this machine happens to be running
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
were tagged and the store went on showing 1.0.0.
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
working copy and published the same version. It was removed on 2026-08-28.
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
real git repo with its own tags, so the released manifest can be read out of the tag itself —
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
written yet, and until it is, this script advertises what is installed.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
+8 -3
View File
@@ -31,9 +31,14 @@ import { join } from 'node:path';
/*
Which plugins directory to check.
There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and
`officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different
sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to.
`platform/plugins/` is the one that exists. There was briefly a second — `officer-dev/plugins/`, meant
to hold every repo in the org side by side — and it was removed on 2026-08-28 once it held nothing but
symlinks into this one. A plugin cannot live outside the platform anyway: Bun resolves a symlink to its
real path, so `@@/…` and every relative import fail from there. Tested, including
`--preserve-symlinks`, which Bun accepts and ignores.
`PLUGINS_DIR` stays overridable for a checkout somewhere else, which costs nothing and is the only
reason this is not a constant.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
const HOST = 'git@gitea.officer.dev';
+11 -4
View File
@@ -82,6 +82,13 @@ LAST_SKIPPED=()
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
#
# ffmpeg is not a convenience here, it is a hard dependency of the file browser.
# Subtitles, audio extraction, audio track listing and video thumbnails all shell
# out to it, so on a machine without it those features fail at the moment somebody
# opens a folder — which reads as Officer being broken rather than as a package
# being absent. It was missing from this list until 2026-08-29 and the features
# worked anyway, because every machine that had been set up by hand already had it.
#
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
# moment a file lands instead of polling for it. Without it a watcher can only
# ask "is there anything new?" on a timer, which is both slower and more work —
@@ -109,24 +116,24 @@ pkgs_core() {
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils fail2ban unattended-upgrades
inotify-tools poppler-utils ffmpeg fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler fail2ban
inotify-tools poppler ffmpeg fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils fail2ban
inotify-tools poppler-utils ffmpeg fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI.
#
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler ffmpeg
;;
esac
}
+20 -2
View File
@@ -405,6 +405,7 @@ if ! skip; then
info "Dependencies — bun install, as ${USERNAME}"
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
echo " sharp: $(sharp_loads && echo "loads (libvips $(sharp_version))" || echo 'does not load')"
echo ""
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
echo " so a package.json that disagrees with it fails rather than quietly"
@@ -412,12 +413,29 @@ if ! skip; then
echo ""
echo " node-pty has no Linux prebuild, so this compiles it from source"
echo " every time — which is what build-essential and python3 are for."
echo ""
echo " sharp is the opposite problem: it does not compile, it downloads a"
echo " libvips binary for this exact platform and architecture. So the"
echo " install can succeed and the module still not load, and the way that"
echo " shows up is a file browser drawing generic icons over a folder of"
echo " photographs. Loading it is the only check worth running."
if deps_installed && node_pty_built; then
ok "already installed, and node-pty is built"
if deps_installed && node_pty_built && sharp_loads; then
ok "already installed; node-pty built and sharp loads"
SUMMARY+=("Dependencies: already installed")
elif confirm "Install them?"; then
if install_deps; then
# Reported separately from node-pty rather than as one "native modules"
# verdict: they fail for different reasons and want different fixes, and a
# combined line sends somebody to rebuild the wrong one.
if ! sharp_loads; then
warn "installed, but sharp does not load"
echo " Image thumbnails in the file browser will silently fall back to"
echo " file icons. Usually the platform-specific binary did not resolve:"
echo " cd $(platform_dir) && bun install --force"
ERRORS+=("Dependencies: sharp does not load")
fi
if node_pty_built; then
ok "installed, node-pty built"
SUMMARY+=("Dependencies: installed")
+23
View File
@@ -95,4 +95,27 @@ deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
# would not start.
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
# sharp is libvips, and it arrives as a PREBUILT BINARY chosen by platform and
# architecture — @img/sharp-linux-x64 and its libvips, or the arm64 pair, or the
# musl ones. bun picks them from the lockfile's optional dependencies.
#
# Which means the install can succeed and the module can still be unusable: a
# lockfile written on x64 carries the x64 binaries, and on a machine where the
# right optional package did not resolve, `import('sharp')` throws at the first
# call rather than at install time. Same failure shape as node-pty above, and the
# same reason to check it here — the symptom otherwise is a file browser drawing
# generic icons over a folder of photographs, which nobody reads as "a native
# module is missing".
#
# Loading it is the only test worth running. A directory being present proves
# nothing; sharp's own `versions.vips` proves libvips is there and bound.
sharp_loads() {
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => { if (!m.default.versions.vips) process.exit(1) }).catch(() => process.exit(1))\"" >/dev/null 2>&1
}
# What libvips sharp found, for the report. Empty when it does not load at all.
sharp_version() {
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => console.log(m.default.versions.vips)).catch(() => {})\"" 2>/dev/null | tr -d '\r'
}
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
+11 -2
View File
@@ -8,7 +8,17 @@ import { useInitialData } from '@/state/useInitialData';
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
import { plugins as installedPlugins } from './Plugins.gen';
import { plugins as installedPlugins, pluginFileActions } from './Plugins.gen';
import { registerFileActions } from 'officerdev';
/*
Plugin actions for the file browser's context menu, handed over once at module load.
Injected rather than imported by the browser itself: `Plugins.gen.tsx` is generated INTO this app, and
the file browser lives in a workspace package this app depends on. Importing it there would point the
dependency backwards and make that package unusable outside Officer's own frontend.
*/
registerFileActions(pluginFileActions);
/**
* `/plugins` → `/marketplace`, KEEPING the query string.
@@ -107,7 +117,6 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/todos" element={<Dashboard.TodosScreen />} />
<Route path="/bots" element={<Dashboard.BotsScreen />} />
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
@@ -109,10 +109,16 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
{item.label}
</span>
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
className="w-10 h-10 md:w-12 md:h-12 rounded-xl overflow-hidden flex items-center justify-center transition-all"
style={{
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
// does not paint it.
//
// `overflow-hidden` is what makes the ROUNDING apply to artwork. A `badge` tile insets
// its glyph, so the corners were never reached and nothing here looked wrong for as
// long as every plugin used one. `bare` fills the box edge to edge, so a logo on an
// opaque background kept its own square corners and sat in the dock as the one tile
// that had not been told the shape.
background: item.tile === 'bare' ? 'transparent' : item.color,
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
}}
@@ -143,7 +143,10 @@ const Row = ({
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
// `overflow-hidden` for the same reason as the dock: `bare` artwork fills the box, so without it
// the rounding is drawn and then painted over. The two have to agree — the same plugin appears in
// both, a few pixels apart.
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md"
style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
@@ -1,12 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
export const TodosScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
};
@@ -1,8 +0,0 @@
import type { LayoutNode } from 'officerdev';
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'todo-board',
appType: 'todo-board',
};
@@ -1 +0,0 @@
export * from './TodosScreen';
@@ -21,5 +21,4 @@ export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
export * from './Bots';
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
name: string | null;
categoryId: number | null;
plugin: string | null;
pluginConfig: Record<string, string> | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherArmedAt: Date | null;
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
referencePaths: commandHqSessions.referencePaths,
name: commandHqSessions.name,
categoryId: commandHqSessions.categoryId,
plugin: commandHqSessions.plugin,
pluginConfig: commandHqSessions.pluginConfig,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherArmedAt: commandHqSessions.watcherArmedAt,
@@ -85,7 +89,16 @@ const SESSION_ROW_COLUMNS = {
createdAt: commandHqSessions.createdAt,
};
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
export type NewCommander = {
userId: number;
name: string;
referencePath: string;
categoryId?: number | null;
/** The plugin whose template this was made from, or null for one somebody made themselves. */
plugin?: string | null;
/** The owner's answers to that template's inputs. Null when there was no template. */
pluginConfig?: Record<string, string> | null;
};
/**
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
@@ -109,6 +122,8 @@ export async function createCommander(input: NewCommander): Promise<CommandHqSes
referencePaths: [input.referencePath],
name: input.name,
categoryId: input.categoryId ?? null,
plugin: input.plugin ?? null,
pluginConfig: input.pluginConfig ?? null,
})
.returning({ id: commandHqSessions.id });
const [row] = await tx
@@ -155,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
export async function listCommandHqSessions(
userId: number,
categoryId?: number | null,
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
plugin?: string,
): Promise<CommandHqSessionRow[]> {
const where =
categoryId === undefined
const where = plugin
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
: categoryId === undefined
? eq(commandHqSessions.userId, userId)
: categoryId === null
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
@@ -257,6 +275,33 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
* longer the current one.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
/*
A commander made from a plugin's template cannot arm until it has been configured.
Enforced HERE rather than in a route because arming is not a button: it happens when the agent
starts a tracked background job, and the platform notices. A check anywhere else would be a check
the agent could route around simply by starting its loop, which is exactly what it does.
Creating a half-configured commander stays fine and useful — it exists, it is visible, and it can be
finished later. It is ARMING one that would run somebody's disk against a blank setting.
A row with no template is unaffected: `plugin` is null and this returns immediately.
*/
const [existing] = await db
.select({ plugin: commandHqSessions.plugin, pluginConfig: commandHqSessions.pluginConfig })
.from(commandHqSessions)
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.limit(1);
if (existing?.plugin) {
// Only the presence of answers is checked here, not which ones are required — that needs the
// template, which is a filesystem read and does not belong in the query layer. The route that
// creates the commander writes every input it was given, so an empty object means nothing was
// configured at all, which is the case worth stopping.
const answered = Object.values(existing.pluginConfig ?? {}).filter((v) => String(v ?? '').trim());
if (answered.length === 0) return false;
}
const rows = await db
.update(commandHqSessions)
.set({
@@ -3,6 +3,7 @@ import {
doublePrecision,
index,
integer,
jsonb,
pgTable,
serial,
text,
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
* its own, and an array supports appending in place (`array_append`) without reading the whole column
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
*/
referencePaths: text('reference_paths')
.array()
.notNull()
.default([]),
referencePaths: text('reference_paths').array().notNull().default([]),
/**
* What the owner called it, held here rather than only in the transcript's summary record.
*
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
watcherArmed: boolean('watcher_armed').notNull().default(false),
/**
* The plugin that shipped this commander's template, or null for one somebody made themselves.
*
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
* provenance should be structural. A category called "av-club" is a string an owner can rename,
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
* with nothing on disk saying otherwise.
*
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
* still in its directory, and the UI can say what it came from rather than pretending it was
* always hand-made.
*/
plugin: text('plugin'),
/**
* The owner's answers to the template's `inputs`, or null when there was no template.
*
* Held here rather than only in the generated watcher script because the form has to be
* re-openable: somebody changing which directory is watched should see what it is watching now,
* not an empty form. The script is generated FROM this, so this is the source of truth.
*/
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
/**
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
+15
View File
@@ -5,6 +5,8 @@ import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertPermissionTotality } from './servers/permissions/totality';
import { bootstrapCaldav } from './servers/caldav-bootstrap';
import { registerThumbnailWarmer } from './servers/events/warm-thumbnails';
import { registerPluginHooks } from './servers/events/plugin-hooks';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { listPluginRootClaims } from './servers/plugins/public-paths';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
@@ -252,6 +254,19 @@ if (secured.repaired.length) {
}
for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`);
/*
Subscribe to platform events before anything can emit one.
Both are pure registration — no I/O, nothing that can fail — so they sit above the plugin mount rather
than inside its try. If either threw, the server not starting would be the correct outcome, and
wrapping them in the plugin block's `catch` would hide that.
The thumbnail warmer is core subscribing to core: the cache belongs to the platform, so the work has to
happen here. Plugin hooks are the general mechanism, resolved per event from whatever is installed.
*/
registerThumbnailWarmer();
registerPluginHooks();
// Mount whatever is installed, BEFORE serve().
//
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,
@@ -0,0 +1,170 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { capabilitySources } from '../../capability-sources';
// Bun's own YAML, the same one `task-files.ts` uses. Not the `yaml` package — it is not a dependency,
// and adding one to read four lines of frontmatter is not a trade worth making.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
/**
* Commanders a plugin ships, ready to be created but not running.
*
* ── Presence is the declaration ──
*
* A plugin with a `commanders/<name>/COMMANDER.md` offers one. Nothing is added to `manifest.ts`, for
* the same reason `tasks/` and `skills/` are not declared there: the directory already says it, and a
* second place to say it is a second place for the two to disagree.
*
* Read IN PLACE, and only from installed-and-enabled plugins — `capabilitySources` handles both. So a
* disabled plugin stops offering its commander, and `plugins:update` changes the template without
* anything having to be republished.
*
* ── A template is not a commander ──
*
* What is here is an OFFER. Nothing runs, nothing is scheduled, and no directory exists until the
* owner creates one from it and fills in the inputs. That is the whole shape the owner asked for:
* a plugin may propose recurring work; only a person may start it.
*
* ── Why the inputs reuse TASK.md's vocabulary ──
*
* `inputs:` here is the same structure a task declares, so the same form renderer draws it and the
* same validation applies. A commander asking "which folder, which extensions, what output" is asking
* exactly what a task asks, and inventing a second schema for it would mean two form renderers to
* keep in step for no gain.
*/
export type CommanderInput = {
type: string;
description?: string;
default?: string;
options?: string[];
/** Unset and required means the commander cannot arm. See `missingInputs`. */
required?: boolean;
};
export type PluginCommanderTemplate = {
/** Directory name, unique within its plugin — `heic-to-webp`. */
dirName: string;
/** The plugin that ships it. Never null: an owner's own commanders do not come from a template. */
plugin: string;
name: string;
description: string;
/** What it watches, in a sentence, for the card. */
watches?: string;
inputs: Record<string, CommanderInput>;
/** The prose that becomes the commander's `CLAUDE.md`. */
body: string;
/** Absolute path to the template directory, so the watcher script can be read at creation. */
dir: string;
};
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { fm: {}, body: raw };
try {
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]! };
} catch {
// A template with unparseable frontmatter is skipped by the caller rather than crashing the list —
// one plugin's bad YAML must not take away every other plugin's commanders.
return { fm: {}, body: match[2]! };
}
}
export async function listPluginCommanders(): Promise<PluginCommanderTemplate[]> {
const sources = await capabilitySources('commanders');
const found: PluginCommanderTemplate[] = [];
for (const source of sources) {
// Only plugins offer these. The owner's own `capabilities/commanders/` is not a thing that exists:
// a commander somebody makes themselves is made in the UI, not from a template on disk.
if (!source.plugin) continue;
let entries;
try {
entries = await readdir(source.dir, { withFileTypes: true });
} catch {
continue; // most plugins ship none — ordinary, not a fault
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = join(source.dir, entry.name);
const file = Bun.file(join(dir, 'COMMANDER.md'));
if (!(await file.exists())) continue;
const { fm, body } = parse(await file.text());
if (!fm.name) continue; // no name means the frontmatter did not parse — skip it quietly
found.push({
dirName: entry.name,
plugin: source.plugin,
name: String(fm.name),
description: String(fm.description ?? ''),
watches: fm.watches ? String(fm.watches) : undefined,
inputs: (fm.inputs as Record<string, CommanderInput>) ?? {},
body,
dir,
});
}
}
return found.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getPluginCommander(plugin: string, dirName: string): Promise<PluginCommanderTemplate | null> {
// Both halves reach this from a request body and are joined onto a path below.
const unsafe = (v: string) => !v || v.includes('/') || v.includes('\\') || v === '.' || v === '..';
if (unsafe(plugin) || unsafe(dirName)) return null;
const all = await listPluginCommanders();
return all.find((t) => t.plugin === plugin && t.dirName === dirName) ?? null;
}
/**
* Which required inputs have no answer.
*
* This is the "configure before running" rule, and it is enforced at ARM time rather than at creation.
* Creating a half-configured commander is fine and useful — it exists, it is visible, and it can be
* finished later. Arming one is what would run somebody's disk against a blank setting.
*/
export function missingInputs(
inputs: Record<string, CommanderInput>,
config: Record<string, string> | null | undefined,
): string[] {
return Object.entries(inputs)
.filter(([key, spec]) => spec.required && !(config?.[key] ?? spec.default ?? '').trim())
.map(([key]) => key);
}
/**
* Everything a created commander needs on disk beyond its `CLAUDE.md`.
*
* ── `config.env`, not a JSON file ──
*
* The watcher is a shell script and this is what it sources. A JSON config would mean the script
* parsing JSON in bash, which is either `jq` (another dependency for four values) or something
* hand-rolled that breaks on the first path with a space in it. The database row remains the source
* of truth; this is a projection of it that bash can read.
*
* ── Every value is quoted, and that is not decoration ──
*
* `WATCH_DIR` is a path the owner typed, and this fleet has directories called `ABOUT US` and
* `70´S`. Unquoted, the first space ends the assignment and the watcher silently watches the wrong
* folder — or, with `rm` involved downstream, the wrong folder's contents.
*/
export function configEnv(
inputs: Record<string, CommanderInput>,
config: Record<string, string> | null | undefined,
): string {
const lines = ['# Written by Officer when this commander was created. Edit through the UI, not here.'];
for (const [key, spec] of Object.entries(inputs)) {
const value = config?.[key] ?? spec.default ?? '';
// Single quotes, with any embedded single quote closed and re-opened around an escaped one — the
// standard shell trick, and the only form that is safe for arbitrary text.
const quoted = `'${String(value).replace(/'/g, `'\\''`)}'`;
lines.push(`${key.toUpperCase()}=${quoted}`);
}
return lines.join('\n') + '\n';
}
+146 -6
View File
@@ -1,11 +1,12 @@
import type { Context } from 'hono';
import { randomUUID } from 'node:crypto';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { checkBudget } from '../sub-agents/budget';
import * as errors from '@@/custom-errors';
import { resolveHomeDir } from '@@/user-home';
import { toShellUsername, getCommanderDir } from '@@/data-path';
import { toShellUsername, getCommanderDir, getCommanderStateDir } from '@@/data-path';
import {
forgetChatSessions,
getChatEventsSince,
@@ -17,6 +18,7 @@ import * as sidecar from '@@/sidecar-registry';
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
import { sessionKeyForTranscript } from '../chat/websocket';
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
import { configEnv, getPluginCommander, listPluginCommanders, missingInputs } from './plugin-commanders';
import { createCommanderDirectory } from './directory';
import { commanderKeyName } from './credential';
import type { ServerMessage } from '../chat/types';
@@ -201,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
watcherLastFiredAt: Date | null;
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
cost: CommandHqCostSummary;
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
plugin?: string | null;
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
pluginConfig?: Record<string, string> | null;
/**
* Created, never opened — so there is no transcript behind it and its id is not a session id.
*
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
*/
pending?: boolean;
};
async function isRunning(sessionId: string): Promise<boolean> {
@@ -222,7 +235,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const categoryParam = ctx.req.query('category');
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
/*
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
which matches nothing and looks exactly like "this plugin has no commanders".
*/
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
const categoryId =
pluginFilter || categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
/*
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
@@ -235,7 +256,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
*/
const rows = await listCommandHqSessions(user.id, categoryId);
const rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
const withTranscript = rows.filter((row) => row.sessionId !== null);
const summaries = withTranscript
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
@@ -258,6 +279,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
),
]);
/*
A commander CREATED but never opened has no transcript, and every branch above drops it.
That was right when the only way to get a row was to start a conversation. It is wrong now: a
commander made from a plugin's template arrives deliberately not running, and dropping it means the
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
conversation failed to start, and a tombstone for it is what the design deliberately refused.
*/
const pending = rows
.filter((row) => row.sessionId === null && row.plugin)
.map(
(row) =>
({
id: `pending-${row.id}`,
title: row.name ?? 'Commander',
dirName: row.dirName,
categoryId: row.categoryId,
tagIds: [],
running: false,
watcherArmed: false,
watcherArmedAt: null,
watcherStoppedAt: null,
watcherLastFiredAt: null,
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
plugin: row.plugin,
pluginConfig: row.pluginConfig,
pending: true,
}) as unknown as CommanderSummary,
);
const commanders: CommanderSummary[] = filed
.map((row) => ({
...liveById.get(row.sessionId!)!,
@@ -277,6 +331,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
// beside the transcript.
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
// it from a category name somebody can rename.
plugin: row.plugin,
pluginConfig: row.pluginConfig,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
@@ -288,7 +346,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ commanders });
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
return ctx.json({ commanders: [...pending, ...commanders] });
});
/**
@@ -463,6 +523,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
categoryId: row.categoryId,
tagIds: tagsByRow.get(row.id) ?? [],
running,
plugin: row.plugin,
pluginConfig: row.pluginConfig,
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
watcherArmedAt: row.watcherArmedAt,
@@ -525,6 +587,21 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
* design removes — a client that has to remember to follow through, on a value the server already knows
* first.
*/
/**
* What the installed plugins OFFER. Not commanders — templates for them.
*
* Separate from `GET /commanders`, which lists what exists. Merging the two would put a thing that
* runs and a thing that could be made in one list, distinguished by a flag, and every consumer would
* have to remember which it was holding.
*/
commandHqRouter.get('/plugin-commanders', async (ctx) => {
const templates = await listPluginCommanders();
// The body is the commander's future CLAUDE.md and can be pages long; the list does not need it.
return ctx.json({
templates: templates.map(({ body: _body, dir: _dir, ...rest }) => rest),
});
});
commandHqRouter.post('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
@@ -535,6 +612,11 @@ commandHqRouter.post('/commanders', async (ctx) => {
/** A chat context to watch, instead of a path — see below. */
context?: string;
contextId?: string;
/** Create from a plugin's template: the plugin's appName and the template's directory name. */
plugin?: string;
template?: string;
/** The owner's answers to that template's inputs. */
config?: Record<string, string>;
};
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
const name = body.name?.trim();
@@ -553,15 +635,73 @@ commandHqRouter.post('/commanders', async (ctx) => {
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
and an account allowed to walk the disk. The button asks for neither.
*/
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
/*
A template can supply the reference path from its own configuration.
`watch_dir` is what this kind of commander is about, so making the owner give a reference path AND a
watch directory would be asking the same question twice and inviting the two to disagree.
*/
const template = body.plugin && body.template ? await getPluginCommander(body.plugin, body.template) : null;
if (body.plugin && body.template && !template) {
throw errors.BAD_REQUEST(`No commander template "${body.template}" in plugin "${body.plugin}"`);
}
const fromConfig = template ? (body.config?.watch_dir ?? '').trim() : '';
const referencePath = body.context
? chatContextDir(body.context, body.contextId, who)
: body.referencePath?.trim() || fromConfig;
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
const row = await createCommander({
userId: user.id,
name,
referencePath,
categoryId: body.categoryId ?? null,
plugin: template?.plugin ?? null,
pluginConfig: template ? (body.config ?? {}) : null,
});
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
name,
referencePath,
userId: user.id,
});
/*
A template's own files, written after the directory exists.
Three things, and each is deliberately a COPY rather than a reference into the plugin:
state/config.env what the owner answered, in the form the watcher sources
watch.sh the loop, 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 would be able to debug
AGENTS.md the template's prose, appended to the brief already written
A failure here is reported and does not undo the commander. It exists, it is visible, and a missing
config.env is exactly what the watcher refuses to start without — which is the configure-before-
running rule enforcing itself rather than a half-created row nobody can see.
*/
let templateError: string | null = null;
if (template) {
try {
const { writeFile, copyFile, appendFile } = await import('node:fs/promises');
const stateDir = getCommanderStateDir(who.email, row.dirName);
await writeFile(join(stateDir, 'config.env'), configEnv(template.inputs, body.config), { mode: 0o600 });
const script = join(template.dir, 'watch.sh');
if (await Bun.file(script).exists()) {
await copyFile(script, join(dir, 'watch.sh'));
await (await import('node:fs/promises')).chmod(join(dir, 'watch.sh'), 0o755);
}
if (template.body.trim()) {
await appendFile(join(dir, 'AGENTS.md'), `\n\n---\n\n${template.body.trim()}\n`);
}
} catch (err) {
templateError = err instanceof Error ? err.message : 'could not write the template files';
console.warn(`[command-hq] ${row.dirName}: template not fully written — ${templateError}`);
}
}
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
if (ownershipError) {
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
@@ -0,0 +1,99 @@
import { snapshotPlugins } from '../../plugins/mount';
import { sidecarPort } from '../../sidecar-registry';
/**
* File types an installed plugin can show and the platform cannot.
*
* ── One place, so three consumers cannot disagree ──
*
* `ls` decides whether an entry promises a thumbnail, `/thumb` decides where to get one, and the viewer
* decides whether a full-size render exists. Those must be the same answer: a listing that promises a
* thumbnail nothing will produce is a request that 404s on every scroll, and a viewer that offers a
* render for a type nothing claims is a spinner that never resolves.
*
* ── Core does not know which plugin this is ──
*
* It reads `fileTypes` from whatever is installed AND ENABLED, and calls a path by name on that
* plugin's own sidecar. Nothing here imports plugin code and nothing names av-club. Disable the plugin
* and the claims vanish with it, which is what makes the file browser degrade to exactly what it was
* rather than to something broken.
*
* ── Why the sidecar directly, and not our own HTTP surface ──
*
* A loopback request back through `/api` would need a credential to satisfy the platform's own auth,
* and minting one so the server can call itself is a worse thing to have than this. `sidecarPort` is
* the port the plugin announced when it started; the proxy in front of it exists for browsers, and the
* server is already inside.
*
* ── `x-officer-user` is the whole authentication, and that is the sidecar's own design ──
*
* A plugin sidecar binds loopback and accepts that header from anything already on the machine — the
* platform is the thing that decides who may call, and by the time this runs the account gate and the
* permission gate have both passed. `sidecarFetch` below sends it because the proxy in front of the
* sidecar sends it, and a caller that skips it gets a 401 that looks like the plugin being broken.
*/
export type FileTypeClaim = {
plugin: string;
/** Absolute URL on the plugin's sidecar, or null when it did not offer that half. */
thumbnail: string | null;
render: string | null;
};
/**
* Extension → who claims it. Lowercase keys, no leading dot.
*
* Not cached: `snapshotPlugins` reads the database, and this is called on a listing that is already
* doing database work. A cache would need invalidating on install, uninstall, enable and disable —
* four events to get wrong in exchange for a lookup that is already cheap.
*/
export async function pluginFileTypes(): Promise<Map<string, FileTypeClaim>> {
const claims = new Map<string, FileTypeClaim>();
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
try {
({ states } = await snapshotPlugins());
} catch {
// The file browser still works. A database that cannot be read is a much louder problem elsewhere,
// and losing every thumbnail to it would only obscure that.
return claims;
}
for (const state of states) {
if (!state.install?.enabled) continue;
const declared = state.plugin.manifest?.fileTypes;
if (!declared?.extensions?.length) continue;
const port = sidecarPort(state.plugin.appName);
// Claimed but not running. Skipped rather than recorded, so `ls` does not promise a thumbnail that
// cannot be fetched while the sidecar is restarting.
if (!port) continue;
const base = `http://127.0.0.1:${port}`;
for (const raw of declared.extensions) {
const ext = String(raw).toLowerCase().replace(/^\./, '');
// First claim wins. Two plugins claiming one extension is a conflict nobody has yet, and picking
// arbitrarily-but-stably beats picking differently on each request.
if (!ext || claims.has(ext)) continue;
claims.set(ext, {
plugin: state.plugin.appName,
thumbnail: declared.thumbnail ? `${base}${declared.thumbnail}` : null,
render: declared.render ? `${base}${declared.render}` : null,
});
}
}
return claims;
}
/**
* Call a plugin's sidecar the way its proxy would.
*
* The email is the account the request is being made FOR, not the server — a plugin scoping anything
* per user reads this, and passing something else would be lying to it about who is asking.
*/
export async function sidecarFetch(url: string, email: string): Promise<Response> {
return fetch(url, { headers: { 'x-officer-user': email } });
}
+178 -1
View File
@@ -9,6 +9,9 @@ import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb';
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
import { emit } from '../../events/bus';
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
@@ -203,6 +206,12 @@ router.get('/ls', async (ctx) => {
}
return ctx.json({ path: '/', entries: [], reset: true });
}
/*
What a plugin can show that we cannot. Resolved ONCE per listing rather than per entry — it reads the
database, and a folder of two hundred files would otherwise ask two hundred times for one answer.
*/
const claimed = await pluginFileTypes();
const entries = await Promise.all(
names.map(async (name) => {
const fullPath = resolve(absPath, name);
@@ -210,11 +219,35 @@ router.get('/ls', async (ctx) => {
if (!isInside(rootDir, fullPath)) return null;
const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory();
return {
name,
type: s.isDirectory() ? 'directory' : 'file',
type: isDirectory ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
/*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire.
The client had no way to know without its own copy, and the copies would have drifted — the
browser's `NATIVE_VIDEO_EXTS` is deliberately only the formats a <video> tag can play, which
is a different question from what ffmpeg can take a frame out of, and every .mkv in the
folder would have gone without a picture for that reason alone.
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
*/
/*
Core's own answer first, then a plugin's claim.
A file only says `thumb` when something can actually produce one: core for the formats sharp
and ffmpeg read, a plugin for what it declared in `fileTypes`. Without that second half, `.heic`
promised a thumbnail nothing would make and cost a 422 on every scroll — which is why core
stopped claiming it.
*/
thumb: isDirectory
? null
: (thumbnailKind(name) ??
(claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase()) ? 'image' : null)),
};
}),
);
@@ -301,6 +334,7 @@ router.post('/upload', async (ctx) => {
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
create is a worse trade than a fragment somebody can see.
*/
const written: string[] = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const filePath = resolve(targetDir, file.name);
@@ -308,8 +342,19 @@ router.post('/upload', async (ctx) => {
await mkdir(dirname(filePath), { recursive: true });
await writeAtomic(filePath, file);
written.push(filePath);
}
/*
Announced AFTER the writes and BEFORE the response, which is the only ordering that is correct.
After, because a subscriber reads these from disk and they have to be there. Before returning is
fine because `emit` schedules and returns — the upload does not wait for anybody. What it must never
become is an await: a subscriber thumbnailing a HEIC would add five seconds to an upload that has
already finished.
*/
emit('files:uploaded', { paths: written, email: user.email });
return ctx.json({ ok: true });
});
@@ -333,6 +378,119 @@ router.post('/rename', async (ctx) => {
// Serve raw file (binary-safe, for audio/video/images/download)
// Supports Range requests for audio/video seeking
/**
* A small JPEG of an image or a video, for the browser's grid and list.
*
* ── Why not just point <img> at `/raw` ──
*
* Because a directory of photographs is then a directory of full-size downloads. Thirty 8MB images to
* draw thirty 80px squares is a quarter of a gigabyte over the wire to show something the size of a
* postage stamp, and it is worse on a phone, which is where it matters. Video cannot be done that way
* at all — there is no frame until something decodes one.
*
* ── Authenticated by `?token=`, like `/raw` ──
*
* An `<img>` cannot send an Authorization header. `user-middleware` already accepts the token in the
* query for exactly this reason, so this route needs no special handling — and it stays behind the same
* gate as everything else here rather than becoming a public URL that happens to be hard to guess.
*
* ── `immutable`, honestly ──
*
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
* different file is a different URL. That is what makes a year-long cache correct rather than a bet.
*/
router.get('/thumb', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const size = clampSize(ctx.req.query('size'));
const kind = thumbnailKind(relPath);
/*
A type core cannot read, but an installed plugin can.
Forwarded to the plugin's own sidecar and cached HERE, under the same key everything else uses —
so a HEIC thumbnail is produced once and then served from disk like any other, and the plugin is
not asked again on every scroll.
`buildThumbnail` is given the fetch rather than a path: the caching, the concurrency limit and the
remembered failures are all in that one function, and a second copy of them here for this one case
is how the two would come to behave differently.
*/
if (!kind) {
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
if (claimed?.thumbnail) {
const fetched = await buildThumbnail(absPath, getUserDataDir(user.email), 'image', size, async () => {
const res = await sidecarFetch(
`${claimed.thumbnail}?path=${encodeURIComponent(absPath)}&size=${size}`,
user.email,
);
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
});
if (!fetched.ok) return ctx.text(fetched.reason, 422);
return new Response(Bun.file(fetched.path), {
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=31536000, immutable' },
});
}
// 415 rather than 404: the file is there, it is simply not something anything here can read. The
// client does not ask for these, so this answers a stale tab rather than normal use.
return ctx.text('no thumbnail for this file type', 415);
}
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, size);
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
// the file, and logging it as a server error would bury the ones that are.
if (!result.ok) return ctx.text(result.reason, 422);
return new Response(Bun.file(result.path), {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=31536000, immutable',
},
});
});
/**
* A displayable version of a file the browser cannot show itself.
*
* Only for types an installed plugin CLAIMS — everything a browser can already decode keeps going
* through `/raw`, which streams bytes, supports ranges and lets the browser render progressively. This
* route exists for exactly one situation: the file is an image, and no browser can display it.
*
* Not cached on disk like `/thumb`. A full-size render is large, viewed once, and keyed on a width the
* viewer chooses — caching it would fill the disk to make the second look at one photograph faster. The
* short `max-age` lets the browser hold it while somebody zooms.
*/
router.get('/render', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
// 415 and not 404: the file is there. Nothing installed can show it, which is a different answer and
// the one the viewer needs in order to say so rather than spin.
if (!claimed?.render) return ctx.text('nothing installed can display this file type', 415);
const asked = Number(ctx.req.query('width'));
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 2048;
const res = await sidecarFetch(`${claimed.render}?path=${encodeURIComponent(absPath)}&width=${width}`, user.email);
if (!res.ok) return ctx.text(`${claimed.plugin} could not read this file`, 422);
return new Response(await res.arrayBuffer(), {
headers: {
'Content-Type': res.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'private, max-age=300',
},
});
});
router.get('/raw', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
@@ -1090,6 +1248,25 @@ router.post('/extract', async (ctx) => {
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
}
/*
What actually came out, walked once rather than guessed from the archive listing.
Bounded, because an archive can hold a hundred thousand files and this runs on the request's way
out. The warmer has its own cap too; this one keeps the WALK from being the expensive part.
*/
const unpacked: string[] = [];
const collect = async (dir: string, depth = 0): Promise<void> => {
if (depth > 8 || unpacked.length >= 500) return;
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
if (unpacked.length >= 500) return;
const full = join(dir, entry.name);
if (entry.isDirectory()) await collect(full, depth + 1);
else unpacked.push(full);
}
};
await collect(finalDest);
emit('archive:extracted', { paths: unpacked, email: user.email });
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ extractedPath });
});
+321
View File
@@ -0,0 +1,321 @@
import { createHash } from 'node:crypto';
import { mkdir, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
import sharp from 'sharp';
/**
* Thumbnails for the file browser — one small JPEG per file, made once and cached.
*
* ── sharp for images, ffmpeg for video ──
*
* Each does the job it is actually for. sharp is libvips in-process: no process to spawn, no pipe, and
* an order of magnitude less work for the common case, which is a folder of photographs. A video has no
* frame until something decodes one, and that is ffmpeg's job and nobody else's.
*
* sharp was deliberately NOT used when this was first written, because it sat in `node_modules` only as
* somebody else's transitive dependency and importing it would have been an undeclared dependency —
* the thing the frozen lockfile exists to prevent. It is now a declared one, added through the
* procedure `bunfig.toml` describes, so this note is history rather than a caveat.
*
* Both are infrastructure and both are checked by the setup script, because a machine missing either
* fails at the point somebody opens a folder rather than at the point it was built.
*
* ── The cache key contains mtime and size, and that is what makes it safe ──
*
* A path alone would be wrong: replace a photo with a different one under the same name and every
* screen keeps showing the old thumbnail, with nothing to clear it but a person who knows the cache
* exists. With mtime and size in the hash, a changed file simply has a different key — the old entry
* is orphaned rather than stale, and the response can be `immutable` because that URL's answer genuinely
* never changes.
*
* ── Failures are remembered ──
*
* A corrupt file, a codec ffmpeg does not have, a zero-byte download: each would otherwise spawn a
* process on every render, forever, for a file that will never produce a thumbnail. The browser retries
* a broken <img> whenever it re-enters the viewport, so "no cache on failure" means a directory with one
* bad video runs ffmpeg every time somebody scrolls past it.
*/
/**
* What the client is allowed to ask about. Anything else is answered 415 rather than attempted.
*
* The image list grew when sharp arrived: libvips reads HEIC and SVG, which ffmpeg does not, and those
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
* file icons was the most visible gap this had.
*/
/*
No `heic` or `heif`, and that is not an oversight.
sharp's bundled libheif is built without the HEVC decoder, so it parses a HEIC's header and then
fails on the pixels. Listing them here made `ls` promise a thumbnail core cannot produce: every HEIC
cost a request that 422'd before falling back to the icon, and the promise was the only reason the
request happened at all.
Decoding them needs ImageMagick and `libheif-plugin-libde265` — a system dependency the core has no
business acquiring for one format. That belongs to a plugin, and av-club already does it.
*/
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg']);
/*
`ts` is NOT here, and that is the one entry worth explaining.
It is a real video extension — MPEG transport stream — and it was in this list until a deploy to fofos
reported `test-setup.ts` as a video. On any machine Officer runs on, `.ts` is TypeScript approximately
always, so including it meant every source file in every repository drew a play badge and spawned 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. Nobody stores broadcast captures in a
folder they browse from here.
*/
const VIDEO_EXT = new Set([
'mp4',
'mov',
'm4v',
'webm',
'mkv',
'avi',
'wmv',
'flv',
'mpg',
'mpeg',
'3gp',
'm2ts',
'mts',
]);
export const thumbnailKind = (name: string): 'image' | 'video' | null => {
const ext = extname(name).slice(1).toLowerCase();
if (IMAGE_EXT.has(ext)) return 'image';
if (VIDEO_EXT.has(ext)) return 'video';
return null;
};
/** Bigger than the grid tile draws, so a retina screen is not resampling upwards. */
const MAX_SIZE = 512;
const DEFAULT_SIZE = 256;
export const clampSize = (raw: string | undefined): number => {
const asked = Number(raw);
if (!Number.isFinite(asked)) return DEFAULT_SIZE;
// Snapped to a step, so a panel resizing by a pixel cannot mint a hundred cache entries of one image.
return Math.min(MAX_SIZE, Math.max(64, Math.round(asked / 64) * 64));
};
/*
How many thumbnail jobs may run at once.
The grid is virtualised, so only what is on screen is requested — but scrolling fast through a folder
queues far more than is visible, and each item either spawns a decoder or turns libvips loose on a
40-megapixel photograph. Four keeps a browse responsive on a small box; unbounded turned a 1.9GB
machine into a swap storm once already, which is the whole reason there is a number here.
It covers BOTH paths deliberately, even though sharp is in-process and ffmpeg is not. The limit is
about the machine, not about the mechanism: libvips uses its own thread pool per image, so a hundred
concurrent resizes is no gentler than a hundred processes.
*/
const MAX_CONCURRENT = 4;
let running = 0;
const waiting: Array<() => void> = [];
const acquire = async (): Promise<void> => {
if (running < MAX_CONCURRENT) {
running += 1;
return;
}
await new Promise<void>((resolve) => waiting.push(resolve));
running += 1;
};
const release = () => {
running -= 1;
waiting.shift()?.();
};
/**
* Paths that will never produce a thumbnail, by cache key — so a failure is not retried on every scroll.
*
* In memory rather than on disk: it is cheap to rebuild, and a restart is the natural moment to give a
* file another chance after somebody has installed a codec or replaced the file. Bounded, because a
* long-lived process browsing a lot of broken media should not grow without limit.
*/
/**
* Builds in flight, by cache key — so the same thumbnail is never made twice at once.
*
* Two callers want the same file all the time now: the warmer starts on upload, and the browser asks
* for it a moment later. Without this they both decode the same 4032x3024 HEIC, take a slot each from a
* limit of four, and finish at the same time having done the work twice — which turns a warmer meant to
* help into one competing with the person waiting.
*
* Keyed on the same string the cache is, so it deduplicates exactly what would collide: same file, same
* size, same content. Removed in a `finally`, or one failure would wedge that key until a restart.
*/
const inFlight = new Map<string, Promise<ThumbnailResult>>();
const failed = new Set<string>();
const FAILED_LIMIT = 5_000;
const rememberFailure = (key: string) => {
if (failed.size >= FAILED_LIMIT) failed.clear();
failed.add(key);
};
/** A frame from a second in, so a video that opens on black does not thumbnail as a black square. */
const SEEK_SECONDS = '1';
/**
* Run ffmpeg and report whether a FRAME CAME OUT — which is not the same as whether it succeeded.
*
* ffmpeg exits 0 having written a zero-byte file when the seek lands past the end of the input.
* Observed on a 0.4s clip: `-ss 1` gave exit 0 and 0 bytes, and the same command without the seek gave
* exit 0 and a real JPEG. So an exit code cannot decide whether to retry, and the earlier version of
* this that trusted one reported every short video as having no thumbnail.
*
* The output file is the test, here and at the end. There is only one question worth asking of an
* image encoder and it is whether there is an image.
*/
const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> => {
const proc = Bun.spawn(['ffmpeg', ...args], { stdout: 'ignore', stderr: 'ignore' });
// 20s is generous for one frame and short enough that a wedged decode does not hold a slot forever.
const timer = setTimeout(() => proc.kill(), 20_000);
await proc.exited;
clearTimeout(timer);
const written = await stat(output).catch(() => null);
return written !== null && written.size > 0;
};
/**
* One image, resized to fit the box.
*
* `fit: 'inside'` with `withoutEnlargement` matches what the ffmpeg path does — fit without cropping,
* and never scale a 40px icon up to 256, which looks worse than leaving it small.
*
* `failOn: 'none'` is deliberate and is the difference between a browsable folder and a wall of icons.
* libvips defaults to rejecting a file with any warning in it, and a truncated JPEG — the ordinary
* result of an interrupted copy or a phone that ran out of battery mid-write — is exactly that. Every
* viewer on earth will show what there is of such a file, so refusing to thumbnail it would be this
* browser being stricter than the thing somebody opens the file with.
*
* `limitInputPixels: false` for the same reason applied to size: libvips refuses images past a default
* pixel count as a denial-of-service guard, which is right for an upload endpoint and wrong here. These
* are the owner's own files, already on the owner's own disk; a panorama is not an attack.
*
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
* file, and the caller already turns that into a fallback icon and a remembered failure.
*/
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
try {
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
.rotate() // Honour the EXIF orientation, or every photo taken sideways thumbnails sideways.
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80, mozjpeg: true })
.toFile(output);
return true;
} catch {
return false;
}
};
export type ThumbnailResult = { ok: true; path: string } | { ok: false; reason: string };
/**
* Produce (or find) the thumbnail for one file, and return where it is on disk.
*
* `userDataDir` is the caller's own data directory — the cache is per user, because the path being
* thumbnailed is resolved against that user's root and two users' `/photos/a.jpg` are different files.
*/
export async function buildThumbnail(
absPath: string,
userDataDir: string,
kind: 'image' | 'video',
size: number,
/**
* Produce the bytes yourself instead of letting sharp or ffmpeg try.
*
* For a type core cannot read but an installed plugin can — see `plugin-file-types.ts`. It is passed
* IN rather than handled by a second function so that everything around it is shared: the cache key
* with its mtime and size, the concurrency limit, and the remembered failures. A parallel path for
* plugin thumbnails would be a second cache to keep correct and a second place to forget the
* negative caching, which is the part that stops one bad file spawning work on every scroll.
*/
produce?: () => Promise<Buffer | null>,
): Promise<ThumbnailResult> {
const info = await stat(absPath).catch(() => null);
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
const key = createHash('sha256').update(`${absPath}\0${info.mtimeMs}\0${info.size}\0${size}`).digest('hex');
if (failed.has(key)) return { ok: false, reason: 'no thumbnail for this file' };
/*
Two levels of fan-out on the first byte of the hash. A single flat directory of thumbnails is fine
until it is forty thousand entries, at which point `readdir` on it — which any backup or sync will
do — becomes the slowest thing on the machine.
*/
const cacheDir = join(userDataDir, 'cache', 'thumbs', key.slice(0, 2), key.slice(2, 4));
const cachePath = join(cacheDir, `${key}.jpg`);
const cached = await stat(cachePath).catch(() => null);
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
// Somebody else is already making this one. Wait for theirs rather than starting a second.
const running = inFlight.get(key);
if (running) return running;
const work = (async (): Promise<ThumbnailResult> => {
await mkdir(cacheDir, { recursive: true });
await acquire();
try {
/*
`force_original_aspect_ratio=decrease` fits inside the box without cropping, and never enlarges
past the source — a 40px icon blown up to 256 is worse than a small one. `-2` on the odd dimension
keeps it even, which the JPEG encoder requires for chroma subsampling.
*/
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
let ok: boolean;
if (produce) {
const bytes = await produce().catch(() => null);
if (bytes?.length) await Bun.write(cachePath, bytes);
ok = !!bytes?.length;
} else if (kind === 'image') {
ok = await resizeImage(absPath, cachePath, size);
} else {
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
// broken file. Take the first frame instead before giving up on it.
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
}
if (!ok) {
/*
A PRODUCER'S FAILURE IS NOT REMEMBERED, and that distinction is the whole point of this branch.
When sharp or ffmpeg fails, the file is the problem — it will fail identically forever, and
remembering that is what stops one bad video spawning work on every scroll.
A producer is a PLUGIN, in another process, reachable over a socket. It fails while it restarts,
while it is busy behind its own concurrency limit, and for the few seconds after an update. Those
all pass. Caching them meant a folder of HEIC came back as file icons and stayed that way until
the SERVER was restarted — no client retry could ever get past it, because the answer was being
served from memory without the plugin being asked again.
*/
if (!produce) rememberFailure(key);
return { ok: false, reason: 'could not read an image from this file' };
}
return { ok: true, path: cachePath };
} finally {
release();
}
})();
inFlight.set(key, work);
try {
return await work;
} finally {
inFlight.delete(key);
}
}
+100
View File
@@ -6,6 +6,9 @@ import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
import { findOrphanTables } from '../../plugins/orphans';
import { fetchPlugin } from '../../plugins/fetch';
import { discoverPlugins } from '../../plugins/discover';
import { listPluginInstalls } from 'officerdb';
import { iconUrl } from '../../app-store/assets';
import {
installPlugin,
@@ -224,6 +227,103 @@ pluginsRouter.post('/update-all', async (ctx) => {
});
});
/**
* `https://host/owner/repo` or `…/repo.git` → `repo`.
*
* Derived rather than asked for, so the name and the address cannot disagree. `appName` IS the directory
* a plugin sits in — never declared in a manifest — so this string becomes the route segment, the table
* prefix and the sidecar suffix. Checked before anything is cloned: a half-clone under an unusable name
* reads as a BROKEN plugin rather than an absent one, which is much harder to get out of.
*/
function pluginNameFromUrl(url: string, override?: string): { appName: string } | { error: string } {
const derived = url
.replace(/\.git$/, '')
.replace(/\/+$/, '')
.split('/')
.pop();
const appName = override?.trim() || derived;
if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) {
return {
error:
`"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` +
'it becomes the directory, the route and the table prefix.',
};
}
return { appName };
}
/*
POST /plugins/fetch-from-url — put a plugin on the machine, and stop there.
The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend"
had no route. The first real case is one that must never be listed: fofos-admin is a single website's
admin panel.
── Why this FETCHES and does not install ──
It was an install first, and that was wrong in a way only using it showed: the button lives on the
grid, the progress log lives in the detail panel, so a URL install ran to completion with nothing on
screen and the plugin simply appeared, installed. Streaming into a view that cannot render it is worse
than not streaming.
Cloning alone is enough to make a plugin real — `/api/plugins` lists what is ON DISK with
`installed: false`, so it shows up as Available like any catalogue entry. From there the existing card,
the existing Install button and the existing streamed progress do the rest. One flow, not two.
── The name still comes from the URL ──
`appName` IS the directory, so the repository name decides the route segment, the table prefix and the
process name. Checked before cloning: a half-clone under an unusable name reads as a BROKEN plugin
rather than an absent one.
Owner-only like everything else here, and worth saying plainly — this puts somebody's code on the
machine. It does not run it, which is exactly why installing stays a separate, deliberate click.
*/
pluginsRouter.post('/fetch-from-url', async (ctx) => {
type FromUrl = { url?: string; appName?: string };
const body = await ctx.req.json<FromUrl>().catch(() => ({}) as FromUrl);
const url = body.url?.trim();
if (!url) throw errors.BAD_REQUEST('url is required');
const named = pluginNameFromUrl(url, body.appName);
if ('error' in named) throw errors.BAD_REQUEST(named.error);
const { appName } = named;
const fetched = await fetchPlugin(appName, undefined, url);
if (!fetched.ok) throw errors.BAD_REQUEST(fetched.error ?? `could not fetch ${appName}`);
/*
Re-read from disk rather than trusting the clone.
`fetchPlugin` checks that `manifest.ts` EXISTS; discovery checks that it PARSES and says what a
plugin needs to say. A directory that fails the second test still renders in the grid — as broken,
which is the honest state — but saying so here means the person hears it while they are still
looking at the field they pasted into.
*/
const { plugins, broken } = await discoverPlugins();
const found = plugins.find((entry) => entry.appName === appName);
const bad = broken.find((entry) => entry.appName === appName);
if (!found) {
return ctx.json(
{ ok: false, appName, error: bad?.error ?? `cloned "${appName}", but it does not read as a plugin` },
400,
);
}
/*
Whether it is ALREADY installed, so the UI can say the true thing and show the right tab.
A directory and an install row are separate facts — deleting a plugin's source leaves the row, so a
re-fetch can land on something the platform still considers installed. Reported here because the
caller has just been told "added" and needs to know where to look; "click it to install" pointing at
an empty tab is worse than no message.
*/
const installs = await listPluginInstalls();
const installed = installs.some((entry) => entry.appName === appName && entry.enabled);
return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName, installed });
});
pluginsRouter.post('/:appName/install', async (ctx) => {
const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
return ctx.json(result, result.ok ? 200 : 400);
-93
View File
@@ -1,93 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree';
/*
/api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md).
The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced
between machines by git, and a second store would be a second truth. So this router is thin — read the
tree, splice frontmatter — and everything it writes is a small, reviewable git diff.
Two rules from the format's contract, enforced here rather than trusted to callers:
- The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it
stops at the closing fence.
- `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot
drift from the tree — the board never edits it directly.
── Owner only, explicitly ──
`todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner.
This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into
members reading — or editing — the platform's own backlog.
*/
export const todosRouter = createRouter();
todosRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only');
return next();
});
/** `<theme>/<slug>`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
const fileForId = (id: string): string => {
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
const [theme, slug] = id.split('/');
const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`));
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
return path;
};
/** GET /api/todos — the whole tree, no bodies. What the board renders. */
todosRouter.get('/', (ctx) => ctx.json(readTree()));
/** GET /api/todos/item?id=<theme>/<slug> — one item, body included. Query param because the id holds a slash. */
todosRouter.get('/item', (ctx) => {
const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8');
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
});
/**
* PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
* Regenerates TODO.md on every write. The body is untouchable through this API, on purpose.
*/
todosRouter.patch('/item', async (ctx) => {
const { id, status, priority, due, order } = await ctx.req.json<{
id: string;
status?: string;
priority?: string;
due?: string;
order?: number;
}>();
const path = fileForId(id);
const updates: Record<string, string> = {};
if (status !== undefined) {
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
updates.status = status;
}
// `urgent` or nothing — the format deliberately has no scale (todo/README.md).
if (priority !== undefined) {
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
updates.priority = priority;
}
if (due !== undefined) {
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
updates.due = due;
}
if (order !== undefined) {
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
updates.order = String(order);
}
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
writeIndex();
return ctx.json({ ok: true });
});
+14 -2
View File
@@ -10,8 +10,20 @@ import { join, resolve } from 'node:path';
// the closing fence.
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
//
// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos`
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
// ── This module has ONE caller now, and that is deliberate ──
//
// `scripts/todo-index.ts` — the CLI behind `bun todo:index`, which regenerates TODO.md from a terminal
// with no Officer running. The `/api/todos` router and the board are gone: they became the `kanban`
// plugin on 2026-08-29, which generalised this reader over a root so it can show any project's folder
// rather than only the one the server was started in.
//
// This copy stayed because the folder it reads is the PLATFORM'S OWN backlog, committed with the
// platform's own code. Moving it would have made regenerating this repository's TODO.md depend on a
// plugin being installed, which is the wrong way round for a repo's own documentation.
//
// So there are two copies of this logic now, and they will drift. That is a known cost, recorded here
// and in the plugin's README rather than discovered later. The honest fix, on the day it matters, is to
// delete the CLI and let the plugin own the format outright.
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
const todoDir = () => resolve(process.cwd(), 'todo');
+3 -1
View File
@@ -43,7 +43,9 @@ export type CapabilitySource = {
plugin: string | null;
};
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
export async function capabilitySources(
type: ItemType | 'affordances' | 'notifications' | 'commanders',
): Promise<CapabilitySource[]> {
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
+71
View File
@@ -0,0 +1,71 @@
import { expect, test } from 'bun:test';
import { emit, listenerCount, on } from './bus';
const settle = () => new Promise((r) => setTimeout(r, 10));
test('a listener is told, with the payload', async () => {
const seen: string[][] = [];
const off = on('files:uploaded', (p) => void seen.push(p.paths));
emit('files:uploaded', { paths: ['/a', '/b'], email: 'x@y' });
await settle();
expect(seen).toEqual([['/a', '/b']]);
off();
});
test('unsubscribing stops it', async () => {
let calls = 0;
const off = on('files:uploaded', () => void calls++);
off();
emit('files:uploaded', { paths: ['/a'], email: 'x@y' });
await settle();
expect(calls).toBe(0);
});
// The property the whole design rests on: a subscriber must never be able to fail the operation that
// triggered it. An upload is already written and answered by the time this runs.
test('emit does not throw when a listener throws synchronously', async () => {
const off = on('files:uploaded', () => {
throw new Error('boom');
});
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
await settle();
off();
});
test('emit does not throw when a listener rejects', async () => {
const off = on('files:uploaded', async () => {
throw new Error('boom');
});
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
await settle();
off();
});
// One bad subscriber must not stop the others — they are independent, not a chain.
test('a failing listener does not stop the next one', async () => {
let reached = false;
const offBad = on('archive:extracted', () => {
throw new Error('boom');
});
const offGood = on('archive:extracted', () => void (reached = true));
emit('archive:extracted', { paths: [], email: 'x@y' });
await settle();
expect(reached).toBe(true);
offBad();
offGood();
});
test('emit returns immediately rather than awaiting listeners', () => {
const off = on('files:placed', async () => {
await new Promise((r) => setTimeout(r, 200));
});
const started = performance.now();
emit('files:placed', { paths: [], email: 'x@y' });
expect(performance.now() - started).toBeLessThan(50);
off();
});
test('an event with no listeners is a no-op', () => {
expect(listenerCount('files:placed')).toBe(0);
expect(() => emit('files:placed', { paths: [], email: 'x@y' })).not.toThrow();
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Platform events, and who is told about them.
*
* ── A NAMED LIST, not an event bus ──
*
* The type below is closed on purpose. An open bus — `emit(anyString, anyPayload)` — becomes an API
* nobody can change: the moment a plugin listens for `file:thing`, that string and the shape beside it
* are a contract, and there is no list anywhere saying so. A closed union means adding an event is a
* deliberate edit here, and removing one fails to compile rather than failing quietly at runtime.
*
* ── Every payload is absolute paths and an owner ──
*
* Not a file's contents, not a request, not a user object. A subscriber that needs more should read it
* from the path, because by the time this fires the work is done and the disk is the truth. Passing
* more would be inventing a second description of something already on the filesystem.
*
* ── Emitting NEVER blocks and NEVER throws ──
*
* This is fired after a response is decided, and the response must not wait for it. An upload that
* takes five extra seconds because a subscriber is thumbnailing a HEIC is an upload somebody reports as
* broken; a failing subscriber must not turn a completed upload into an error the caller sees.
*
* So `emit` returns immediately and swallows everything. That is deliberate rather than lazy: a
* subscriber's failure is ITS failure, logged with its name, and the operation that triggered it has
* already succeeded.
*/
/** Absolute paths that now exist, and whose account they belong to. */
export type FileEventPayload = {
/** Absolute. Files, not directories — a subscriber wanting the folder can take the dirname. */
paths: string[];
/** The account the operation was performed for. */
email: string;
};
export type PlatformEvents = {
/** One or more files arrived through `POST /file-browser/upload`. */
'files:uploaded': FileEventPayload;
/** An archive was unpacked. `paths` is what came out of it. */
'archive:extracted': FileEventPayload;
/** Files were copied or moved to a new location. `paths` is where they are NOW. */
'files:placed': FileEventPayload;
};
export type EventName = keyof PlatformEvents;
type Listener<K extends EventName> = (payload: PlatformEvents[K]) => void | Promise<void>;
const listeners = new Map<EventName, Set<Listener<EventName>>>();
/**
* Subscribe. Returns the unsubscribe, which nothing currently calls — core's own subscribers live for
* the process — but returning it means a future one can be torn down without changing this.
*/
export function on<K extends EventName>(event: K, listener: Listener<K>): () => void {
const set = listeners.get(event) ?? new Set();
set.add(listener as Listener<EventName>);
listeners.set(event, set);
return () => set.delete(listener as Listener<EventName>);
}
/**
* Tell everyone, and get out of the way.
*
* Synchronous return, work scheduled after. `queueMicrotask` rather than awaiting: the caller is a
* request handler about to write a response, and the point is that it does not wait.
*/
export function emit<K extends EventName>(event: K, payload: PlatformEvents[K]): void {
const set = listeners.get(event);
if (!set?.size) return;
for (const listener of set) {
queueMicrotask(() => {
try {
// A listener returning a rejected promise is the common case — most of them are async — and it
// needs catching separately from a synchronous throw.
void Promise.resolve(listener(payload)).catch((err: unknown) => {
console.error(`[events] ${event} listener failed —`, err instanceof Error ? err.message : err);
});
} catch (err) {
console.error(`[events] ${event} listener threw —`, err instanceof Error ? err.message : err);
}
});
}
}
/** For tests: how many listeners an event has. Nothing in the server should need this. */
export const listenerCount = (event: EventName): number => listeners.get(event)?.size ?? 0;
+76
View File
@@ -0,0 +1,76 @@
import { snapshotPlugins } from '../plugins/mount';
import { sidecarPort } from '../sidecar-registry';
import { on, type EventName, type FileEventPayload } from './bus';
/**
* Letting plugins subscribe to platform events.
*
* ── The same shape as `fileTypes`, deliberately ──
*
* A manifest declares `hooks: { 'files:uploaded': '/v1/on-upload' }`. Core resolves that at emit time
* from whatever is installed and enabled, POSTs the payload to the plugin's own sidecar, and knows
* nothing about which plugin it is. Disable the plugin and the hook stops firing, with nothing to
* unregister — the same property that makes a claimed file type disappear cleanly.
*
* ── Resolved at EMIT time, not at boot ──
*
* The obvious design registers every plugin's hooks once at startup. It is wrong here: plugins are
* installed, updated, enabled and disabled while the server runs, and a registration made at boot
* describes a machine that has since changed. Reading the current set costs one database call on an
* event that has just done filesystem work, which is not the expensive part of anything.
*
* ── A hook cannot fail the operation that triggered it ──
*
* `emit` already swallows, and this adds nothing that could throw past it. The upload succeeded before
* anybody was told. A plugin that is down, slow, or returns 500 gets a line in the log and no more —
* because the alternative is an upload that reports failure for work that has nothing to do with it.
*/
/** Long enough for real work, short enough that a wedged plugin does not hold a socket all day. */
const HOOK_TIMEOUT_MS = 120_000;
async function dispatch(event: EventName, payload: FileEventPayload): Promise<void> {
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
try {
({ states } = await snapshotPlugins());
} catch {
return; // a database that cannot be read is a louder problem elsewhere
}
for (const state of states) {
if (!state.install?.enabled) continue;
const path = state.plugin.manifest?.hooks?.[event];
if (!path) continue;
const port = sidecarPort(state.plugin.appName);
// Declared but not running — restarting, or never started. Skipped silently: a plugin being down is
// not an error of the upload that happened to coincide with it.
if (!port) continue;
try {
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-officer-user': payload.email },
body: JSON.stringify({ event, ...payload }),
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS),
});
if (!res.ok) {
console.warn(`[hooks] ${state.plugin.appName} ${event} -> ${res.status}`);
}
} catch (err) {
console.warn(`[hooks] ${state.plugin.appName} ${event} failed —`, err instanceof Error ? err.message : err);
}
}
}
/**
* Wire plugin dispatch into every event. Called once, from the server's own start-up.
*
* One listener per event rather than one per plugin: which plugins care is resolved when the event
* fires, so there is nothing to add or remove as they come and go.
*/
export function registerPluginHooks(): void {
const events: EventName[] = ['files:uploaded', 'archive:extracted', 'files:placed'];
for (const event of events) on(event, (payload) => dispatch(event, payload));
}
+107
View File
@@ -0,0 +1,107 @@
import { join } from 'node:path';
import { DATA_PATH } from '../data-path';
import { buildThumbnail, thumbnailKind } from '../api/file-browser/thumbnails';
import { pluginFileTypes, sidecarFetch } from '../api/file-browser/plugin-file-types';
import { on, type FileEventPayload } from './bus';
/**
* Make the thumbnails before anybody asks for them.
*
* ── Why this is worth doing at all ──
*
* Thumbnails are generated on demand and cached. That is right — most files are never looked at — but
* it means the first person to open a folder waits for the work. For a jpeg that is 40ms nobody
* notices. For a HEIC it is four and a half seconds each, because ImageMagick decodes the whole
* 4032x3024 frame before sharp sees it. A folder of photographs from a phone is thirteen seconds of
* staring at file icons.
*
* An upload is the one moment the platform KNOWS which files are new, so it is the moment to do it.
*
* ── This is core subscribing to core, and that is the point ──
*
* The thumbnail cache belongs to the platform: keyed on its scheme, read by its route. A plugin doing
* this work would produce them somewhere the file browser never looks. So the warmer lives here and
* uses the same `buildThumbnail` the request path uses — which means a HEIC still warms through
* av-club, via the `fileTypes` claim, without this file knowing that plugin exists.
*
* ── ONE size, and it is the one both views ask for ──
*
* The cache key includes the size. Warming 128 when the browser asks for 256 is work that produces
* nothing anybody reads, so this must stay equal to what `FileItem` requests. Both views were made to
* ask for 256 for exactly this reason.
*/
const WARM_SIZE = 256;
/**
* How many to warm at once.
*
* Lower than the request path's own limit on purpose. This is background work competing with a person
* who is actually waiting: somebody opening a different folder while an upload warms must not queue
* behind it. `buildThumbnail` has its own cap too, so this is a second, tighter gate rather than the
* only one.
*/
const WARM_CONCURRENCY = 2;
/** Stop after this many. An unpacked archive can hold thousands, and warming all of them is not a favour. */
const WARM_LIMIT = 200;
async function warm(payload: FileEventPayload): Promise<void> {
const claimed = await pluginFileTypes();
// Only what will actually be asked for: core's own types plus whatever a plugin claims. Anything else
// has no thumbnail to make and would be a spawn per file for nothing.
const targets = payload.paths
.filter((path) => {
const name = path.slice(path.lastIndexOf('/') + 1);
return !!thumbnailKind(name) || claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
})
.slice(0, WARM_LIMIT);
if (targets.length === 0) return;
const userDataDir = join(DATA_PATH, payload.email);
let index = 0;
const worker = async () => {
while (index < targets.length) {
const path = targets[index++]!;
const name = path.slice(path.lastIndexOf('/') + 1);
const kind = thumbnailKind(name);
const claim = kind ? null : claimed.get(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
/*
Exactly what the request path does, including its cache and its remembered failures — so a file
warmed here is a cache HIT when the browser asks, and a file that cannot be thumbnailed is not
attempted twice.
*/
await buildThumbnail(
path,
userDataDir,
kind ?? 'image',
WARM_SIZE,
claim?.thumbnail
? async () => {
const res = await sidecarFetch(
`${claim.thumbnail}?path=${encodeURIComponent(path)}&size=${WARM_SIZE}`,
payload.email,
);
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
}
: undefined,
).catch(() => {
// Already logged by the builder where it matters. A warm that fails costs nothing: the request
// path will try again and answer honestly.
});
}
};
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, targets.length) }, worker));
}
/** Called once from start-up. */
export function registerThumbnailWarmer(): void {
on('files:uploaded', warm);
on('archive:extracted', warm);
on('files:placed', warm);
}
-2
View File
@@ -10,7 +10,6 @@ import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { rolesRouter } from './api/roles/router';
import { todosRouter } from './api/todos/router';
import { apiKeysRouter } from './api/api-keys/router';
import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks';
@@ -131,7 +130,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter],
['/roles', rolesRouter],
['/todos', todosRouter],
['/skills', skillsRouter],
['/tasks', tasksRouter],
['/agents', agentsRouter],
-11
View File
@@ -435,17 +435,6 @@ const CORE_REGISTRY: Permission[] = [
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
routes: ['/marketplace', '/plugins'],
},
{
key: 'todos',
label: 'Todo board',
description: "The platform's own backlog — todo/ in the repo, browsed and edited at /todos",
// Admin because the subject is the platform's development, not the account's data: the tree lives in
// the server's own checkout and a write is a git-visible edit to it. The router double-gates on
// isSuperAdmin, same belt-and-braces as the rest of this kind.
kind: 'admin',
api: ['/todos'],
routes: ['/todos'],
},
{
key: 'server-admin',
label: 'Server settings',
+3 -1
View File
@@ -98,6 +98,8 @@ function findWeb(
screens: [
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
],
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
},
};
}
@@ -157,7 +159,7 @@ function findWeb(
}
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
return { web: { screens } };
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
}
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
+18 -6
View File
@@ -40,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git');
* verb with different questions — what happens to uncommitted changes, does the version go backwards —
* and it does not exist yet.
*/
export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<FetchResult> {
export async function fetchPlugin(appName: string, onLog?: OnLog, source?: string): Promise<FetchResult> {
const dir = join(PLUGINS_DIR, appName);
if (existsSync(dir)) {
@@ -50,8 +50,20 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
return { ok: false, dir, error: 'git is not installed on this machine' };
}
const entry = await catalogueEntry(appName);
if (!entry) return { ok: false, dir, error: `no catalogue entry for "${appName}"` };
/*
A URL the caller supplied, or the catalogue's.
The catalogue is not the only way a plugin should be able to arrive. "Build one and give it to a
friend" had no route at all, and the first real case is a plugin that must NOT be listed: fofos-admin
is one website's admin panel and belongs in nobody's registry.
The URL is checked identically either way, below. It has to be — the catalogue is not a trust
boundary here, it is just where a string came from, and the same string reaches the same command
line. What DOES differ is who is asking: an install from a URL is somebody typing an address, so the
route that accepts one is owner-only.
*/
const url = source?.trim() || (await catalogueEntry(appName))?.source;
if (!url) return { ok: false, dir, error: `no catalogue entry for "${appName}", and no source URL given` };
// The URL comes from the catalogue and reaches a command line, so it is checked rather than trusted.
// Deliberately narrow: https only, no credentials in the URL, no shell metacharacters. A private
@@ -63,8 +75,8 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
// refused a completely valid, credential-free, https clone URL with a message ("https URLs only, no
// credentials") that describes neither actual reason. Row fixed for consistency with its siblings, but
// the regex should never have depended on it.
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(entry.source)) {
return { ok: false, dir, error: `refusing to clone from "${entry.source}" — https URLs only, no credentials` };
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(url)) {
return { ok: false, dir, error: `refusing to clone from "${url}" — https URLs only, no credentials` };
}
const result = await runLogged(
@@ -74,7 +86,7 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
// `--progress` instead of `--quiet`: git only reports progress when it believes it has a terminal, and
// this is the step where a slow link looks identical to a hang. `runLogged` collapses the per-percent
// rewrites, so the counter is readable rather than a flood.
['git', 'clone', '--depth', '1', '--progress', entry.source, dir],
['git', 'clone', '--depth', '1', '--progress', url, dir],
{
// No terminal to answer a credential prompt: without this, a repository that turned private hangs
// the install forever rather than failing it. Same reasoning as `sudo -n` in os-deps.ts.
+30 -1
View File
@@ -66,6 +66,24 @@ export function generatePluginsModule(states: PluginState[]): string {
// it always did; caldav has two, so it emits two routes, two layout keys and two dock tiles from one
// install. The alias counter runs over screens for that reason — two screens of one plugin must not
// share an import name.
/*
File-browser actions, one block per PLUGIN rather than per screen.
The context menu is global — it is the same menu wherever the browser is mounted — so an action
belongs to the plugin, not to one of its screens. A plugin with two screens contributes its actions
once.
*/
const actionImports: string[] = [];
const actionEntries: string[] = [];
let a = 0;
for (const { plugin } of withWeb) {
if (!plugin.web?.fileActions) continue;
const alias = `Actions${a}`;
a += 1;
actionImports.push(`import { fileActions as ${alias} } from '${specifier(plugin.web.fileActions)}';`);
actionEntries.push(` ...${alias},`);
}
let i = 0;
for (const { plugin } of withWeb) {
for (const screen of plugin.web!.screens) {
@@ -85,8 +103,19 @@ export function generatePluginsModule(states: PluginState[]): string {
}
const body = `${HEADER}
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
import type { AppRegistryMeta, FileAction, LayoutNode } from 'officerdev';
${imports.join('\n')}
${actionImports.join('\n')}
/**
* Context-menu actions plugins contribute to the file browser.
*
* Flat and global: the menu is the same wherever the browser is mounted, so these are not scoped to a
* screen. Each decides for itself whether it applies to what was selected.
*/
export const pluginFileActions: FileAction[] = [
${actionEntries.join('\n')}
];
export type GeneratedPlugin = {
/** The plugin this screen belongs to. Two screens of one plugin share it. */
+9 -1
View File
@@ -145,6 +145,14 @@ export async function installPlugin(
onStep?: OnStep,
forUserId?: number,
onLog?: OnLog,
/**
* Clone from HERE instead of from the catalogue.
*
* For a plugin that is not in any registry and should not be — one website's admin panel, something
* built for one person. Ignored when the plugin is already on disk, because then there is nothing to
* fetch and a URL would be describing a decision already made.
*/
source?: string,
): Promise<PluginActionResult> {
const steps: string[] = [];
@@ -160,7 +168,7 @@ export async function installPlugin(
// as it was.
let plugin = await findPlugin(appName);
if (!plugin) {
const fetched = await fetchPlugin(appName, onLog);
const fetched = await fetchPlugin(appName, onLog, source);
if (!fetched.ok) {
return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` };
}
+85 -1
View File
@@ -477,6 +477,77 @@ export type PluginManifest = {
settings?: PluginSetting[];
websocket?: { sidecar: string };
/**
* File types this plugin can show that the platform cannot.
*
* ── Why this exists ──
*
* The file browser's thumbnailer reads what sharp and ffmpeg read, and that list is a constant in
* core. HEIC is the case that forced the question: sharp's bundled libheif has no HEVC decoder, and
* teaching core to shell out to ImageMagick would mean the platform acquiring a system dependency for
* one format — which is a plugin's job, not core's.
*
* So a plugin may CLAIM extensions. `ls` unions these with core's own, so a file only promises a
* thumbnail when something can actually produce one; `/thumb` forwards a claimed type to the plugin
* that claimed it; and the viewer asks for `render` when opening one full size.
*
* ── The dependency still points the right way ──
*
* Core does not import plugin code and does not know av-club exists. It reads a claim at runtime from
* whatever is installed and enabled, calls a route by name, and falls back to a file icon when nothing
* claims the type. Uninstall the plugin and the file browser is exactly what it was.
*
* `thumbnail` and `render` are paths on the plugin's own sidecar, called with `?path=<absolute>` —
* plus `&size=` for a thumbnail and `&width=` for a render. Both must answer image bytes.
*/
fileTypes?: {
/** Lowercase, no leading dot. `['heic', 'heif']`. */
extensions: string[];
/** Sidecar path returning a small image. Omit and the platform will not offer thumbnails for these. */
thumbnail?: string;
/** Sidecar path returning a full-size displayable image. Omit and the viewer will not offer them. */
render?: string;
};
/**
* Platform events this plugin wants to be told about, and where to tell it.
*
* `{ 'files:uploaded': '/v1/on-upload' }` — the key is an event from `servers/events/bus.ts`, the
* value a path on this plugin's own sidecar. Core POSTs `{ event, paths, email }` to it after the
* operation has already succeeded.
*
* **A hook cannot fail the thing that triggered it.** The upload is done and answered before anybody
* is told; a plugin that is down, slow or erroring gets a line in the log. Nothing here is a way to
* veto or modify an operation, and that is deliberate — a hook that could refuse an upload would make
* every upload's reliability the worst of every installed plugin's.
*/
hooks?: Partial<Record<string, string>>;
/**
* An MCP server this plugin serves, offered to the agent as its own.
*
* ── Why a path and not a URL ──
*
* The path is under this plugin's own mount, so `/mcp` becomes `/api/<plugin>/mcp` on THIS Officer.
* A URL would let a plugin point the agent at any host, and the agent's config carries a token that
* signs as the owner — so the one field that must not be free-form is the destination.
*
* ── Why this exists rather than wrapping the tools ──
*
* A plugin that fronts something with its own MCP server — openseo is the first — can either
* 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 this 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 a second time.
*/
mcp?: {
/** Under the plugin's mount. `/mcp` on a plugin mounted at `/api/openseo` is `/api/openseo/mcp`. */
path: string;
};
osDependencies?: OsDependency[];
/**
@@ -565,7 +636,20 @@ export type DiscoveredPlugin = {
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
* with a partial frontend.
*/
web: { screens: DiscoveredScreen[] } | null;
web: {
screens: DiscoveredScreen[];
/**
* `web/file-actions.ts`, when the plugin has one.
*
* Presence is the declaration, as with `panels.ts` — the manifest says nothing about it because
* there is nothing a human choice adds: an action carries its own label and decides for itself
* whether it applies.
*
* Not per screen. The file browser's context menu is the same menu wherever it is mounted, so an
* action belongs to the plugin.
*/
fileActions: string | null;
} | null;
};
/**
+26
View File
@@ -502,6 +502,32 @@ const BUNDLED: CatalogueEntry[] = [
],
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'kanban',
publisher: 'officerdev',
version: '2026.08.29.01',
platform: '>=1.0.0',
label: 'Kanban',
summary: 'A board over folders of markdown todo files — as many projects as you point it at',
color: '#8b5cf6',
icon: 'SquareKanban',
source: 'https://gitea.officer.dev/plugins/kanban.git',
permissions: [
{
key: 'kanban',
label: 'Kanban',
description: 'Read and edit todo boards over folders you have added',
},
],
/*
`schema: true` for one table, and it is worth saying what is in it: a list of FOLDERS. the items
are markdown on disk and this plugin keeps no copy of them, so an uninstall with "delete its data"
ticked loses the list and never a line of anybody's work.
`sidecar: false` — it reads and writes files with node's own fs. there is nothing to run.
*/
has: { api: true, schema: true, sidecar: false, web: true },
},
{
appName: 'example',
publisher: 'officerdev',
+48 -2
View File
@@ -1,3 +1,4 @@
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import type { DiscoveredPlugin } from './manifest';
@@ -104,7 +105,33 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
return { ok: true, ran: false, output: '', conflicts };
}
const body = {
/*
Packages the PLATFORM owns, which a plugin may never bring its own copy of.
React holds module-level state — the hook dispatcher — so two copies in one page is not a duplicated
dependency, it is a broken one. The second instance's `useRef` reads a null dispatcher and every panel
fails to render with "Cannot read properties of null (reading 'useRef')", which names nothing that
would lead somebody here.
It is easy to arrive at by accident and hard to diagnose: nobody asks for React. `@tiptap/react`
declares it as a PEER, bun installs peers, and the copy lands in plugins/node_modules where it wins
resolution for everything under it. That took the fofos-admin screen down the moment tiptap arrived.
Stripped rather than pinned. Matching the platform's version would still be two copies, and two copies
is the whole problem — bun resolves up the tree, so removing them entirely is what makes a plugin use
the one the platform already loaded.
Removed AFTER the install as well as before it, and the second is the one that does the work: no
plugin declares React, so there is nothing to strip from the merged list. It arrives as a PEER of
something that does, which bun installs and the generated file never mentions.
*/
const PLATFORM_OWNED = ['react', 'react-dom'];
for (const owned of PLATFORM_OWNED) {
if (owned in merged) delete merged[owned];
}
const body = {
name: '@officer/plugin-dependencies',
private: true,
'//': 'GENERATED from every plugin manifest by src/servers/plugins/npm-deps.ts. Not edited, not committed.',
@@ -117,7 +144,26 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
const proc = Bun.spawn(['bun', 'install'], { cwd: PLUGINS_DIR, stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, ran: true, output: `${out}${err}`.trim(), conflicts };
/*
Evict the platform's singletons, however they got here.
This is the step that matters. Stripping the merged list above only covers a plugin ASKING for
React, and none do — it arrives as a peer of something that does, so bun installs it without the
generated file ever naming it. Deleting it afterwards is crude and it is deterministic, which is
the right trade for a failure whose symptom is every panel on a screen refusing to render.
*/
const evicted: string[] = [];
for (const owned of PLATFORM_OWNED) {
const path = join(PLUGINS_DIR, 'node_modules', owned);
if (existsSync(path)) {
rmSync(path, { recursive: true, force: true });
evicted.push(owned);
}
}
const note = evicted.length ? `\nremoved ${evicted.join(', ')} — the platform owns those` : '';
return { ok: code === 0, ran: true, output: `${out}${err}${note}`.trim(), conflicts };
} catch (error) {
return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts };
}
+47 -2
View File
@@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt';
import { getUserByEmail, getOwnerUser } from 'officerdb';
import { DATA_PATH, findEmailAccountName } from '../../data-path';
import { listPluginInstalls } from 'officerdb';
import { discoverPlugins } from '../../plugins/discover';
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
@@ -140,7 +142,7 @@ loadState(dbUser.id);
// ── MCP config ──
function generateMcpConfig(): string {
async function generateMcpConfig(): Promise<string> {
// Was `.container-context`, from the architecture where each user ran inside their own Docker
// container and this directory described that container to the agent. Nothing about it is
// container-related now — it holds exactly one file, the MCP server config handed to the CLI. The
@@ -152,8 +154,45 @@ function generateMcpConfig(): string {
const userRoot = join(DATA_PATH, email!);
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
/*
MCP servers contributed by plugins.
A plugin that fronts something with its own MCP server can either 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 this block.
The plugin declares a PATH, never a URL, and it is resolved against this Officer's own API. That is
the whole safety property: the config below carries `OFFICER_AUTH_TOKEN`, a token that 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 ever reach this machine, through this machine's own gate.
Installed AND enabled, because a disabled plugin's routes are not mounted — offering the agent a
tool list that 404s is worse than not offering it.
*/
const pluginMcpServers: Record<string, { type: 'http'; url: string; headers: Record<string, string> }> = {};
try {
const [installs, discovered] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
const live = new Set(installs.filter((entry) => entry.enabled).map((entry) => entry.appName));
for (const plugin of discovered.plugins) {
const declared = plugin.manifest?.mcp?.path;
if (!declared || !live.has(plugin.appName)) continue;
const path = declared.startsWith('/') ? declared : `/${declared}`;
pluginMcpServers[plugin.appName] = {
type: 'http',
url: `${OFFICER_API_URL}/api/${plugin.appName}${path}`,
headers: { Authorization: `Bearer ${OFFICER_AUTH_TOKEN}` },
};
}
} catch {
// A plugin that cannot be read must not stop an agent from starting. It loses its MCP tools for this
// session and everything else works, which is the right side to fail on.
}
const hostConfig = {
mcpServers: {
...pluginMcpServers,
'officer-tools': {
type: 'stdio',
command: 'bun',
@@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void {
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
// terminal `claude` loads too.
setMcpConfigPath(generateMcpConfig());
/*
Awaited, because the config now asks the database which plugins are installed.
Not fire-and-forget: `setMcpConfigPath` is what the agent manager reads when it spawns, so a config
that arrives after the first spawn is an agent running without its plugin tools and no sign of why.
*/
setMcpConfigPath(await generateMcpConfig());
ensureAnthropicEnv();
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
@@ -13,7 +13,6 @@ import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
import type { QueryClient } from '@tanstack/react-query';
import { globalQueryKey } from 'hooks/useGlobal';
import { metasToRegistry } from './useAppRegistry';
@@ -31,7 +30,6 @@ export const apps = [
...qrTransferMetas,
...sendOffMetas,
...commandHqMetas,
...todoBoardMetas,
...appStoreMetas,
...pluginsMetas,
];
@@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { remarkMentions, MENTION_HREF } from './remark-mentions';
import remarkGfm from 'remark-gfm';
import { remarkLineBreaks } from './remark-line-breaks';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import {
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
base64 blob pushes the bubble past max-w and out of the pane. */}
<div className="chat-md break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMentions]}
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -248,7 +249,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
<div className="chat-md">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMentions]}
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -546,7 +547,7 @@ const PlanCard = ({
</div>
<div className="max-h-96 overflow-y-auto break-words">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions]} components={MD_COMPONENTS}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]} components={MD_COMPONENTS}>
{message.plan}
</ReactMarkdown>
</div>
@@ -637,7 +638,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
{text ? (
<div className="chat-md">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMentions]}
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -0,0 +1,61 @@
import { expect, test } from 'bun:test';
import { remarkLineBreaks } from './remark-line-breaks';
type MdNode = { type: string; value?: string; children?: MdNode[] };
const run = (tree: MdNode): MdNode => {
remarkLineBreaks()(tree);
return tree;
};
const para = (...children: MdNode[]): MdNode => ({ type: 'paragraph', children });
const text = (value: string): MdNode => ({ type: 'text', value });
test('a single newline becomes a break', () => {
const tree = run({ type: 'root', children: [para(text('one\ntwo'))] });
expect(tree.children![0]!.children).toEqual([
{ type: 'text', value: 'one' },
{ type: 'break' },
{ type: 'text', value: 'two' },
]);
});
test('several newlines make several breaks', () => {
const tree = run({ type: 'root', children: [para(text('a\nb\nc'))] });
expect(tree.children![0]!.children!.filter((n) => n.type === 'break')).toHaveLength(2);
});
// The empty-segment case: a trailing newline must give one break, never a break plus an empty text
// node, which renders as an extra blank line and double-spaces everything after it.
test('a trailing newline does not leave an empty text node', () => {
const tree = run({ type: 'root', children: [para(text('one\n'))] });
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'one' }, { type: 'break' }]);
});
// The property that makes this safe: by the time it runs, a newline inside a fence is the VALUE of a
// `code` node, never a `text` node. Walking text alone cannot reach it.
test('code blocks are untouched', () => {
const code: MdNode = { type: 'code', value: 'line one\nline two' };
const tree = run({ type: 'root', children: [code] });
expect(tree.children![0]).toEqual({ type: 'code', value: 'line one\nline two' });
});
test('inline code is untouched', () => {
const tree = run({ type: 'root', children: [para({ type: 'inlineCode', value: 'a\nb' })] });
expect(tree.children![0]!.children![0]).toEqual({ type: 'inlineCode', value: 'a\nb' });
});
test('text without newlines is left exactly as it was', () => {
const tree = run({ type: 'root', children: [para(text('nothing to do here'))] });
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'nothing to do here' }]);
});
// Emphasis, links and mentions all nest text — the walk has to go into them, not just the top level.
test('nested nodes are walked', () => {
const tree = run({ type: 'root', children: [para({ type: 'emphasis', children: [text('one\ntwo')] })] });
expect(tree.children![0]!.children![0]!.children).toEqual([
{ type: 'text', value: 'one' },
{ type: 'break' },
{ type: 'text', value: 'two' },
]);
});
@@ -0,0 +1,67 @@
/**
* Make a single newline a line break, the way every chat app does.
*
* ── The message was never wrong ──
*
* 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 paragraph. So
*
* one
* two
*
* renders as "one two", correctly, per CommonMark. That is right for prose and wrong for a chat box,
* where somebody pressing Shift+Enter is asking for a line.
*
* The fix therefore belongs at RENDER time. The alternative — appending two spaces to every line as the
* message is saved — would edit what somebody typed on its way to disk, 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. It is not in the lockfile, and `remark-mentions` beside this file
* records the reasoning for not adding one: installs are frozen, and a dependency for a short tree walk
* is a deliberate lockfile change for no benefit.
*
* ── Why the split is only inside text nodes ──
*
* By the time this runs the tree is already parsed, so a newline inside a code fence is the VALUE of a
* `code` node and never a `text` node — walking text alone means fenced code, inline code and tables are
* untouched. That is the property that makes this safe: it cannot reach anything where a newline means
* something else.
*/
type MdNode = { type: string; value?: string; children?: MdNode[] };
export function remarkLineBreaks() {
return (tree: MdNode) => {
const walk = (node: MdNode) => {
if (!node.children?.length) return;
const next: MdNode[] = [];
for (const child of node.children) {
if (child.type !== 'text' || !child.value?.includes('\n')) {
walk(child);
next.push(child);
continue;
}
/*
`one\ntwo` becomes text("one"), break, text("two").
Empty segments are dropped rather than emitted, so a trailing newline produces one break and
not a break followed by an empty text node — which renders as an extra blank line and is the
usual way this ends up double-spacing everything.
*/
const parts = child.value.split('\n');
parts.forEach((part, index) => {
if (index > 0) next.push({ type: 'break' });
if (part) next.push({ type: 'text', value: part });
});
}
node.children = next;
};
walk(tree);
};
}
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
import { usePluginCommanderTemplates } from './useCommandHq';
import { useCommandHqCategories } from './useCommandHq';
import type { CommandHqCategoryFilter } from './types';
import { useTranslation } from '../../locales';
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
// Which plugins have anything to offer. Derived from the templates rather than from the installed
// plugin list: a plugin that ships no commander should not get a row that is always empty.
const { templates } = usePluginCommanderTemplates();
const plugins = [...new Set(templates.map((template) => template.plugin))].sort();
const { categories, createCategory } = useCommandHqCategories();
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
to={commandHqListPath(category.id)}
/>
))}
{/*
One row per plugin that offers commanders, below the owner's own categories and separated from
them.
Separated because they are not the same kind of thing. A category is the owner's filing and they
can rename or delete it; a plugin is where something came from and they cannot. Mixing them into
one list would invite the reasonable assumption that "av-club" is a folder they made, and the
first thing somebody does with a folder they made is rename it.
Shown even when the plugin has no commanders yet — that is the whole point. The row is how you
find out a plugin is offering one.
*/}
{plugins.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<span className="block px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
{t('commandHq.fromPlugins')}
</span>
{plugins.map((plugin) => (
<Row
key={plugin}
label={plugin}
active={active === `plugin:${plugin}`}
to={commandHqListPath(`plugin:${plugin}`)}
/>
))}
</div>
)}
{adding && (
// No submit button — a form with exactly one text field still submits on Enter.
<form onSubmit={submitNew}>
@@ -20,7 +20,7 @@ import {
useCommandHqCommanderActions,
useCreateCommander,
} from './useCommandHq';
import type { CommandHqCommanderCreated } from './types';
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
import { formatCommanderCost, formatTokens } from './format';
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
import { useTranslation } from '../../locales';
@@ -246,7 +246,7 @@ function NewCommanderSetup({
category,
onCreated,
}: {
category: number | 'none' | undefined;
category: CommandHqCategoryFilter;
onCreated: (commander: CommandHqCommanderCreated) => void;
}) {
/*
@@ -335,7 +335,7 @@ function ActiveNewCommander({
onStarted,
}: {
commander: CommandHqCommanderCreated;
category: number | 'none' | undefined;
category: CommandHqCategoryFilter;
onStarted: (id: string) => void;
}) {
const { t, tUi, locale } = useTranslation();
@@ -446,13 +446,7 @@ function ActiveNewCommander({
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
* socket dropped").
*/
function NewCommander({
category,
onStarted,
}: {
category: number | 'none' | undefined;
onStarted: (id: string) => void;
}) {
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
@@ -5,6 +5,7 @@ import { Check, Plus, Trash2, X } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
import { PluginCommanderOffers } from './PluginCommanderOffers';
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
import { formatCommanderCost, formatTokens } from './format';
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
row.
*/}
<div className="flex flex-col gap-3 p-4">
{/* Only on a plugin's own filter. On "All commanders" this would put every plugin's offers above
a list that is mostly not theirs, which is a different screen pretending to be this one. */}
{typeof category === 'string' && category.startsWith('plugin:') && (
<PluginCommanderOffers plugin={category.slice('plugin:'.length)} />
)}
{commanders.map((commander) => (
<Card
key={commander.id}
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { Loader2, Package, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'officerdev';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { usePluginCommanderTemplates } from './useCommandHq';
import type { PluginCommanderTemplate } from './types';
/**
* What a plugin offers but has not been started.
*
* ── Offers sit ABOVE the grid, not inside it ──
*
* A commander in the grid is running, has a transcript and has cost something. An offer is a
* description on disk. Drawing them as the same kind of card, distinguished by a badge, makes the two
* look interchangeable — and the first consequence is somebody looking for the transcript of a thing
* that has never run.
*
* ── An offer stays visible after it has been used ──
*
* Deliberately: a plugin's commander is a template, and there is no reason to have only one. Watching
* two different folders for two different formats is two commanders from the same offer, and hiding
* the offer once used would make the second one look impossible.
*/
export const PluginCommanderOffers = ({ plugin }: { plugin: string }) => {
const { t } = useTranslation();
const { templates, createFromTemplate } = usePluginCommanderTemplates();
const [configuring, setConfiguring] = useState<PluginCommanderTemplate | null>(null);
const offers = templates.filter((template) => template.plugin === plugin);
if (offers.length === 0) return null;
return (
<div className="border-b border-border">
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<Package className="size-3.5 text-muted-foreground" />
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{t('commandHq.available')}
</span>
</div>
<div className="flex flex-col gap-2 p-3 pt-1">
{offers.map((offer) => (
<div key={offer.dirName} className="rounded-lg border border-border bg-card p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{offer.name}</p>
<p className="text-xs text-muted-foreground">{offer.description}</p>
{offer.watches && <p className="pt-0.5 text-xs text-muted-foreground/70">{offer.watches}</p>}
</div>
<Button
size="sm"
variant={configuring?.dirName === offer.dirName ? 'secondary' : 'outline'}
className="shrink-0"
onClick={() => setConfiguring(configuring?.dirName === offer.dirName ? null : offer)}
>
<Plus className="size-3.5" />
{t('commandHq.configureAndCreate')}
</Button>
</div>
{configuring?.dirName === offer.dirName && (
<ConfigureForm
template={offer}
busy={createFromTemplate.isPending}
onCancel={() => setConfiguring(null)}
onSubmit={(name, config) => {
createFromTemplate.mutate(
{ plugin: offer.plugin, template: offer.dirName, name, config },
{
onSuccess: () => {
setConfiguring(null);
// Named, because it is now in the grid below among things that look alike.
toast.success(`${name} created — it is not running yet`);
},
onError: (err: unknown) =>
toast.error(err instanceof Error ? err.message : 'could not create it'),
},
);
}}
/>
)}
</div>
))}
</div>
</div>
);
};
/**
* The template's own inputs, drawn from its declaration.
*
* Nothing here knows what av-club's commander wants. `options` becomes a select, `boolean` becomes
* yes/no, everything else is a text field — the same three shapes a task's inputs use, because they
* are the same declaration.
*/
const ConfigureForm = ({
template,
busy,
onCancel,
onSubmit,
}: {
template: PluginCommanderTemplate;
busy: boolean;
onCancel: () => void;
onSubmit: (name: string, config: Record<string, string>) => void;
}) => {
const { t } = useTranslation();
const [name, setName] = useState(template.name);
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(Object.entries(template.inputs).map(([key, spec]) => [key, spec.default ?? ''])),
);
const missing = Object.entries(template.inputs)
.filter(([key, spec]) => spec.required && !values[key]?.trim())
.map(([key]) => key);
return (
<form
className="mt-3 flex flex-col gap-3 border-t border-border pt-3"
onSubmit={(event) => {
event.preventDefault();
if (missing.length === 0 && name.trim()) onSubmit(name.trim(), values);
}}
>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t('commandHq.name')}</span>
<Input value={name} onChange={(event) => setName(event.target.value)} className="text-sm" />
</label>
{Object.entries(template.inputs).map(([key, spec]) => (
<label key={key} className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{key.replace(/_/g, ' ')}
{spec.required && <span className="text-destructive"> *</span>}
</span>
{spec.options ? (
<div className="flex flex-wrap gap-1">
{spec.options.map((option) => (
<button
key={option}
type="button"
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
values[key] === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
}`}
>
{option}
</button>
))}
</div>
) : spec.type === 'boolean' ? (
<div className="flex gap-1">
{['false', 'true'].map((option) => (
<button
key={option}
type="button"
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
(values[key] || 'false') === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
}`}
>
{option === 'true' ? 'Yes' : 'No'}
</button>
))}
</div>
) : (
<Input
value={values[key] ?? ''}
onChange={(event) => setValues((v) => ({ ...v, [key]: event.target.value }))}
placeholder={spec.type === 'directory' ? '/home/you/folder' : ''}
className="font-mono text-xs"
/>
)}
{spec.description && <span className="text-[11px] text-muted-foreground/70">{spec.description}</span>}
</label>
))}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={busy || missing.length > 0 || !name.trim()}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
{t('commandHq.configureAndCreate')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
{/* Named, so it is obvious WHICH field is stopping the button rather than hunting for a red mark. */}
{missing.length > 0 && (
<span className="text-xs text-muted-foreground">needs {missing.join(', ').replace(/_/g, ' ')}</span>
)}
</div>
</form>
);
};
@@ -40,6 +40,9 @@ export const commandHqSessionPath = (sessionId: string, category?: CommandHqCate
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
if (raw === null) return undefined;
if (raw === 'none') return 'none';
// `plugin:av-club`. Checked before the numeric parse, since an appName never parses as a number and
// would otherwise fall through to `undefined` and silently show every commander.
if (raw.startsWith('plugin:') && raw.length > 'plugin:'.length) return raw as CommandHqCategoryFilter;
const id = Number(raw);
return Number.isFinite(id) ? id : undefined;
};
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
totalUsd: number;
};
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
export type CommandHqCategoryFilter = number | 'none' | undefined;
/**
* `category` query value: omitted (all), `'none'` (uncategorised), a category id, or `plugin:<appName>`.
*
* The plugin form is not a category and deliberately does not pretend to be one. A commander's category
* is the owner's filing; which plugin shipped it is a fact about where it came from, and the two are
* independent — a plugin's commander can also be filed under "Nightly" without either meaning changing.
* One query parameter carries both because they are both "which subset of the grid", and that is what
* this parameter has always meant.
*/
export type CommandHqCategoryFilter = number | 'none' | `plugin:${string}` | undefined;
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
export type CommandHqCommander = {
@@ -100,3 +108,23 @@ export type CommandHqFire = {
taskId: string;
firedAt: string;
};
/**
* A commander a plugin OFFERS but which does not exist yet.
*
* Deliberately a different type from `CommandHqCommander`, and fetched from a different endpoint. One
* runs, has a transcript and costs money; the other is a description on disk. Modelling them as one
* type with an `exists` flag would mean every consumer checking the flag before touching anything, and
* the one that forgets renders a card with a cost of zero for something that was never started.
*/
export type PluginCommanderTemplate = {
dirName: string;
plugin: string;
name: string;
description: string;
watches?: string;
inputs: Record<
string,
{ type: string; description?: string; default?: string; options?: string[]; required?: boolean }
>;
};
@@ -6,6 +6,7 @@ import type {
CommandHqCommander,
CommandHqCommanderCreated,
CommandHqCommanderDetail,
PluginCommanderTemplate,
} from './types';
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
isLoading,
};
}
/**
* What the installed plugins offer, and making one real.
*
* The list is cheap and changes only when a plugin is installed, updated or disabled — so it is not
* refetched on a window focus. A plugin the owner just installed appears when Command HQ next mounts,
* which is the same moment its dock tile does.
*/
export const usePluginCommanderTemplates = () => {
const { get, post } = useClient();
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ templates: PluginCommanderTemplate[] }>({
queryKey: ['command-hq', 'plugin-commanders'],
queryFn: () => get('/command-hq/plugin-commanders'),
staleTime: 60_000,
});
const createFromTemplate = useMutation({
mutationFn: (input: { plugin: string; template: string; name: string; config: Record<string, string> }) =>
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
// The grid reads `commanders`, and a newly created one has to appear in it without a reload —
// creating from a template is the one moment somebody is certain something should have happened.
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
});
return { templates: data?.templates ?? EMPTY_TEMPLATES, isLoading, createFromTemplate };
};
// Module-level, so an empty result is the SAME array every render. A fresh `[]` in the fallback is a new
// identity each time, which re-runs every effect and memo that depends on it — the trap this file already
// avoids for categories and commanders.
const EMPTY_TEMPLATES: PluginCommanderTemplate[] = [];
@@ -6,11 +6,20 @@ import { TaskRunnerDialog } from './components/TaskRunnerDialog';
import { AgentRunnerDialog } from './components/AgentRunnerDialog';
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
import { DictateDialog } from './components/DictateDialog';
import { useFileBrowserApp } from './useFileBrowserApp';
import { useFileBrowserApp, type PickedFile } from './useFileBrowserApp';
type FileBrowserAppProps = {
basePath?: string;
rootOverride?: string;
/** What the root crumb is called. Defaults to the base folder's own name. */
rootLabel?: string;
/**
* Keep the current folder in this query param, on the route you are already on.
*
* For an embedded browser that wants a reloadable, shareable location. NOT `urlPath`, which owns the
* pathname and navigates to `/files` — passing that from a panel takes the whole app with it.
*/
pathParam?: string;
/** Put the current folder in `?path=`. Opt-in — only the /files screen is guaranteed one browser. */
urlPath?: boolean;
/**
@@ -18,10 +27,26 @@ type FileBrowserAppProps = {
* where the folder is the URL and already does.
*/
panelId?: string;
/**
* Turn this into a CHOOSER: opening a file hands it back instead of opening a viewer.
*
* Folders still navigate, because choosing a file means getting to it. A prop rather than a second
* component — a picker built separately is a listing, a breadcrumb, a sort, thumbnails and keyboard
* handling reimplemented, all of it free to drift from the real browser.
*/
onPickFile?: (file: PickedFile) => void;
};
export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId);
export const FileBrowserApp = ({
basePath = '/',
rootOverride,
rootLabel,
urlPath,
panelId,
pathParam,
onPickFile,
}: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
const { handleNavigate } = fileBrowserManager;
return (
@@ -31,6 +56,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId
path={fileBrowserManager.currentPath}
onNavigate={handleNavigate}
basePath={basePath}
rootLabel={rootLabel}
hrefForPath={fileBrowserManager.hrefForPath}
/>
<UploadProgress fileBrowserManager={fileBrowserManager} />
@@ -5,6 +5,14 @@ type BreadcrumbProps = {
path: string;
onNavigate: (path: string) => void;
basePath?: string;
/**
* What to call the root crumb.
*
* 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.
*/
rootLabel?: string;
/**
* Where a folder lives, when this browser owns the address bar. Given, the crumbs are real links and
* cmd-click opens the folder in a new tab; absent (a dashboard panel, the widget) they stay buttons,
@@ -15,7 +23,7 @@ type BreadcrumbProps = {
hrefForPath?: ((path: string) => { pathname: string; search: string }) | null;
};
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: BreadcrumbProps) => {
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath, rootLabel }: BreadcrumbProps) => {
const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
const segments = relativePath.split('/').filter(Boolean);
@@ -35,7 +43,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
<nav className="flex items-center gap-1 text-sm flex-wrap">
<Crumb to={basePath} className="flex items-center gap-1 text-duck-teal cursor-pointer font-medium">
<Home className="h-4 w-4" />
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
<span>{rootLabel ?? (basePath === '/' ? 'home' : basePath.split('/').pop())}</span>
</Crumb>
{segments.map((segment, i) => {
@@ -4,6 +4,8 @@ import { ChevronUp, ChevronDown } from 'lucide-react';
import type { DirEntry } from '../../../../hooks/useFilesAPI';
import { getFileType } from '../../../FileViewer';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
import { useClient } from 'hooks/useClient';
import { fileActionsFor } from '../../file-actions';
import { FileItem } from './FileItem';
import { useTranslation } from '../../../../locales';
@@ -15,7 +17,19 @@ type FileGridProps = {
};
const LIST_ROW_HEIGHT = 42;
const GRID_ROW_HEIGHT = 130;
/*
What one grid row actually occupies, and why the number changed.
It was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text — about 148. The
card overflowed its own row by roughly the size of the gap, so consecutive rows touched and the grid
looked vertically stacked. The horizontal gap was fine, which is what made it read as a styling
oddity rather than a measurement being wrong.
Now: a 96px image band, then `py-2` (16) around a 20px name and a 16px meta line, inside a 2px
border. 96 + 16 + 20 + 16 + 4 = 152. Measured against the card rather than guessed, because guessing
is how it came to be 130.
*/
const GRID_ROW_HEIGHT = 152;
const GRID_GAP = 12;
const HEADER_HEIGHT = 36;
// Type-ahead: keys pressed within this window accumulate into one burst. 1–2 chars jump to the first
@@ -49,12 +63,16 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
const { t } = useTranslation();
// For plugin actions: `run` is outside React and cannot call the hook itself.
const client = useClient();
const {
visibleEntries: entries,
viewMode,
currentPath,
rootOverride,
selected,
clipboard,
refresh,
handleOpen,
handleDelete,
handleRename,
@@ -154,8 +172,23 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
const cols = useColumnCount(scrollRef);
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
const anySelected = selected.size > 0;
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
const pluginActionsFor = (entry: DirEntry) => {
const names = selected.has(entry.name) && selected.size > 1 ? [...selected] : [entry.name];
const payload = entries
.filter((candidate) => names.includes(candidate.name))
.map((candidate) => ({ name: candidate.name, path: entryPath(candidate.name), type: candidate.type }));
return fileActionsFor(payload).map((action) => ({
key: action.key,
label: action.label,
Icon: action.icon,
onClick: () => action.run(payload, { currentPath, refresh, client }),
}));
};
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
const virtualizer = useVirtualizer({
@@ -236,6 +269,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
key={entry.name}
entry={entry}
viewMode={viewMode}
path={entryPath(entry.name)}
root={rootOverride}
selected={selected.has(entry.name)}
anySelected={anySelected}
selectedCount={selected.size}
@@ -254,6 +289,14 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onReadAloud={handleReadAloud}
onExtract={handleExtract}
taskGroups={getMatchingTaskGroups(entry.name, entry.type)}
/*
Plugin actions, resolved here because this is where the SELECTION lives.
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.
*/
pluginActions={pluginActionsFor(entry)}
onRunTask={handleRunTask}
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
onRunAgent={handleRunAgent}
@@ -262,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onCompress={handleCompress}
onDuplicate={handleDuplicate}
onPasteInto={handlePasteInto}
onDragStart={fileBrowserManager.handleEntryDragStart}
onDropInto={fileBrowserManager.handleEntryDrop}
onTogglePin={handleTogglePin}
isPinned={isPinned(entryPath(entry.name))}
canPaste={clipboard !== null}
@@ -23,8 +23,10 @@ import {
FileArchive,
Pin,
PinOff,
Sparkles,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { Thumbnail } from './Thumbnail';
import {
DropdownMenu,
DropdownMenuContent,
@@ -56,6 +58,10 @@ import { useFormat } from 'i18n';
export type FileItemProps = {
entry: DirEntry;
viewMode: 'grid' | 'list';
/** Root-relative path of this entry, for the thumbnail URL. */
path: string;
/** The browser's root, when it is not `home`. */
root?: string;
selected: boolean;
anySelected: boolean;
selectedCount: number;
@@ -79,9 +85,25 @@ export type FileItemProps = {
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
onCreateDashboard: (entry: DirEntry) => void;
onOpenInEditor: (entry: DirEntry) => void;
/**
* Context-menu actions contributed by plugins, already resolved against the selection.
*
* Resolved by the caller, not here: whether an action receives one entry or the whole selection is a
* question about the SELECTION, which this component does not have and should not need.
*/
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
onCompress: (entry: DirEntry) => void;
onDuplicate: (entry: DirEntry) => void;
onPasteInto: (entry: DirEntry) => void;
/**
* Dragging this row, and dropping onto it.
*
* A custom MIME type on the DataTransfer survives between windows of the same browser, which is what
* makes "drag from one Officer window into another" work at all. See useFileBrowserApp for the type
* and why `Files` is left alone.
*/
onDragStart: (ev: React.DragEvent, entry: DirEntry) => void;
onDropInto: (ev: React.DragEvent, entry: DirEntry) => Promise<void>;
onTogglePin: (entry: DirEntry) => void;
isPinned: boolean;
canPaste: boolean;
@@ -172,6 +194,7 @@ type MenuItemsProps = {
canPaste: boolean;
onOpen: (e: DirEntry) => void;
onOpenInEditor: (e: DirEntry) => void;
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
onDelete: (e: DirEntry) => void;
onStartRename: () => void;
onChat: (e: DirEntry) => void;
@@ -207,6 +230,7 @@ const MenuItems = ({
canPaste,
onOpen,
onOpenInEditor,
pluginActions,
onDelete,
onStartRename,
onChat,
@@ -249,7 +273,7 @@ const MenuItems = ({
const hasTasks = taskGroups.length > 0;
const hasAgents = agentGroups.length > 0;
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents || Boolean(pluginActions?.length);
// Only nest when more than one category matched — a file usually matches a single category, and
// Run Task > Video > Convert would just add a hop.
const nestTasks = taskGroups.length > 1;
@@ -300,6 +324,14 @@ const MenuItems = ({
{t('files.menu.extract')}
</Item>
)}
{/* Plugin actions, flat rather than nested. There are few of them and each is one click; a
submenu for two items is a hop that buys nothing. */}
{pluginActions?.map((action) => (
<Item key={action.key} onClick={action.onClick} className="cursor-pointer">
{action.Icon ? <action.Icon className="mr-2 h-4 w-4" /> : <Sparkles className="mr-2 h-4 w-4" />}
{action.label}
</Item>
))}
{hasTasks && (
<Sub>
<SubTrigger className="cursor-pointer">
@@ -560,6 +592,7 @@ export const FileItem = ({
viewMode,
selected,
anySelected,
pluginActions,
selectedCount,
isCut,
onOpen,
@@ -584,9 +617,13 @@ export const FileItem = ({
onCompress,
onDuplicate,
onPasteInto,
onDragStart,
onDropInto,
onTogglePin,
isPinned,
canPaste,
path,
root,
}: FileItemProps) => {
const { t } = useTranslation();
const { date } = useFormat();
@@ -605,6 +642,50 @@ export const FileItem = ({
<MaterialFileIcon name={entry.name} className="inline-flex h-10 w-10" />
);
/*
The picture, where there is one, wrapping the icon it replaces.
`entry.thumb` comes from the server — see the note on it in useFilesAPI. Both views ask for a size
a little larger than they draw so a retina screen is not resampling upwards, and both fall back to
exactly the icon above when there is no picture to be had.
Rounded in the list and square in the grid: the list row is a line of text with a small image in it,
where a soft edge sits better beside a glyph; the grid tile is the picture, and cropping it into a
rounded box would trim content off a photograph for decoration.
*/
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}>
{node}
</Thumbnail>
);
/*
ONE SIZE FOR BOTH VIEWS, and it is 256 rather than the 128 the list used to ask for.
The size is part of the cache key, so two sizes meant two entries — and switching from grid to list
re-rendered every picture from scratch. For a jpeg that is a few milliseconds nobody notices. For a
HEIC, which a plugin decodes, it is four seconds per file: the list showed icons where the grid had
just shown photographs, and the two views appeared to disagree about the same file.
The list draws it at 28px and the grid at about 80, so both are downscaling either way and 256 is
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
*/
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
/*
The grid picture fills the card's WIDTH and is cropped by height.
It used to be a fixed `h-16 w-20` box centred with `object-contain`, so a landscape photo sat in the
middle of the card with air either side and a portrait one shrank to nothing. `w-full` with the
card's own `overflow-hidden` doing the 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.
No rounding here: the card clips, so rounding the image too would show a sliver of card colour
inside the corner.
*/
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-24 w-full', 256) : iconLarge;
useEffect(() => {
return () => {
if (clickTimer.current) clearTimeout(clickTimer.current);
@@ -684,6 +765,10 @@ export const FileItem = ({
onCreateDashboard,
onOpen,
onOpenInEditor,
// Typed on both ends and forwarded by neither, which is why "Create Gallery" was in the bundle and
// not in the menu. `menuProps` is assembled field by field, so a new prop is not carried by being
// declared.
pluginActions,
onCompress,
onDuplicate,
onPasteInto,
@@ -706,9 +791,27 @@ export const FileItem = ({
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
draggable
onDragStart={(ev) => onDragStart(ev, entry)}
/*
A folder is a drop target; a file is not. `onDragOver` must preventDefault or the browser
refuses the drop entirely — that is the single line whose absence makes a target look
dead, with no error anywhere.
`dropEffect` is set here rather than at dragstart 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.
*/
onDragOver={(ev) => {
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
ev.preventDefault();
ev.stopPropagation();
ev.dataTransfer.dropEffect = 'move';
}}
onDrop={(ev) => void onDropInto(ev, entry)}
>
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{icon}</span>
<span className="shrink-0">{listVisual}</span>
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRenameInput
@@ -746,13 +849,22 @@ export const FileItem = ({
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors ${
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
} ${cutOpacity} select-none`}
style={selected ? undefined : cardStyle()}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
draggable
onDragStart={(ev) => onDragStart(ev, entry)}
onDragOver={(ev) => {
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
ev.preventDefault();
ev.stopPropagation();
ev.dataTransfer.dropEffect = 'move';
}}
onDrop={(ev) => void onDropInto(ev, entry)}
>
<div className="absolute top-1 left-1">
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
@@ -762,23 +874,27 @@ export const FileItem = ({
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div>
{iconLarge}
{/* One fixed-height band whether it holds a photograph or a folder icon, so every tile is
the same shape and the rows line up. The card clips; this does not round. */}
<div className="flex h-24 w-full shrink-0 items-center justify-center overflow-hidden">{gridVisual}</div>
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
)}
<div className="flex min-w-0 flex-col items-center gap-0.5 px-2 py-2">
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
)}
<span className="text-xs text-duck-dark/50">
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
{' · '}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="text-xs text-duck-dark/50">
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
{' · '}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import { Play } from 'lucide-react';
import { getHeaders } from 'hooks/useClient';
// Matches , which declares its own for the same reason: one string, and no
// import cycle between the browser and the viewer.
const API_URL = '/api';
/**
* The picture of a file, where there is one — an image, or a frame from a video.
*
* ── One component for both views ──
*
* The grid and the list want the same thing at two sizes, so the only difference between them is a
* `size` and a class. Two implementations would have drifted the first time one of them learned
* something the other did not, and the fallback behaviour below is the part that would have drifted.
*
* ── It falls back to the icon, and the icon is the CHILD ──
*
* `children` is whatever the item would have drawn without a thumbnail. It is rendered until the image
* loads and again forever if it fails, so a file that cannot be thumbnailed — a codec ffmpeg does not
* have, a corrupt download, a member without file access — looks exactly like it did before this
* existed rather than like a hole in the layout.
*
* That is also why the icon is not merely hidden behind the image: an <img> that 404s draws a broken
* glyph in most browsers, and a layout that reserves space for a picture that never comes reads as a
* page still loading.
*
* ── `loading="lazy"`, on top of virtualisation ──
*
* The grid only mounts what is on screen, so most of the work is already avoided. Lazy loading covers
* the rest: the rows just past the fold are mounted by the virtualiser's overscan, and without this
* every one of them would spawn an ffmpeg for something nobody has looked at.
*/
/*
How many times to try before settling for the icon, and how long to wait between.
A thumbnail can legitimately fail for a REASON THAT PASSES: the plugin that renders it is restarting,
the server is busy behind its concurrency cap, or — for a format that needs decoding — the first
request is simply slow enough that something upstream gave up. Treating the first failure as final
meant a folder of HEIC came back as file icons and stayed that way until somebody reloaded the page,
which is what made it look like a race rather than a retry that never happened.
Backing off rather than hammering: a genuinely undecodable file must not become a request loop, and
three attempts over about seven seconds is enough to outlast a sidecar restart without becoming one.
*/
const MAX_ATTEMPTS = 3;
const RETRY_DELAYS_MS = [1200, 3000, 6000];
export const Thumbnail = ({
path,
root,
size,
isVideo,
className,
children,
}: {
/** Root-relative, the same path every other file-browser call uses. */
path: string;
root?: string;
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
size: number;
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
isVideo: boolean;
className?: string;
/** The file icon — shown until the image arrives, and kept if it never does. */
children: React.ReactNode;
}) => {
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
const [attempt, setAttempt] = useState(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// A pending retry must not fire into an unmounted row — the grid is virtualised, so scrolling past a
// slow thumbnail unmounts it mid-wait.
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
if (state === 'failed') return <>{children}</>;
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
return (
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
{/* Underneath rather than instead of. Swapping on load would reflow the row twice per file on a
fast connection, which is visible as a shudder while a folder fills in. */}
{state === 'loading' && <span className="absolute inset-0 flex items-center justify-center">{children}</span>}
<img
src={src}
alt=""
loading="lazy"
decoding="async"
draggable={false}
// `attempt` is in the key, so a retry REMOUNTS the element. Changing only the src would leave
// the browser's own failed-request cache in play and the second attempt would not be one.
key={attempt}
onLoad={() => setState('ready')}
onError={() => {
if (attempt + 1 >= MAX_ATTEMPTS) {
setState('failed');
return;
}
timer.current = setTimeout(() => setAttempt((n) => n + 1), RETRY_DELAYS_MS[attempt] ?? 6000);
}}
className={`size-full object-cover transition-opacity duration-150 ${
state === 'ready' ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Only once the frame is actually showing — a badge over a file icon would claim the icon is a
video still, which is the one thing it is not. */}
{state === 'ready' && isVideo && (
<span className="pointer-events-none absolute right-0.5 bottom-0.5 rounded-sm bg-black/60 p-0.5">
<Play className="size-2.5 fill-white text-white" />
</span>
)}
</span>
);
};
@@ -1,5 +1,5 @@
import { useRef } from 'react';
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
import { FolderPlus, Upload, FolderUp, ClipboardPaste, X } from 'lucide-react';
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
import { useTranslation } from '../../../../../locales';
@@ -9,7 +9,7 @@ type DefaultActionsProps = {
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
const { t } = useTranslation();
const { setNewEntry, handleUpload, clipboard, handlePaste } = fileBrowserManager;
const { setNewEntry, handleUpload, clipboard, handlePaste, setClipboard } = fileBrowserManager;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
@@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
/>
</button>
{/*
What is on the clipboard, said out loud.
It used to be an icon and a toast. That was enough while the clipboard died with the tab — you
had just pressed the key, so you knew what was on it. Now it is per-user and survives reloads,
tabs and days, which means somebody can arrive at a folder with three files pending from
yesterday and paste them without ever having been told. Invisible persistent state is how a
file gets moved by accident.
So it says how many and whether they will be copied or moved, and offers a way to let go of
them. `cut` is worth distinguishing in the label rather than only in the icon: it is the one
that takes the originals with it.
*/}
{clipboard !== null && (
<button
onClick={handlePaste}
title={t('files.menu.paste')}
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
<div className="flex items-center gap-0.5 rounded-md bg-duck-dark/5 pl-2">
<span className="text-xs text-duck-teal whitespace-nowrap">
{clipboard.paths.length} {clipboard.mode === 'cut' ? t('files.toMove') : t('files.toCopy')}
</span>
<button
onClick={handlePaste}
title={t('files.menu.paste')}
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<ClipboardPaste className="h-4 w-4" />
</button>
<button
onClick={() => setClipboard(null)}
title={t('files.clearClipboard')}
className="p-1.5 rounded-md text-duck-teal/60 hover:bg-duck-dark/10 hover:text-duck-teal cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
);
@@ -252,48 +252,57 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
{entries.map((e, i) => (
<div
key={`${e.url}-${i}`}
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
/*
A COLUMN, not a row. The title, the thumbnail and the button used to share one
line, so a long title — which is most of them — competed with the button for width
and won: `truncate` cannot bite while a sibling can be pushed out of the card.
Split, the title has the whole width to truncate against and the button has a row
it cannot be squeezed out of.
*/
className="flex flex-col gap-2 rounded-md border border-duck-dark/10 bg-background/40 p-2"
>
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
{e.status === 'loading' ? (
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
) : e.status === 'error' ? (
<AlertCircle size={16} className="text-destructive/70" />
) : e.thumbnail && !audioOnly ? (
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
) : (
<Music size={16} className="text-duck-dark/40" />
)}
</div>
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
{e.status === 'loading' ? (
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
) : e.status === 'error' ? (
<AlertCircle size={16} className="text-destructive/70" />
) : e.thumbnail && !audioOnly ? (
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
) : (
<Music size={16} className="text-duck-dark/40" />
)}
</div>
<div className="min-w-0 flex-1">
{e.status === 'loading' ? (
<div className="space-y-1.5">
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-destructive">
{t('videoDownload.couldNotFetch')}
</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
<>
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
{e.title || e.url}
</p>
<p className="truncate text-xs text-duck-dark/50">
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
</p>
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
</>
)}
<div className="min-w-0 flex-1">
{e.status === 'loading' ? (
<div className="space-y-1.5">
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-destructive">
{t('videoDownload.couldNotFetch')}
</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
<>
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
{e.title || e.url}
</p>
<p className="truncate text-xs text-duck-dark/50">
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
</p>
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
</>
)}
</div>
</div>
{e.status === 'ready' && (
<div className="shrink-0">
<div className="flex justify-end">
{e.dl === 'done' ? (
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
<Check size={14} /> {t('videoDownload.saved')}
@@ -10,6 +10,7 @@ import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams }
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents';
import { useUserState } from 'state/useUserState';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { runTaskNow } from './components/runTaskNow';
@@ -24,7 +25,30 @@ import { useTranslation } from '../../../locales';
*/
export const FILES_PATH_PARAM = 'path';
export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false, panelId?: string) => {
/**
* `pathParam` — keep the current folder in a QUERY PARAM, on whatever route we are already on.
*
* This is not `urlPath` and must not be confused with it. `urlPath` owns the URL's PATHNAME: it reads
* the folder out of `location.pathname` and navigates to `/files/<folder>`. That is correct for the
* Files screen, which IS that route, and catastrophic inside a panel — a plugin passing `urlPath`
* navigated the whole app to /files the moment anybody opened a folder.
*
* So an embedder gets its own mechanism: a named search param, written with `replace` so browsing does
* not fill the back stack, and no pathname involved at all. A reload lands where you were and the link
* can be sent to somebody, which is what was actually wanted.
*/
/** What a picker is handed when somebody opens a file in pick mode. */
export type PickedFile = { name: string; path: string; type: string };
export const useFileBrowserApp = (
basePath: string,
rootOverride?: string,
urlPath = false,
panelId?: string,
pathParam?: string,
/** Set to turn this into a chooser: opening a file calls this instead of a viewer. */
onPickFile?: (file: PickedFile) => void,
) => {
const { t } = useTranslation();
const { user } = useAuth();
const navigate = useNavigate();
@@ -64,14 +88,55 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
// visit still opens at home, because a URL with no `path` means the base. Either way the main
// (unscoped) browser mirrors its folder into files/currentPath so the Create Dashboard flow can
// default a new dashboard's cwd to it.
const currentPath = urlPath ? folderFromPathname(location.pathname) : localPath;
/*
Three sources, mutually exclusive by construction: the pathname for /files, a named query param for
an embedded browser, and component state for an ordinary panel.
── The param is RELATIVE to the base, and that is not only for tidiness ──
An absolute path put the account's own email in every link — `?media=/officerdev/data/
someone@gmail.com/...` — so a URL sent to a colleague carried the owner's address with it. Relative,
the same folder is `?media=ABOUT+US`: shorter, and it says nothing about the machine or the account.
It is also what makes the jail hold by construction rather than by comparison. The value is JOINED
to `basePath`, so there is no "outside" to check for — only `..` could climb out, and a segment of
`..` is refused outright.
*/
const paramRelative = pathParam ? searchParams.get(pathParam) : null;
const paramPath =
paramRelative && !paramRelative.split('/').includes('..')
? `${basePath.replace(/\/$/, '')}/${paramRelative.replace(/^\/+/, '')}`
: null;
const currentPath = urlPath ? folderFromPathname(location.pathname) : pathParam ? (paramPath ?? basePath) : localPath;
const setCurrentPath = (path: string) => {
// Leaving a folder closes what was open in it. A pane shows a file from the folder you were in, so
// carrying it into the next one leaves the two disagreeing — the listing says one place, the pane
// another. Only the OVERLAY params go; anything else in the query (global UI state) is left alone,
// which is the distinction a wholesale reset kept getting wrong.
if (urlPath) navigate({ pathname: folderHref(path), search: withoutViewerParams(location.search) });
else {
else if (pathParam) {
/*
PUSHED, not replaced — so Back and Forward move between folders.
This was `replace` on the argument that browsing is not navigation worth a history entry. That
was wrong, and wrong in a way the URL itself gives 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.
Reading is unchanged: `currentPath` is derived from the param, so a Back that rewrites the query
moves the browser with it and needs nothing here.
*/
setSearchParams((prev) => {
const next = new URLSearchParams(withoutViewerParams(prev.toString()));
// The base folder is the ABSENCE of the param, so a link to the top of an embedded browser is
// the plain screen URL and nothing accumulates for having gone nowhere.
const relative = path.startsWith(basePath) ? path.slice(basePath.replace(/\/$/, '').length + 1) : '';
if (!relative) next.delete(pathParam);
else next.set(pathParam, relative);
return next;
});
} else {
setLocalPath(path);
// Written here rather than in an effect on `currentPath`, so it records a NAVIGATION and not the
// initial render. An effect would fire once on mount with the restored value and persist it back,
@@ -133,7 +198,25 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const { togglePin, isPinned } = usePinnedFiles();
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [clipboard, setClipboard] = useState<ClipboardState>(null);
/*
The clipboard lives on the SERVER, not in this component.
── Why ──
It was `useState`, which meant 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 exactly this kind of thing, and
`useUserState` already reads and writes it through React Query with an optimistic local update. So
a clipboard is one key in it and there is no new table, route or 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.
*/
const queryClient = useQueryClient();
const [clipboard, setClipboard] = useUserState<ClipboardState>('fileClipboard', null);
const [renamingName, setRenamingName] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
@@ -164,6 +247,10 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
filesRef.current = files;
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
// Read inside `refresh`, which is not re-created per render and would otherwise close over a
// stale list the first time it ran.
const entriesRef = useRef(entries);
entriesRef.current = entries;
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
@@ -171,8 +258,29 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
const firstListing = useRef(true);
/**
* The folder the entries currently on screen belong to.
*
* What separates "re-read this folder" from "go to a different one", which look identical from
* inside `refresh` and need opposite treatment — see below.
*/
const shownPath = useRef<string | null>(null);
const refresh = async () => {
setLoading(true);
/*
Blank the list only when there is nothing true to show.
`loading` swaps the whole grid for a spinner, which is right when arriving somewhere new and
wrong when re-reading a folder already on screen: every refresh — after a paste, after a delete,
and now every time the window comes forward — made the contents vanish and come back. On a
folder of a hundred files that is a flash and a lost scroll position, for information that was
already correct.
So the spinner is for a folder we cannot yet show: nothing listed, or a different folder than the
one listed. Re-reading the same folder leaves what is there until better information arrives, and
the only visible change is whatever actually changed.
*/
if (entriesRef.current.length === 0 || shownPath.current !== currentPathRef.current) setLoading(true);
try {
const data = await filesRef.current.listDir(currentPathRef.current);
if (data.reset) {
@@ -180,6 +288,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
return;
}
setEntries(data.entries);
shownPath.current = currentPathRef.current;
if (data.rootDir) setRootDir(data.rootDir);
} catch (err: any) {
console.error('[FileBrowser] refresh error:', err);
@@ -243,6 +352,50 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
};
}, [searchQuery]);
/*
Pull the clipboard again whenever this window comes forward.
`useUserState` holds USER_STATE with `staleTime: Infinity` — right for a dock layout, wrong for
something another window can change while this one is idle. Without this, window A cuts, window B
still shows nothing to paste, and the feature reads as broken in exactly the case it was built for.
Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into
the other window means focusing the other window first, so the refresh happens at precisely the
moment it is needed and never while nobody is looking. A socket would be live for a case that does
not arise — two visible windows dragging between each other already carry the paths in the drag
itself.
`visibilitychange` as well as `focus`, because a background tab brought forward by switching tabs
fires only the second one in some browsers.
*/
/*
...and the FOLDER as well as the clipboard.
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.
Held in a ref because `refreshRef.current` is rebuilt on every render — putting it in the
dependency array would tear down and re-add both listeners several times a second.
*/
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
useEffect(() => {
const onForeground = () => {
if (document.visibilityState !== 'visible') return;
void queryClient.invalidateQueries({ queryKey: ['USER_STATE'] });
void refreshRef.current();
};
window.addEventListener('focus', onForeground);
document.addEventListener('visibilitychange', onForeground);
return () => {
window.removeEventListener('focus', onForeground);
document.removeEventListener('visibilitychange', onForeground);
};
}, [queryClient]);
// Clipboard image paste
useEffect(() => {
const handler = (ev: ClipboardEvent) => {
@@ -416,6 +569,21 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
setCurrentPath(next);
return;
}
/*
PICK MODE.
When a caller is using this browser to CHOOSE a file rather than to work in a folder, opening a
file means handing it back — not opening a viewer the caller would then have to close. Folders
still navigate above, because choosing a file requires getting to it.
A prop rather than a second browser: a picker built separately would be a listing, a breadcrumb,
a sort, thumbnails and keyboard handling reimplemented, all of it drifting from the real one.
This is the same component with one branch.
*/
if (onPickFile) {
onPickFile({ name: entry.name, path: entryPath(entry.name), type: entry.type });
return;
}
// An archive extracts rather than opening a pane. The viewer has nothing to show for one — it renders
// a placeholder reading "Archive file" and the name you can already see in the listing — so a pane
// was a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a
@@ -911,6 +1079,78 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
}
};
/*
Dragging rows between windows.
── The MIME type is the whole trick ──
A custom 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, one folder in each. It does not
survive between different browsers, and it cannot exchange with Finder — dragging out to the
desktop needs a Chrome-only API and only works outward.
`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.
── It also writes the clipboard ──
Belt and braces, and cheap now that the clipboard 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 becomes a gesture over the same mechanism rather than a second one beside it.
*/
const DRAG_TYPE = 'application/x-officer-files';
const handleEntryDragStart = (ev: React.DragEvent, entry: DirEntry) => {
// Dragging one of several selected rows takes the whole selection; dragging an unselected row
// takes just that one, which is what every file manager does and what a hand expects.
const paths = selected.has(entry.name)
? [...selected].map((name) => entryPath(name))
: [entryPath(entry.name)];
ev.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths));
// `move` so the cursor shows what will happen. The drop decides for real; this is the hint.
ev.dataTransfer.effectAllowed = 'move';
setClipboard({ paths, mode: 'cut' });
};
/** Paths from a drag that started in an Officer window, or null if this is something else. */
const draggedPaths = (ev: React.DragEvent): string[] | null => {
if (!ev.dataTransfer.types.includes(DRAG_TYPE)) return null;
try {
const parsed = JSON.parse(ev.dataTransfer.getData(DRAG_TYPE));
return Array.isArray(parsed) && parsed.every((p) => typeof p === 'string') ? parsed : null;
} catch {
return null;
}
};
/**
* Drop onto a folder row — move into it.
*
* Refuses a folder being dropped into itself or into its own child, which the server would reject
* anyway but with an error rather than a shrug. Comparing with a trailing separator so `/a/bc` is
* not read as living inside `/a/b`.
*/
const handleEntryDrop = async (ev: React.DragEvent, entry: DirEntry) => {
const paths = draggedPaths(ev);
if (!paths || entry.type !== 'directory') return;
ev.preventDefault();
ev.stopPropagation();
const destination = entryPath(entry.name);
const into = paths.filter((path) => destination !== path && !destination.startsWith(`${path}/`));
if (into.length === 0) return;
try {
await files.move(into, destination);
setClipboard(null);
await refresh();
toast.success(t('files.pastedItems', { count: into.length }));
} catch {
toast.error(t('files.pasteFailed'));
}
};
const handleDragEnter = (ev: React.DragEvent) => {
ev.preventDefault();
dragCounter.current++;
@@ -936,6 +1176,28 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
dragCounter.current = 0;
setDragging(false);
/*
A drag that began in an Officer window, dropped on this folder's background.
Checked before the upload path, because a drag from inside carries no `Files` and would
otherwise fall through to "read the dropped files", find none, and do nothing at all.
*/
const dragged = draggedPaths(ev);
if (dragged) {
const into = dragged.filter((path) => currentPath !== path && !currentPath.startsWith(`${path}/`));
// Dropping a folder into the folder it already lives in. Nothing to do, and nothing to say.
if (into.length === 0) return;
try {
await files.move(into, currentPath);
setClipboard(null);
await refresh();
toast.success(t('files.pastedItems', { count: into.length }));
} catch {
toast.error(t('files.pasteFailed'));
}
return;
}
const items = ev.dataTransfer.items;
if (!items || items.length === 0) return;
@@ -1003,6 +1265,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
user,
// Navigation
homeRoot,
/*
The root this browser is reading, when it is not the caller's home.
Already threaded into `useFilesAPI` and the viewer params above; exposed because anything building
a URL to one of these files — a thumbnail, a download — needs the same answer, and reconstructing
it from `homeRoot` gets the panel-with-an-override case wrong in exactly the way line 440 explains.
*/
rootOverride,
currentPath,
setCurrentPath,
// Null when this browser does not own the address bar — the breadcrumb then stays buttons.
@@ -1021,7 +1291,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
// Selection
selected,
setSelected,
handleEntryDragStart,
handleEntryDrop,
clipboard,
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
// clipboard is something a person has to be able to clear rather than wait out.
setClipboard,
renamingName,
setRenamingName,
// Search
@@ -0,0 +1,103 @@
/**
* Actions a PLUGIN contributes to the file browser's context menu.
*
* ── Why this exists beside tasks ──
*
* A task already reaches that menu, and for "do this to these files" it is the right thing: it runs a
* script as the owner, reports through the job system, and needs no frontend at all.
*
* It is 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`, the generated plugin module collects
* them, and the menu renders the ones that apply. Presence on disk is the declaration, exactly as
* `panels.ts` and `layout.ts` already are.
*
* ── They run in the BROWSER ──
*
* Which is the whole difference. An action gets the selection and does what a panel would do — open a
* dialog, call its own plugin's API, navigate. It has no privileges of its own: everything it calls
* goes through the same permission gate as every other request that page makes.
*/
/** One entry the action was invoked on. */
export type FileActionEntry = {
name: string;
/** Absolute within the browser's root, as the browser addresses it. */
path: string;
type: 'file' | 'directory';
};
export type FileActionContext = {
/** The folder currently open, so an action can act on "here" rather than on a selection. */
currentPath: string;
/** Re-read the listing. For an action that creates or removes something on disk. */
refresh: () => void;
/**
* An authenticated client for Officer's API, rooted at `/api`.
*
* Handed over rather than left to the action, because there is no way for it to build one: `run` is
* called from an event handler outside React, so it cannot use `useClient`, and a bare `fetch` gets a
* 401 — this API authenticates with a bearer token, not a cookie, and only the hook holds it.
*/
client: {
get: <T>(url: string) => Promise<T>;
post: <T>(url: string, payload?: unknown) => Promise<T>;
put: <T>(url: string, payload?: unknown) => Promise<T>;
delete: <T>(url: string, payload?: unknown) => Promise<T>;
};
};
export type FileAction = {
/** Unique across every plugin. Prefixed with the plugin name by convention, like panel keys. */
key: string;
label: string;
/** A lucide icon component, as the rest of the menu uses. */
icon?: React.ComponentType<{ className?: string }>;
/**
* Whether this action makes sense for what was selected.
*
* Given EVERY entry the action would receive, so an action that only handles images can refuse a
* mixed selection rather than half-working. Returning false hides it — a menu of greyed-out items
* nobody can explain is worse than a shorter menu.
*/
appliesTo: (entries: FileActionEntry[]) => boolean;
run: (entries: FileActionEntry[], context: FileActionContext) => void;
};
/**
* The registry, filled by the app at startup.
*
* ── Why injection rather than an import ──
*
* The generated plugin module lives in the APP (`src/apps/officer-web/Plugins.gen.tsx`) and this is a
* workspace package the app depends on. Importing it from here would point the dependency backwards and
* make this package unusable by anything that is not Officer's own frontend.
*
* So the package owns the list and the app hands it over — one call, at startup, before anything
* renders a file browser.
*/
let registered: FileAction[] = [];
export const registerFileActions = (actions: FileAction[]): void => {
registered = actions;
};
/**
* The actions that apply to this selection, in registration order.
*
* `entries` is what the action would receive: the whole selection when the right-clicked item is part
* of a multiple selection, and just that item otherwise. That is what every file manager does, and the
* alternative — acting on a selection the click was not part of — is the surprising one.
*/
export const fileActionsFor = (entries: FileActionEntry[]): FileAction[] =>
registered.filter((action) => {
try {
return action.appliesTo(entries);
} catch {
// A plugin's predicate that throws hides its own action rather than breaking the menu for
// everything else on it.
return false;
}
});
@@ -9,6 +9,7 @@ export { useTasks, type TaskSummary } from './useTasks';
export { useRecentFiles } from './useRecentFiles';
export { usePinnedFiles } from './usePinnedFiles';
export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp';
export type { PickedFile } from './FileBrowserApp/useFileBrowserApp';
export { FileBrowserWidget } from './FileBrowserWidget';
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
@@ -27,3 +28,5 @@ export const appRegistryMetas: AppRegistryMeta[] = [
availableOnPanel: false,
},
];
export type { FileAction, FileActionEntry, FileActionContext } from './file-actions';
export { registerFileActions, fileActionsFor } from './file-actions';
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
import { getFileType, getThumbUrl } from './file-types';
import { useFileViewer } from './FileViewerContext';
/**
* Move between the files in a folder without going back to the listing.
*
* ── Which files ──
*
* Everything in the same folder the viewer can SHOW — images, video, audio, pdf, markdown, code, text.
* Not directories and not archives: opening one is an extraction rather than a view, so putting them in
* a strip of things you step through would offer a step that does something else entirely.
*
* ── It lists the folder itself ──
*
* The viewer is given one path and nothing else, and it is rendered by two different screens that reach
* it in two different ways. Listing the folder here means neither has to pass a collection, and the
* strip is right the moment a file is opened from anywhere — a link somebody sent, a search result, or
* the grid.
*
* ── The thumbnails are the file browser's ──
*
* Same `/thumb` route, same cache, same size the grid asks for. So a folder already browsed has a warm
* strip, and a HEIC in it works exactly where it works elsewhere rather than being a second thing to
* teach about formats.
*/
const STEPPABLE = new Set(['image', 'video', 'audio', 'pdf', 'markdown', 'code', 'text']);
export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
const { filePath, root, onNavigate } = useFileViewer();
const files = useFilesAPI(root || 'home');
const [entries, setEntries] = useState<DirEntry[]>([]);
const folder = filePath.slice(0, filePath.lastIndexOf('/')) || '/';
useEffect(() => {
let live = true;
void files
.listDir(folder)
.then((data) => {
if (live) setEntries(data.entries ?? []);
})
.catch(() => {
// A folder that cannot be listed simply gets no strip. The file itself is already open and
// readable; failing loudly here would break a working view over its navigation.
if (live) setEntries([]);
});
return () => {
live = false;
};
// `folder` only: re-listing on every file change would refetch the same directory for each step.
}, [folder]);
const siblings = useMemo(
() =>
entries
.filter((e) => e.type === 'file' && STEPPABLE.has(getFileType(e.name)))
.sort((a, b) => a.name.localeCompare(b.name)),
[entries],
);
const index = siblings.findIndex((e) => `${folder === '/' ? '' : folder}/${e.name}` === filePath);
const go = useCallback(
(delta: number) => {
if (index === -1 || !onNavigate) return;
const next = siblings[index + delta];
if (next) onNavigate(`${folder === '/' ? '' : folder}/${next.name}`);
},
[index, siblings, folder, onNavigate],
);
/*
Keyboard, on the window.
Ignored while somebody is typing — the viewer can be EDITING a file, and stealing arrow keys from a
text area would make the editor unusable. `isContentEditable` catches the rich cases that are not
inputs.
*/
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return;
if (event.key === 'Escape' && onClose) {
event.preventDefault();
onClose();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
go(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
go(1);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [go, onClose]);
// One file in the folder is not a carousel. The strip would be a row containing the thing already on
// screen, and two arrows that do nothing.
if (!onNavigate || siblings.length < 2) return null;
return (
<div className="flex shrink-0 items-center gap-2 border-t border-duck-dark/10 bg-background/60 px-2 py-2 dark:border-foreground/10">
<Caret side="left" disabled={index <= 0} onClick={() => go(-1)} />
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto">
{siblings.map((entry, i) => {
const path = `${folder === '/' ? '' : folder}/${entry.name}`;
const active = i === index;
return (
<button
key={entry.name}
type="button"
onClick={() => onNavigate(path)}
title={entry.name}
/* `ref` scrolls the active one into view as you step, so the strip follows rather than
being something you have to drag along behind you. */
ref={(el) => {
if (active) el?.scrollIntoView({ block: 'nearest', inline: 'center' });
}}
className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 transition-colors ${
active ? 'border-duck-teal' : 'border-transparent opacity-60 hover:opacity-100'
}`}
>
{entry.thumb ? (
<img
src={getThumbUrl(path, root)}
alt=""
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
) : (
<span className="flex size-full items-center justify-center px-1 text-[9px] leading-tight break-all text-duck-dark/60 dark:text-foreground/60">
{entry.name.slice(0, 18)}
</span>
)}
</button>
);
})}
</div>
<Caret side="right" disabled={index === -1 || index >= siblings.length - 1} onClick={() => go(1)} />
</div>
);
};
const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={side === 'left' ? 'Previous (←)' : 'Next (→)'}
className="shrink-0 rounded p-1 text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-25 dark:text-foreground/60 dark:hover:bg-foreground/5"
>
<Icon className="size-5" />
</button>
);
};
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { Loader2, FolderArchive, FileWarning, Download } from 'lucide-react';
import { getLang, getRawUrl, getArchiveBaseName } from './file-types';
import { getLang, getRawUrl, getRenderUrl, needsServerRender, getArchiveBaseName } from './file-types';
import { useFileViewer } from './FileViewerContext';
import { PdfRenderer } from './renderers/PdfRenderer';
import { ImageRenderer } from './renderers/ImageRenderer';
@@ -63,7 +63,15 @@ export const FileViewerBody = () => {
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
</div>
) : fileType === 'image' ? (
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
<ImageRenderer
/*
HEIC and friends go through the server, because no browser decodes them. Everything else
keeps using `/raw`: it streams, supports ranges, and lets the browser paint progressively —
all of which a rendered copy gives up.
*/
src={needsServerRender(fileName) ? getRenderUrl(filePath, root) : getRawUrl(filePath, root)}
fileName={fileName}
/>
) : fileType === 'video' ? (
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
) : fileType === 'audio' ? (
@@ -5,6 +5,14 @@ export type FileViewerContextValue = {
filePath: string;
fileName: string;
root: string;
/**
* Show a different file, without leaving the viewer. Absent when the host cannot express it.
*
* Each host writes it differently — the Files screen puts a name in `?view=`, an embedded panel puts a
* path — so the viewer asks rather than deciding, and a host that offers nothing simply gets no
* carousel.
*/
onNavigate?: (filePath: string) => void;
fileType: FileType;
content: string | null;
loading: boolean;
@@ -13,6 +13,8 @@ type FileViewerProviderProps = {
root?: string;
content?: string;
onOpenFile?: (filePath: string, root: string) => void;
/** Show a different file in the same viewer — what the carousel calls. */
onNavigate?: (filePath: string) => void;
onReplaceView?: (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => void;
autoPlay?: boolean;
children: ReactNode;
@@ -45,6 +47,7 @@ export const FileViewerProvider = ({
root = 'home',
content: directContent,
onOpenFile,
onNavigate,
onReplaceView,
autoPlay = false,
children,
@@ -171,6 +174,7 @@ export const FileViewerProvider = ({
<FileViewerContext
value={{
filePath,
onNavigate,
fileName,
root,
fileType,
@@ -2,6 +2,7 @@ import { X } from 'lucide-react';
import { FileViewerProvider } from './FileViewerProvider';
import { FileViewerHeader } from './FileViewerHeader';
import { FileViewerBody } from './FileViewerBody';
import { FileCarousel } from './FileCarousel';
type FileViewerViewProps = {
filePath: string;
@@ -9,10 +10,12 @@ type FileViewerViewProps = {
root?: string;
content?: string;
onClose?: () => void;
/** Enables the carousel. Without it the viewer shows one file and no strip. */
onNavigate?: (filePath: string) => void;
};
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => (
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content} onNavigate={onNavigate}>
<div className="h-full flex flex-col">
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
<FileViewerHeader />
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
<div className="flex-1 min-h-0">
<FileViewerBody />
</div>
{/* Below the content, outside the scrolling area — a strip that scrolled away with a long
markdown file would be a control you have to go looking for. Renders nothing when the folder
holds one viewable file, or when the host gave no `onNavigate`. */}
<FileCarousel onClose={onClose} />
</div>
</FileViewerProvider>
);
@@ -124,6 +124,34 @@ export function formatTime(s: number): string {
return `${m}:${sec.toString().padStart(2, '0')}`;
}
/**
* Image formats NO browser can display, whatever the server does.
*
* This is a fact about browsers, not about plugins — which is why it is a constant here rather than
* something fetched. Chrome and Firefox have never decoded HEIC and Safari only does on Apple's own
* platforms, so a `<img src>` pointed at one shows a broken image everywhere it matters.
*
* The server may or may not be able to render one into something showable; that depends on whether a
* plugin claims the type, and `/file-browser/render` answers 415 when nothing does.
*/
export const BROWSER_CANNOT_DISPLAY = ['heic', 'heif'];
export const needsServerRender = (name: string): boolean =>
BROWSER_CANNOT_DISPLAY.includes(name.split('.').pop()?.toLowerCase() ?? '');
/**
* A displayable version of a file the browser cannot decode. 415 when nothing installed can make one.
*
* `width` is what the viewer will draw at, doubled for retina and clamped server-side — asking for the
* full 4032px of a phone photograph to show it in a panel is megabytes nobody looks at.
*/
export function getRenderUrl(filePath: string, root?: string, width = 2048): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/render?path=${encodeURIComponent(filePath)}&width=${width}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -132,6 +160,19 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`;
}
/**
* The file browser's own thumbnail, at the size its grid asks for.
*
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
* format that needs a plugin to decode works here exactly where it works there.
*/
export function getThumbUrl(filePath: string, root?: string, size = 256): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getAudioTracksUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -1,4 +1,5 @@
export { FileViewerView } from './FileViewerView';
export { FileCarousel } from './FileCarousel';
export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody';
@@ -16,8 +16,22 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
/*
WHICH src has loaded, not whether one has.
The first fix reset a boolean in an effect on `src`, and that has a race a cached image wins every
time: the effect runs AFTER render, so the browser fires `onLoad` for an image already in cache
before the effect gets to clear the flag — and then the effect clears it, and `onLoad` never comes
again. The spinner stayed forever on exactly the files that were fastest.
Comparing the loaded src to the current one has no ordering at all. It is false the instant `src`
changes, true the moment that src's own load fires, and there is no window in which the two can be
written in the wrong order.
*/
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const [errorSrc, setErrorSrc] = useState<string | null>(null);
const loaded = loadedSrc === src;
const error = errorSrc === src;
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
@@ -77,6 +91,30 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
if (zoom <= 1) setOffset({ x: 0, y: 0 });
}, [zoom]);
/*
A NEW FILE IS A NEW LOAD, and everything about the last one goes.
`loaded` was set once and never cleared, which did not matter while a viewer showed one file for its
whole life. With a carousel it is the bug you see: the flag stays true, the opacity gate stays open,
and the browser goes on painting the PREVIOUS photograph until the next one has decoded. Stepping
through a folder looked like the image lagged a beat behind the strip — the strip was right and the
picture was stale.
Zoom, rotation and pan reset with it. They are a view of one image, and carrying 3x and 90 degrees
onto the next photograph means arriving somewhere nobody asked to be, in the corner of a picture
they have not seen yet.
*/
/*
Zoom, rotation and pan are a view of ONE image, so they reset when the image changes. These stay in
an effect because they have no race to lose: nothing else writes them during a load, and being one
render late is invisible.
*/
useEffect(() => {
setZoom(1);
setRotation(0);
setOffset({ x: 0, y: 0 });
}, [src]);
if (error) {
return (
<div className="flex items-center justify-center h-full">
@@ -104,8 +142,8 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
<img
src={src}
alt={fileName}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
onLoad={() => setLoadedSrc(src)}
onError={() => setErrorSrc(src)}
className="transition-transform duration-150 select-none"
draggable={false}
style={{
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { GitBranch, X, Loader2 } from 'lucide-react';
/**
* "Install from Git repository" — for a plugin that is in no catalogue and should not be.
*
* The marketplace was the only way a remote plugin could arrive, so "build one and give it to a friend"
* had no affordance. It is also the wrong shape for a plugin built for one person: fofos-admin is one
* website's admin panel, and listing it publicly in order to install it would be a lie about who it is for.
*
* ── It FETCHES. It does not install ──
*
* The first version installed, and using it showed why that was wrong: this button is on the grid, the
* progress log is in the detail panel, so the install ran to completion with nothing on screen and the
* plugin simply appeared, already installed. No feedback, and no moment to change your mind.
*
* Cloning is enough to make a plugin real — the platform lists what is on disk, so it shows up as
* Available like any catalogue entry. You then click the card and install it there, with the progress
* and the result the existing flow already renders. One install path, and it is the one people know.
*
* ── No name field ──
*
* The name is derived from the repository server-side, because `appName` IS the directory a plugin sits
* in and decides its route, its table prefix and its process name. A box for it would invite a name that
* disagrees with the address. What it became is reported back, and named in the confirmation.
*/
export const InstallFromUrl = ({
onFetch,
}: {
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>;
}) => {
const [open, setOpen] = useState(false);
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
if (!open) {
return (
<div className="flex items-center gap-2">
{/* The outcome stays until the next attempt. A toast would be gone before somebody who just
pasted a URL has looked up, and "which card is mine" is the question this answers. */}
{note && <span className={note.ok ? 'text-duck-dark/50' : 'text-destructive'}>{note.text}</span>}
<button
onClick={() => {
setNote(null);
setOpen(true);
}}
className="flex shrink-0 items-center gap-1.5 rounded-md border border-duck-dark/10 px-2.5 py-1 text-xs text-duck-dark/60 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark"
>
<GitBranch className="h-3.5 w-3.5" />
Install from Git repository
</button>
</div>
);
}
const submit = async () => {
const trimmed = url.trim();
if (!trimmed || busy) return;
setBusy(true);
setNote(null);
const result = await onFetch(trimmed);
setBusy(false);
if (result.ok) {
/*
Named, because the name was derived rather than typed — this is where you find out what to look
for. And it says which of the two things happened: a plugin whose source was deleted but whose
install row survived comes back ALREADY installed, and telling somebody to "click it to install"
while it sits in a different tab is worse than saying nothing.
*/
setNote({
ok: true,
text: result.installed
? `${result.appName} added — already installed`
: `${result.appName} added — click it to install`,
});
setOpen(false);
setUrl('');
} else {
setNote({ ok: false, text: result.error ?? 'Could not fetch that repository' });
}
};
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
{busy ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-duck-dark/40" />
) : (
<GitBranch className="h-3.5 w-3.5 shrink-0 text-duck-dark/40" />
)}
<input
autoFocus
value={url}
disabled={busy}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
if (e.key === 'Escape' && !busy) setOpen(false);
}}
placeholder="https://gitea.officer.dev/owner/plugin.git"
className="min-w-0 flex-1 rounded-md border border-duck-dark/10 bg-transparent px-2 py-1 font-mono text-xs outline-none focus:border-duck-dark/30 disabled:opacity-50"
/>
{note && !note.ok && <span className="shrink-0 text-destructive">{note.text}</span>}
<button
onClick={() => void submit()}
disabled={!url.trim() || busy}
className="shrink-0 rounded-md bg-duck-dark/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-duck-dark/20 disabled:opacity-40"
>
{busy ? 'Fetching…' : 'Add'}
</button>
<button
onClick={() => setOpen(false)}
disabled={busy}
aria-label="Cancel"
className="shrink-0 rounded p-1 text-duck-dark/40 hover:text-duck-dark disabled:opacity-40"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
};
@@ -0,0 +1,119 @@
import { useState, type ReactNode } from 'react';
import { Check, Download, GitBranch, Store } from 'lucide-react';
import { InstallFromUrl } from './InstallFromUrl';
import type { AvailablePlugin, PluginItem } from './usePlugins';
/**
* The Marketplace section: what this machine has, and what it could have.
*
* ── Three tabs, because there are genuinely three states ──
*
* `Installed` is what is running. The other two are both "not installed" and they are NOT the same
* thing, which is the distinction the old single grid lost:
*
* `Official` the marketplace catalogue — somewhere else, one click and a download away.
* `Available` already ON THIS MACHINE and not installed. A plugin fetched from a git URL lands
* here, and so does one that was deleted without being purged.
*
* Collapsing those two hid the whole point of fetching from a repository: you add something, and it is
* indistinguishable from twenty things you have not downloaded yet.
*
* ── Why the git field lives in Available ──
*
* That is the tab its result appears in. A button that adds something to the list you are looking at is
* legible; the same button floating above a filtered grid was not, which is how the first version
* managed to install a plugin with no feedback at all.
*/
type Entry = (PluginItem | AvailablePlugin) & { installed?: boolean };
type TabKey = 'installed' | 'official' | 'available';
export const MarketplaceOverview = ({
installed,
official,
available,
renderCard,
onFetch,
}: {
installed: Entry[];
official: Entry[];
available: Entry[];
renderCard: (entry: Entry) => ReactNode;
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>;
}) => {
// Opens on what you HAVE. A store that opens on the catalogue is selling; this is mostly a place
// people come to check on something they already installed.
const [tab, setTab] = useState<TabKey>('installed');
const tabs: { key: TabKey; label: string; icon: typeof Check; items: Entry[] }[] = [
{ key: 'installed', label: 'Installed', icon: Check, items: installed },
{ key: 'official', label: 'Official', icon: Store, items: official },
{ key: 'available', label: 'Available', icon: Download, items: available },
];
const current = tabs.find((t) => t.key === tab) ?? tabs[0]!;
return (
<div className="flex h-full flex-col">
{/* One ruled row, same border as the nav beside it, so the two panels read as one screen. */}
<div className="flex shrink-0 items-center gap-1 border-b border-border px-3">
{tabs.map(({ key, label, icon: Icon, items }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm transition-colors ${
tab === key ? 'border-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="size-3.5 shrink-0" />
{label}
<span className="text-xs tabular-nums text-muted-foreground">{items.length}</span>
</button>
))}
{/* Only on the tab it affects. Elsewhere it would be an action whose result appears somewhere
you cannot see, which is the mistake this whole section is correcting. */}
{tab === 'available' && (
<div className="ml-auto flex min-w-0 flex-1 justify-end py-1.5 pl-3">
{/* The tab FOLLOWS the result. A plugin whose directory was removed but whose install row
survived comes back already installed, so "click it to install" would be pointing at a
tab it is not in — which is exactly what happened the first time this shipped. */}
<InstallFromUrl
onFetch={async (url) => {
const result = await onFetch(url);
if (result.ok && result.installed) setTab('installed');
return result;
}}
/>
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{current.items.length === 0 ? (
<Empty tab={current.key} />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{current.items.map((entry) => renderCard(entry))}
</div>
)}
</div>
</div>
);
};
/** Says what the tab is FOR, not just that it is empty — an empty tab is otherwise a dead end. */
const Empty = ({ tab }: { tab: TabKey }) => {
const text =
tab === 'installed'
? 'Nothing installed yet. Official has the catalogue.'
: tab === 'official'
? 'The marketplace catalogue is empty or unreachable.'
: 'Nothing waiting to be installed. Add one from a Git repository above.';
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<GitBranch className="size-5 opacity-30" />
<p className="text-sm text-muted-foreground">{text}</p>
</div>
);
};
@@ -177,7 +177,7 @@ const ActionLog = ({
/** Artwork if the catalogue has a URL, else the plugin's own lucide glyph. */
const AvailableGlyph = ({ plugin }: { plugin: AvailablePlugin }) => {
if (plugin.image) return <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" />;
if (plugin.image) return <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 rounded-xl object-contain" />;
const Glyph = resolveIcon(plugin.icon ?? 'Box');
return <Glyph className="h-9 w-9 shrink-0" style={{ color: plugin.color }} />;
};
@@ -431,7 +431,9 @@ export const PluginDetail = () => {
return (
<div className="h-full overflow-auto p-6">
<div className="flex items-center gap-3">
{plugin.image ? <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" /> : null}
{plugin.image ? (
<img src={plugin.image} alt="" className="h-10 w-10 shrink-0 rounded-xl object-contain" />
) : null}
<div className="min-w-0">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
@@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router';
import { Check, Download } from 'lucide-react';
import { usePlugins, type AvailablePlugin, type PluginItem } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
import { MarketplaceOverview } from './MarketplaceOverview';
/**
* The store: cards in a grid, filtered by the tag on the left.
@@ -80,11 +81,32 @@ const Card = ({ entry, search }: { entry: Entry; search: string }) => {
};
export const PluginGrid = () => {
const { plugins, available, isLoading } = usePlugins();
const { plugins, available, isLoading, fetchFromUrl } = usePlugins();
const [params] = useSearchParams();
const tag = params.get('tag');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
/*
No tag means the Marketplace section — the state of this machine, in three tabs.
The tag grid below is unchanged and still what a filter shows. Two views rather than one because
they answer different questions: "what have I got" wants installed-versus-not, and "what is there
for my calendar" wants everything of that kind together regardless of state.
*/
if (!tag) {
const installed = plugins.filter((p) => p.installed);
const onDisk = plugins.filter((p) => !p.installed);
return (
<MarketplaceOverview
installed={installed}
official={available}
available={onDisk}
onFetch={fetchFromUrl}
renderCard={(entry) => <Card key={entry.appName} entry={entry} search={params.toString()} />}
/>
);
}
// Installed first, then the rest. Both are the same thing to a person browsing — a plugin — and the
// ones they already have are the ones they are most likely looking for.
@@ -106,7 +128,9 @@ export const PluginGrid = () => {
return (
<div className="h-full overflow-auto p-4">
<div className="mb-3 text-xs text-duck-dark/40">
{/* No git field here any more: it lives in the Marketplace section's Available tab, which is where
what it adds actually shows up. */}
<div className="mb-3 text-xs text-muted-foreground">
{shown.length} plugin{shown.length === 1 ? '' : 's'}
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
</div>
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle, Database, Package } from 'lucide-react';
import { Puzzle, AlertTriangle, Database, Package, Store } from 'lucide-react';
import { usePlugins, type OrphanTable } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
@@ -32,14 +32,31 @@ const TAG_ICONS: Record<string, string> = {
Fun: 'Gamepad2',
};
const ROW = 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors w-full text-left';
/*
The house section-nav row, taken from `fofos-admin/web/NavPanel.tsx`.
Three things carry the look, and each is doing a job:
`border-b border-border` — the SEPARATION. Gapped pills read as a floating list where the boundary
between one item and the next is guesswork; a ruled edge says where a row ends.
The label is NOT muted. It was `text-duck-dark/70`, so every row whispered and the eye had nothing to
land on. The muting moves to the line BELOW it, where a count is genuinely secondary.
Active is `bg-muted` — a filled row rather than a tinted label. Colour on the text was carrying both
"this is a link" and "this one is selected", which is why nothing looked selected.
*/
const rowClass = (active: boolean) =>
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
active ? 'bg-muted' : 'hover:bg-muted/50'
}`;
export const TagNav = () => {
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll } = usePlugins();
const [params] = useSearchParams();
const active = params.get('tag');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
// Counted over everything the grid can show — installed and available together — so a number here
// always matches what clicking it produces.
@@ -55,6 +72,25 @@ export const TagNav = () => {
);
const untagged = all.filter((p) => !p.tags?.length).length;
// On disk and not installed — a plugin fetched from a git URL lands here, which is why it is counted
// beside the catalogue rather than folded into it.
const notInstalled = plugins.filter((p) => !p.installed).length;
/**
* The Marketplace section — a view, not a filter.
*
* `?tag=` narrows the same grid; this replaces it, so it clears the tag rather than setting one. It is
* also the DEFAULT: arriving with neither parameter lands here, because "what do I have, and what can
* I get" is the question somebody opening a store is asking, and the tag list is how you narrow it
* afterwards.
*/
const overviewLink = () => {
const next = new URLSearchParams(params);
next.delete('tag');
next.delete('selected');
return { search: next.toString() ? `?${next}` : '' };
};
const onOverview = !active;
const link = (tag: string | null) => {
const next = new URLSearchParams(params);
@@ -65,61 +101,70 @@ export const TagNav = () => {
return { search: `?${next}` };
};
/** One row: icon, a label that is actually legible, and what it holds underneath. */
const row = (
key: string,
to: ReturnType<typeof link>,
Icon: typeof Package,
label: string,
n: number,
on: boolean,
) => (
<Link key={key} to={to} className={rowClass(on)}>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{label}</span>
{/* The count moves here from the right-hand column. It was a number floating at the edge at 60%
opacity, which is where the eye goes last; as the row's second line it is read WITH the label
rather than hunted for. */}
<span className="block truncate text-xs text-muted-foreground tabular-nums">
{n} plugin{n === 1 ? '' : 's'}
</span>
</span>
</Link>
);
return (
<nav className="flex h-full flex-col overflow-auto">
<div className="flex flex-col gap-0.5 p-2">
<Link
to={link(null)}
className={`${ROW} ${!active ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
>
<Package className="size-4 shrink-0" />
<span className="flex-1">All</span>
<span className="text-xs tabular-nums opacity-60">{all.length}</span>
<div className="flex flex-col">
{/* Above the tags and separated from them: it answers a different question. The tags say what a
plugin is FOR; this says what state this machine is in. */}
<Link to={overviewLink()} className={`${rowClass(onOverview)} border-b-2`}>
<Store className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">Marketplace</span>
<span className="block truncate text-xs text-muted-foreground tabular-nums">
{plugins.filter((p) => p.installed).length} installed · {notInstalled + available.length} available
</span>
</span>
</Link>
{ordered.map(([tag, n]) => {
const Icon = resolveIcon(TAG_ICONS[tag] ?? 'Puzzle');
return (
<Link
key={tag}
to={link(tag)}
className={`${ROW} ${active === tag ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1 truncate">{tag}</span>
<span className="text-xs tabular-nums opacity-60">{n}</span>
</Link>
);
})}
{row('__all', link(null), Package, 'All', all.length, false)}
{ordered.map(([tag, n]) =>
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
)}
{/* Shown only when there are any. A plugin with no tags is reachable from All but from no filter,
and that is worth being able to SEE rather than discovering by its absence. */}
{untagged > 0 && (
<Link
to={link('__untagged')}
className={`${ROW} ${active === '__untagged' ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
>
<Puzzle className="size-4 shrink-0" />
<span className="flex-1 truncate">Untagged</span>
<span className="text-xs tabular-nums opacity-60">{untagged}</span>
</Link>
)}
{untagged > 0 && row('__untagged', link('__untagged'), Puzzle, 'Untagged', untagged, active === '__untagged')}
</div>
<div className="mt-auto flex flex-col gap-1 border-t border-duck-dark/5 p-2 text-xs">
{/* Below the rows, and separated by the same border they use — these are facts about the store
rather than filters you can click, so they must not look like another section. */}
<div className="mt-auto flex flex-col gap-1 border-t border-border p-3 text-xs">
{/* Update-all belongs here rather than in a plugin's detail: it is the one action about all of
them at once. Hidden when nothing is installed, where it would read as a broken button. */}
{plugins.length > 0 && (
<button
onClick={() => updateAll()}
disabled={updatingAll}
className="rounded-lg px-3 py-2 text-left text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-50"
className="rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50 disabled:opacity-50"
>
{updatingAll ? 'Updating…' : 'Update all'}
</button>
)}
{broken.length > 0 && (
<div className="flex items-start gap-2 px-3 py-1.5 text-busy-ink">
<div className="flex items-start gap-2 px-2 py-1.5 text-busy-ink">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{broken.length} broken plugin{broken.length === 1 ? '' : 's'}
@@ -127,7 +172,7 @@ export const TagNav = () => {
</div>
)}
{orphans.length > 0 && (
<div className="flex items-start gap-2 px-3 py-1.5 text-duck-dark/50">
<div className="flex items-start gap-2 px-2 py-1.5 text-muted-foreground">
<Database className="mt-0.5 size-3.5 shrink-0" />
<span>
{orphans.length} orphaned table{orphans.length === 1 ? '' : 's'} from removed plugins
@@ -204,23 +204,31 @@ export function usePlugins() {
* The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has
* to understand what our own endpoint emits.
*/
const run = useCallback(
async (verb: PluginVerb, appName: string) => {
/**
* The frame reader, which does not care where the stream came from.
*
* Split out when "Install from Git repository" arrived: it posts to a different path with a body, and
* everything after the first byte is identical — same step and log events, same `done`. Two copies of
* this parser would be two places to get an incomplete frame wrong.
*/
const stream = useCallback(
async (path: string, label: PluginVerb, appName: string, body?: unknown) => {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
setSteps([]);
setResult(null);
setRunning(verb);
setRunning(label);
try {
const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, {
const res = await fetch(path, {
method: 'POST',
headers: getHeaders(),
headers: body ? { ...getHeaders(), 'content-type': 'application/json' } : getHeaders(),
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!res.body) throw new Error(`${verb} failed: no response body`);
if (!res.body) throw new Error(`${label} failed: no response body`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
@@ -265,6 +273,47 @@ export function usePlugins() {
[invalidate],
);
const run = useCallback(
(verb: PluginVerb, appName: string) => stream(`/api/plugins/${appName}/${verb}/stream`, verb, appName),
[stream],
);
/**
* Clone a plugin from its repository so it appears as Available. It does NOT install it.
*
* That distinction is the whole design. Installing from here ran to completion with nothing on screen,
* because the progress log lives in the detail panel and this button is on the grid. Fetching puts a
* card in the grid instead, and the card already has an Install button with the streamed progress
* beside it — so there is one install flow rather than two, and the one that exists is the one people
* have already used.
*/
const fetchFromUrl = useCallback(
async (url: string): Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }> => {
const res = await fetch('/api/plugins/fetch-from-url', {
method: 'POST',
headers: { ...getHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ url }),
});
const body = (await res.json().catch(() => ({}))) as {
appName?: string;
installed?: boolean;
error?: string;
};
/*
AWAITED, so the caller resolves after the list has actually been refetched.
`invalidateQueries` is fire-and-forget: without awaiting it, the confirmation appeared while the
grid was still showing the previous answer, and the new plugin turned up a moment later — which
reads as "nothing happened" and sends somebody looking for a refresh button.
*/
await queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
// A clone that failed discovery still left something on disk worth showing, so this runs either way.
invalidate();
return { ok: res.ok, appName: body.appName, installed: body.installed, error: body.error };
},
[invalidate, queryClient],
);
// Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step
// streams is not a readable log, and the useful answer here is the summary: how many moved, and which
// refused. A dirty checkout is the expected refusal — every plugin directory on a developer's machine
@@ -301,6 +350,7 @@ export function usePlugins() {
/** Which verb is in flight, or null. */
running,
run,
fetchFromUrl,
updateAll,
updatingAll,
updateAllResult,
@@ -1,215 +0,0 @@
import type { DragEvent } from 'react';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { AlertTriangle, CalendarClock, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TodoItem } from './useTodos';
import { useTodoItem, useTodoPatch, useTodoTree } from './useTodos';
/**
* The board over `todo/` — the platform's own backlog, one file per item (todo/README.md).
*
* Selection and the theme filter live in the URL (`?selected=`, `?theme=`), per the navigation audit:
* a card is a real link, a filtered board is an addressable page, and back means what it should.
*
* Writes go through PATCH /todos/item, which touches frontmatter only and regenerates TODO.md — this
* board never holds state the files do not; a drag is a file edit and a refetch.
*/
const COLUMNS = [
{ status: 'open', label: 'Open' },
{ status: 'doing', label: 'Doing' },
{ status: 'blocked', label: 'Blocked' },
{ status: 'backburner', label: 'Backburner' },
{ status: 'done', label: 'Done' },
] as const;
export const TodoBoardPanel = () => {
const { data, isLoading, error } = useTodoTree();
const patch = useTodoPatch();
const [params] = useSearchParams();
const selected = params.get('selected');
const themeFilter = params.get('theme');
const [dragOver, setDragOver] = useState<string | null>(null);
const href = (changes: Record<string, string | null>): string => {
const next = new URLSearchParams(params);
for (const [key, value] of Object.entries(changes)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
const qs = next.toString();
return qs ? `?${qs}` : '.';
};
if (error) {
return (
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to
show.
</div>
);
}
if (isLoading || !data) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Reading todo/…</div>;
}
const themes = data.themes.filter((t) => t.items.length > 0);
const items = themes.flatMap((t) => t.items);
const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items;
const drop = (status: string) => (ev: DragEvent) => {
ev.preventDefault();
setDragOver(null);
const id = ev.dataTransfer.getData('text/todo-id');
if (id) patch.mutate({ id, status });
};
return (
<div className="flex h-full min-h-0">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-1.5 border-b border-border px-4 py-2">
<Link
to={href({ theme: null })}
className={cn(
'rounded-full px-2.5 py-0.5 text-xs',
!themeFilter
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
All · {items.filter((i) => i.status !== 'done').length}
</Link>
{themes.map((t) => {
const open = t.items.filter((i) => i.status !== 'done').length;
return (
<Link
key={t.theme}
to={href({ theme: t.theme === themeFilter ? null : t.theme })}
className={cn(
'rounded-full px-2.5 py-0.5 text-xs',
themeFilter === t.theme
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
{t.heading.split('—')[0]?.trim()} · {open}
</Link>
);
})}
</div>
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
{COLUMNS.map((col) => {
const cards = visible
.filter((i) => i.status === col.status)
.sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0));
return (
<div
key={col.status}
onDragOver={(ev) => {
ev.preventDefault();
setDragOver(col.status);
}}
onDragLeave={() => setDragOver(null)}
onDrop={drop(col.status)}
className={cn(
'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30',
dragOver === col.status && 'border-primary bg-primary/5',
)}
>
<div className="flex items-baseline justify-between px-3 py-2 text-xs font-semibold text-muted-foreground">
<span>{col.label}</span>
<span>{cards.length}</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
{cards.map((item) => (
<TodoCard
key={item.id}
item={item}
href={href({ selected: item.id })}
selected={selected === item.id}
/>
))}
</div>
</div>
);
})}
</div>
</div>
{selected ? <TodoDetail id={selected} closeHref={href({ selected: null })} /> : null}
</div>
);
};
type TodoCardProps = { item: TodoItem; href: string; selected: boolean };
const TodoCard = ({ item, href, selected }: TodoCardProps) => (
<Link
to={href}
draggable
onDragStart={(ev) => ev.dataTransfer.setData('text/todo-id', item.id)}
className={cn(
'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50',
selected && 'border-primary',
)}
>
<div className="text-xs leading-snug text-card-foreground">{item.title}</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="rounded bg-muted px-1 py-px">{item.theme}</span>
{item.priority === 'urgent' && (
<span className="flex items-center gap-0.5 rounded bg-destructive/15 px-1 py-px text-destructive">
<AlertTriangle className="h-2.5 w-2.5" /> urgent
</span>
)}
{item.due && (
<span className="flex items-center gap-0.5">
<CalendarClock className="h-2.5 w-2.5" /> {item.due}
</span>
)}
<span>{item.created}</span>
</div>
</Link>
);
type TodoDetailProps = { id: string; closeHref: string };
const TodoDetail = ({ id, closeHref }: TodoDetailProps) => {
const { data, isLoading } = useTodoItem(id);
const patch = useTodoPatch();
const status = data?.meta.status ?? 'open';
return (
<div className="flex w-[28rem] shrink-0 flex-col border-l border-border">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
<span className="truncate font-mono text-xs text-muted-foreground">{id}</span>
<div className="flex items-center gap-2">
<select
value={status}
onChange={(ev) => patch.mutate({ id, status: ev.target.value })}
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
>
{COLUMNS.map((c) => (
<option key={c.status} value={c.status}>
{c.label}
</option>
))}
</select>
<Link to={closeHref} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</Link>
</div>
</div>
<div className="file-viewer-md min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm">
{isLoading || !data ? (
<span className="text-sm text-muted-foreground">Reading…</span>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.body}</ReactMarkdown>
)}
</div>
</div>
);
};
@@ -1,10 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { SquareKanban } from 'lucide-react';
import { TodoBoardPanel } from './TodoBoardPanel';
export { TodoBoardPanel } from './TodoBoardPanel';
export { useTodoTree, useTodoItem, useTodoPatch } from './useTodos';
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'todo-board', name: 'Todo board', icon: SquareKanban, component: TodoBoardPanel },
];
@@ -1,52 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type TodoItem = {
id: string;
title: string;
theme: string;
status: string;
created: string;
priority: string;
due: string;
order: number;
file: string;
};
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
export type TodoPatch = { id: string; status?: string; priority?: string; due?: string; order?: number };
export const useTodoTree = () => {
const client = useClient();
return useQuery({
queryKey: ['todos'],
queryFn: () => client.get<{ themes: TodoTheme[] }>('/todos'),
// The tree is files on disk that other agents edit while the board is open; refetch keeps the board
// honest without a watcher, which the sync contract allows (todo/README.md — poll is the cheapest
// thing that works).
refetchInterval: 30_000,
});
};
export const useTodoItem = (id: string | null) => {
const client = useClient();
return useQuery({
queryKey: ['todos', 'item', id],
queryFn: () =>
client.get<{ meta: Record<string, string>; body: string }>(`/todos/item?id=${encodeURIComponent(id!)}`),
enabled: id !== null,
});
};
export const useTodoPatch = () => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: TodoPatch) => client.patch<{ ok: boolean }>('/todos/item', patch),
onSuccess: (_data, patch) => {
void queryClient.invalidateQueries({ queryKey: ['todos'] });
void queryClient.invalidateQueries({ queryKey: ['todos', 'item', patch.id] });
},
});
};
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronRight, CornerLeftUp, Folder, FolderPlus, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// The platform's own formatter rather than fofos-admin's, which knew about that plugin's three
// specific failures. Here the only failures are "cannot read that folder" and "not allowed".
import { errorText } from 'helpers/error-text';
/**
* Pick a directory, and make one if it is not there yet.
*
* ── Shared, because two plugins wanted it and a plugin cannot import from another ──
*
* Written inside fofos-admin, then wanted verbatim by kanban's project form. A plugin cannot import
* from a sibling plugin, so the choice was to copy it or to move it here — and there is nothing
* fofos-specific in it: it browses the owner's home over the platform's own file API. Copying would
* have meant two of them drifting apart, which for a component whose whole job is "do not let somebody
* type a path wrong" is the wrong kind of thing to have two of.
*
* ── It uses the PLATFORM's file API, not routes of any plugin's own ──
*
* `/api/file-browser/ls` and `/api/file-browser/mkdir` already exist, already refuse to leave the
* home directory, and already distinguish "missing" from "not allowed" — a distinction that cost
* somebody an afternoon to get right and would have been re-guessed here. A plugin that shipped its
* own listing and mkdir would be a second filesystem surface to audit for the sake of one form.
*
* The consequence is worth stating: this browses the OWNER'S HOME and nothing above it. The file API
* is an execution capability and never grantable, so for anybody but the owner these calls 403 — the
* component says so instead of showing an empty tree, because an empty listing is data and must never
* be how a refusal looks.
*
* A path outside home is still reachable by TYPING it in the field this sits under. The picker is a
* convenience over the common case, not the only way in.
*
* ── `rootDir` comes from the server, so the full path is real ──
*
* `ls` answers with the absolute `rootDir` it resolved as well as the relative path inside it. Joining
* those two is what lets this hand back an absolute path without the browser ever guessing at what
* home means — which it cannot know and would get wrong for anybody but the owner.
*/
type Entry = { name: string; type: 'directory' | 'file' };
type Listing = { path: string; rootDir?: string; entries: Entry[] };
/** `/a/b` under `/home/x` → `/home/x/a/b`. The root itself is `/`, which must not become a trailing slash. */
const absolute = (rootDir: string, path: string) => `${rootDir}${path === '/' ? '' : path}`.replace(/\/+$/, '') || '/';
export const DirectoryPicker = ({
startAt,
onPick,
onCancel,
}: {
/** Relative to home, e.g. `/fofos`. Where the browser opens. */
startAt?: string;
/** The chosen directory, ABSOLUTE. */
onPick: (absolutePath: string) => void;
onCancel: () => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [path, setPath] = useState(startAt ?? '/');
const [listing, setListing] = useState<Listing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const load = useCallback(async (target: string) => {
setLoading(true);
setError(null);
try {
const result = await clientRef.current.get<Listing>(
`/file-browser/ls?root=home&path=${encodeURIComponent(target)}`,
);
setListing(result);
// `ls` resets to the root when a path has gone, and reports where it actually ended up. Following
// its answer rather than our request is what stops a stale path showing the wrong folder's contents.
setPath(result.path);
} catch (failure) {
setError(errorText(failure, 'could not read that folder'));
setListing(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(startAt ?? '/');
}, [load, startAt]);
const rootDir = listing?.rootDir ?? '';
const directories = (listing?.entries ?? []).filter((entry) => entry.type === 'directory');
const parent = path === '/' ? null : path.slice(0, path.lastIndexOf('/')) || '/';
const create = async () => {
const name = newName.trim();
if (!name) return;
setError(null);
try {
const target = path === '/' ? `/${name}` : `${path}/${name}`;
await clientRef.current.post('/file-browser/mkdir?root=home', { path: target });
setNewName('');
setCreating(false);
// Straight into it. Making a folder here means meaning to use it, and stopping at its parent
// makes somebody find what they just made in a list.
await load(target);
} catch (failure) {
setError(errorText(failure, 'could not read that folder'));
}
};
return (
<div className="flex flex-col gap-2 rounded-md border border-border">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs">{absolute(rootDir, path)}</span>
{loading && <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />}
</div>
<div className="max-h-56 overflow-auto px-1">
{parent !== null && (
<button
onClick={() => void load(parent)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
>
<CornerLeftUp className="size-3.5 shrink-0 text-muted-foreground" />
Up one level
</button>
)}
{directories.map((entry) => (
<button
key={entry.name}
onClick={() => void load(path === '/' ? `/${entry.name}` : `${path}/${entry.name}`)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60" />
</button>
))}
{/* Only when there is genuinely nothing, and worded as a fact about this folder. Files are not
listed at all — this picks a directory, and showing them would be a list of things that
cannot be chosen. */}
{!loading && !error && directories.length === 0 && (
<p className="px-2 py-2 text-xs text-muted-foreground">No folders here.</p>
)}
</div>
{error && <p className="px-3 text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
{creating ? (
<>
<Input
autoFocus
value={newName}
onChange={(event) => setNewName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void create();
if (event.key === 'Escape') setCreating(false);
}}
placeholder="New folder name"
className="h-7 min-w-0 flex-1 text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button size="sm" className="h-7" onClick={() => void create()} disabled={!newName.trim()}>
Create
</Button>
<Button variant="ghost" size="sm" className="h-7" onClick={() => setCreating(false)}>
Cancel
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" className="h-7" onClick={() => setCreating(true)}>
<FolderPlus className="size-3.5" />
New folder
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7" onClick={onCancel}>
Cancel
</Button>
{/* Chooses the folder currently OPEN, not one highlighted in the list. Opening a folder to
look inside it and then choosing it is one gesture rather than two, and it is the only
way to choose a folder that has nothing in it yet — which a freshly made one always is. */}
<Button size="sm" className="h-7" disabled={!rootDir} onClick={() => onPick(absolute(rootDir, path))}>
Use this folder
</Button>
</>
)}
</div>
</div>
);
};
@@ -53,6 +53,15 @@ export function ViewerProvider({ children }: { children: ReactNode }) {
fileName={fileName}
onOpenFile={onOpenFile}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -83,6 +92,15 @@ export function EphemeralProvider({ children }: { children: ReactNode }) {
root={ephemeralRoot}
onOpenFile={onOpenFile}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -104,6 +122,15 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
root={ephemeral2Root}
autoPlay={ephemeral2Auto}
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
screen's own convention — so the absolute path the carousel works in is converted back. */
onNavigate={(next) =>
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
return params;
})
}
>
{children}
</FileViewerProvider>
@@ -198,6 +198,13 @@ export type DirEntry = {
type: 'file' | 'directory';
size: number;
modifiedAt: number;
/**
* Set by `ls` when `/thumb` can draw this file, and which kind it is.
*
* The server decides, so there is one list of extensions instead of one per side. Absent on an older
* response, which simply means no thumbnail — the file icon, exactly as before.
*/
thumb?: 'image' | 'video' | null;
};
type ListDirResponse = {
+6
View File
@@ -6,6 +6,8 @@ export { panelCatalogs } from './locales';
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
export * from './AppRegistry';
/** Shared UI a plugin can use. `DirectoryPicker` is here because two plugins wanted the same one. */
export { DirectoryPicker } from './components/DirectoryPicker';
export * from './WidgetRegistry';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
@@ -57,6 +59,9 @@ export {
useRecentFiles,
usePinnedFiles,
FileBrowserApp,
registerFileActions,
type FileAction,
type PickedFile,
FileBrowserPanelWrapper,
FileBrowserWidget,
TaskRunnerModal,
@@ -64,6 +69,7 @@ export {
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
export {
FileViewerView,
FileCarousel,
FileViewerProvider,
FileViewerHeader,
FileViewerBody,
@@ -257,7 +257,11 @@
},
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
"startBlocked": "{{missing}} — dann startet dies ihn.",
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
"confirmDeletingNamed": "Löschen von {{name}} bestätigen",
"fromPlugins": "Von Plugins",
"available": "Verfügbar",
"configureAndCreate": "Einrichten und erstellen",
"notCreatedYet": "Noch nicht erstellt — richten Sie es ein, um zu starten."
},
"chatHistory": {
"newChat": "Neuer Chat",
@@ -538,7 +542,10 @@
"type": "Typ",
"size": "Größe",
"date": "Datum"
}
},
"toCopy": "zu kopieren",
"toMove": "zu verschieben",
"clearClipboard": "Zwischenablage leeren"
},
"taskRunner": {
"stop": "Stopp",
@@ -257,7 +257,11 @@
},
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
"startBlocked": "{{missing}} — then this starts it.",
"confirmDeletingNamed": "Confirm deleting {{name}}"
"confirmDeletingNamed": "Confirm deleting {{name}}",
"fromPlugins": "From plugins",
"available": "Available",
"configureAndCreate": "Configure and create",
"notCreatedYet": "Not created yet — configure it to start."
},
"chatHistory": {
"newChat": "New Chat",
@@ -538,7 +542,10 @@
"type": "Type",
"size": "Size",
"date": "Date"
}
},
"toCopy": "to copy",
"toMove": "to move",
"clearClipboard": "Clear clipboard"
},
"taskRunner": {
"stop": "Stop",
@@ -257,7 +257,11 @@
},
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}",
"fromPlugins": "De plugins",
"available": "Disponible",
"configureAndCreate": "Configurar y crear",
"notCreatedYet": "Aún no creado — configúralo para empezar."
},
"chatHistory": {
"newChat": "Nuevo chat",
@@ -538,7 +542,10 @@
"type": "Tipo",
"size": "Tamaño",
"date": "Fecha"
}
},
"toCopy": "para copiar",
"toMove": "para mover",
"clearClipboard": "Vaciar portapapeles"
},
"taskRunner": {
"stop": "Detener",
@@ -257,7 +257,11 @@
},
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
"confirmDeletingNamed": "Confirmer la suppression de {{name}}",
"fromPlugins": "Depuis les plugins",
"available": "Disponible",
"configureAndCreate": "Configurer et créer",
"notCreatedYet": "Pas encore créé — configurez-le pour commencer."
},
"chatHistory": {
"newChat": "Nouveau Chat",
@@ -538,7 +542,10 @@
"type": "Type",
"size": "Taille",
"date": "Date"
}
},
"toCopy": "à copier",
"toMove": "à déplacer",
"clearClipboard": "Vider le presse-papiers"
},
"taskRunner": {
"stop": "Arrêter",

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