Compare commits

...
Author SHA1 Message Date
PastilhasandClaude Opus 5 efa5703337 file-browser: a thumbnail URL says which version of the file it means
`/thumb` answers `immutable` for a year, and the comment above it explained why that was safe: the
cache key holds the file's mtime and size, so a different file is a different URL.

Half of that was true, and it was the half a browser cannot see. The DISK key has always held mtime and
size, so the server regenerated correctly every time — verified against a file renamed into a name that
had been drawn before: the bytes coming back were right. The URL held the path and the box size and
nothing else, so a browser told it could keep the answer for a year did exactly that, and the picture
on screen was whatever had last occupied the name. Upload, rename, replace a photograph: the thumbnail
did not move, and nothing cleared it but somebody who knew to hard-reload.

The listing already carries `modifiedAt` and `size` per entry, so the client had the facts and was not
using them. It now sends `v=<mtime>-<size>`, from one helper shared by the grid, the list and the
viewer's filmstrip — two spellings would be two URLs for one file, which is two renders and two cache
entries for nothing.

Nothing on the server reads `v`. This route stats the file regardless, and a parameter it depended on
would be a second source of truth about which file is meant. It is in the URL to be part of the URL.

No migration and nothing to clear: the stale entries stay in whatever cache holds them, and are never
asked for again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:41:44 +01:00
pastilhasandClaude Opus 5 06bed42ace a plugin's frontend gets the same defines as the platform's own
One line in a vendored plugin file took the whole app down. scripts/build/web.ts
defines process.env.NODE_ENV for the platform bundle; the plugin frontend build
in generate.ts defined nothing, so any process.* in a plugin's web code was
emitted verbatim into a browser chunk and threw "ReferenceError: process is not
defined" at module evaluation. One throwing module fails the whole graph, so the
symptom is a blank platform rather than a broken panel.

Hit on macbookpro after installing m3e-canvas, which carries four files from a
Next.js original reading process.env.NEXT_PUBLIC_BASE_PATH. This machine was
unaffected only because the plugin is on disk but not installed, so it never
entered Plugins.gen.tsx.

Defines process.env.NODE_ENV for parity and process.env to ({}) for the rest,
so every other process.env.X becomes undefined — which is what the `?? ''` in
that vendored code already expects. Longest match wins; verified against bun
build rather than assumed. Fixed in the build rather than in the vendored tree,
because vendored code stays close to upstream here and this protects every
plugin instead of the one that hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-09-22 13:16:52 +01:00
pastilhasandClaude Opus 5 f4147cef30 caldav cannot install radicale on macOS, and retries forever
Confirmed on macbookpro (macOS 26.6.2): four boots, four identical failures.
caldav's manifest declares apt/pacman/dnf and no brew, so the installer falls
back to the binary name — and radicale is not a Homebrew formula at all, it is
a Python app (pipx/uv). There is nothing correct to put in a brew field, which
is the real gap: PackageManager has no pipx member and osDependencies has no
way to say "unavailable on this platform", so the choice is a wrong guess
retried every boot or silence.

Also recorded, as a NON-bug, because it looked like the real failure and was
already over: the motion/react resolve error appeared on exactly one boot, when
m3e-canvas landed before plugins/node_modules/motion existed. Verified on the
machine — motion is installed and import('motion/react') resolves with 398
exports, and the current boot builds cleanly.

The gap that one exposes is still real: installNpmDependencies is called only
from the install flow, never at boot, so a plugin that arrives without that
flow completing leaves every boot failing a rebuild with "Maybe you need to bun
install" and nothing that ever will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-09-22 12:48:32 +01:00
pastilhas 091cfcca96 deploy report 2026.09.14.01 — 4 of 4 targets
alpha, fofos, macbookpro, deadpool-rezio-bridge all at the tag, officer
back online, health answering. officer-dev skipped as the rollout host;
macbruno unreachable; both Pertento hosts on the skip list.
2026-09-14 20:16:51 +01:00
pastilhas 4a37cfd2ce release 2026.09.14.01 2026-09-14 20:11:15 +01:00
pastilhas 36db9a2ae0 command-hq: self-reported arm/fire/disarm for loops that outlive their turn
The existing arm path only fires off the harness's own task:started/
task:notification tracking for the live session that submitted the dialog,
so a loop started detached (setsid/nohup) or as a tracked job that dies
with its turn — which is every real commander on this machine — never
arms in Postgres and never records a fire, even after running correctly
for weeks. Adds POST /commanders/dir/:dirName/{arm,fire,disarm}, a second
door to the same armCommandHqWatcher/recordCommandHqWatcherFired/
disarmCommandHqWatcher functions, authenticated with the commander's own
long-lived OFFICER_API_KEY and keyed on dirName since that's what a loop
already knows about itself.
2026-09-14 19:48:11 +01:00
pastilhas 78896793aa correct the 2026.09.09.01 report: no cron moved those hosts
The agent inferred auto-pull.sh had pulled alpha and fofos ahead of the rollout. It had not. alpha was
rolled by hand by the session that cut the release, before being stopped; fofos was current because it
had been deployed to all day for plugin work.

The wrong reading is kept in the text rather than deleted — a real cron, on the right machine, with the
right effect, and nothing to do with this. That is what a plausible wrong answer looks like.
2026-09-09 23:32:37 +01:00
pastilhasandClaude Opus 5 9217d1faaa deploy report 2026.09.09.01 — 4 of 4 targets
plugin_ports verified by query on every host. alpha and fofos were
already at the tag; macbookpro and deadpool were real fast-forwards
and the only two that needed @anthropic-ai/sdk.

Two plugins did not update, both blocked by uncommitted local work:
av-club on alpha, fofos-website on fofos. Nothing was discarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 23:28:31 +01:00
pastilhas be3139f97d release 2026.09.09.01
Twenty-seven commits and ten days. One new table, plugin_ports, which is a plain create — nothing
references it, so db:push has no ordering problem to get wrong. The marketplace's nullable release_tag
is deliberately out of scope: db:push covers officer_db, and the marketplace runs only on officer-dev,
which the rollout skips.

@anthropic-ai/sdk is new, so bun install is a step. The lockfile is committed and matches; a host that
cannot resolve from it is a finding, not something to force past.
2026-09-09 23:17:12 +01:00
pastilhas fafc1adbfd anthropicViaProxy — the boundary for platform-side Claude calls
The proxy secret lives in sidecar/claude-state.json: platform-internal state whose location, format
and permissions are this repo's to change. The first caller outside the sidecar read the file
directly, which worked and meant the sidecar could no longer move its own state without breaking a
plugin. Plugins import this module now, and only platform code touches the file.

A module and not an HTTP route, deliberately: every caller is server-side code in this process,
already behind its own gate. An endpoint that spends the owner's subscription would be new surface —
reachable by anything with a session, needing its own registry entry — bought for no caller that
exists. The day a browser-side caller appears, the route is a thin wrapper over this.

The SDK is re-exported from here too, because a plugin resolving its own copy from the plugins'
node_modules would have its own class identities — and an error thrown by this copy is not an
instanceof the other's RateLimitError, so error mapping silently stops matching.

Deliberate unfreeze for @anthropic-ai/sdk ^0.71.0; the lockfile diff is that one package and its
json-schema-to-ts dependency.
2026-09-09 22:48:05 +01:00
pastilhas b0ceb52bf1 Move both Pertento hosts to deliberately-skipped deploy targets
Owner's call, not an incident on either box. Details kept in the
skipped section so re-adding them is a move, not a re-derivation.
2026-09-08 22:10:18 +01:00
pastilhas 861e828fe2 Theme keys treat a code editor as typing
Monaco is neither a textarea nor contenteditable — its modern input surface is a plain div the
EditContext API writes into — so the tagName guard missed it and ctrl+shift+arrow cycled the theme
while somebody was selecting words in a file. `.monaco-editor` is the class its root always carries.

This belongs here rather than in the editor: capture runs window inward, so a global shortcut
registered on window cannot be stopped from a node deeper in the tree. A handler that guards on "am
I typing" is the only place that can answer it.
2026-09-08 15:16:44 +01:00
pastilhas da07048f53 FileBrowser can report the folder it is showing
A chooser whose answer is a folder had no way to ask. Folders navigate rather than being pickable —
getting to a file requires it — so the only way to say "this one" is to be standing in it, and the
caller needs to know which one that is to put a button on it.

Reported from the component rather than threaded through the hook: the hook's job is the folder,
and a seventh positional argument is how a signature stops being readable.
2026-09-07 14:06:46 +01:00
pastilhas 5fa3f3ec76 FileTree can reveal a path
For a tree whose selection is driven from outside — a nav beside it, a URL — where a
folder left shut around the open file hides the one thing being looked at. Every directory
on the way opens, loading as it goes.

It only ever opens. A node that closed itself when the selection moved elsewhere would
collapse the tree under somebody as they clicked around it.
2026-09-06 15:25:13 +01:00
pastilhas fb5304563b export FileTree, so a plugin can have the code editor's tree
It only lists — opening is the caller's callback — so a panel that wants the editor's file
list against its own directory no longer has to draw one.

fofos-website is the first: it was rendering its own tree, and the editor's is the one
people recognise.
2026-09-06 15:22:25 +01:00
pastilhas f31aeb4f6e a restarted process kept an environment the ecosystem file no longer had
startProcess used 'pm2 start <name>' for a process PM2 already knew, which
restarts it with the environment PM2 is holding and never re-reads the file. That
was harmless while an entry was a script path and a cwd. It stopped being
harmless the moment entries carried env.

Seen once, immediately: a plugin's allocated port was re-drawn, the installer
wrote PORT_SITE=34507 into ecosystem.config.cjs, the sidecar went on serving 3011,
and nothing anywhere reported the disagreement. The file and the process simply
disagreed, silently, which is the exact failure the port mechanism was supposed
to remove rather than introduce.

Now it restarts THROUGH the file - 'restart <file> --only <name> --update-env'.
The file is re-read, and --update-env replaces the stored environment rather than
merging into it, so a variable removed from an entry actually goes away.
2026-09-06 11:00:19 +01:00
pastilhas d9110c373d a manifest cannot ask for a particular port
A preference sounds helpful and fails silently. When the wished-for number is
taken the plugin is handed a different one, the install succeeds, and whatever
was configured against the old number goes on pointing at nothing - with nobody
told. This afternoon that nearly happened: 3011 was only free because fofos-dev
had been stopped twenty minutes earlier, and had it still been running the site
would have been given a random port behind an nginx host still aimed at 3011.

So the number is always drawn fresh and always kept. Always drawn means it is
never something you can assume; always kept means the proxy host is configured
against it exactly once. The failure mode that remains - having to go and read
the number - is one that announces itself, which the other one did not.

isFree went with it; it existed only to test a wish.
2026-09-06 10:58:41 +01:00
pastilhas 518d329c29 the platform can allocate a plugin a port, and remember it
A sidecar already gets a port and nothing here changes that: it binds 0, the
kernel picks, it announces the number. That is right for what it is - a private
detail between two processes, loopback, re-chosen every restart, and nothing
outside Officer needs it.

A port something ELSE connects to is a different thing. A plugin serving a
website has to be reachable from nginx, which means the number must survive a
restart and be knowable by whoever writes the proxy configuration. Ephemeral
fails both, which is why the first site plugin was going to hardcode 3011 and
hope.

So: `extraPorts: [{ name: 'site', preferred: 3011 }]` in a manifest, allocated at
install, persisted in plugin_ports, and delivered as PORT_SITE in the sidecar's
pm2 environment. Environment rather than the registration socket because a socket
answer arrives after the process has already had to decide what to bind, and pm2
is the only thing that can tell a process something before it starts.

What the platform guarantees is the part per-plugin config could never do: no two
plugins are ever handed the same number. That is a unique index rather than a
convention, because a collision is not a duplicate row - it is two services
fighting over a listener where the loser fails to bind and the winner is whoever
started first.

`preferred` is a wish, not a claim. A plugin usually arrives after the thing that
connects to it, and moving that configuration is work the platform can spare
somebody; but a preference that could fail an install would be a claim wearing a
softer word.

Allocations survive uninstall and are released only by purge. The number is
written down where this database cannot see it, so handing it to a different
plugin because this one was briefly removed points that configuration at the
wrong service.

Pm2App gained `env`, emitted as JSON rather than hand-quoted: a port is a number
today, but this is the seam where a path arrives eventually, and one apostrophe
in a value produces a file PM2 cannot parse - which takes every process on the
machine down, not just the plugin's.

Verified: stable across calls, distinct across plugins, released cleanly,
honours a preference, and a plugin declaring no ports gets no env key at all.
2026-09-06 10:47:26 +01:00
pastilhasandClaude Fable 5 b99a1e7443 todo: m3e upstream said no — vendoring is the path
issue #55 and PR #56 both closed unmerged. two reasons, and the first
one is ours: the PR wired one of three clipboard writes and left
Inspector and PromptPanel writing directly, so it added a public surface
without delivering the thing it was for. the issue text named all three
sites and the PR wired one.

the second is theirs and fair — embedding is not on their roadmap and it
is a single-user need so far.

what survives is worth more than the merge would have been: they said
the adapter design is sound and they would start from it, and named what
a complete version needs — the Canvas extraction plus every export path.
that is a specification for the fork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 02:47:20 +01:00
pastilhasandClaude Fable 5 f5bd611848 todo: m3e-canvas as a plugin, with the measurements
not decided. the card carries numbers so the decision is not made from a
readme — which is what produced the first, wrong estimate.

the finding: next.js is not actually there. one import in 14,822 lines
and it is type-only, so there is nothing to strip. the fork line then
falls on a file boundary — page.tsx and lib/ai.ts are ours (~3,600
lines), the rest vendors (~11,200), and the split is meaningful rather
than convenient. buildPrompt/effectivePrompt are pure functions, so the
export point is a call rather than a bridge.

against it: 37 commits, three days old, 22 of them landed today. two
authors. 'vendor and track upstream' assumes an upstream that has
settled and this one has not started.

so the cheapest next step is an issue asking upstream for an explicit
seam — depend rather than fork. the answer changes what gets built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 20:32:12 +01:00
pastilhasandClaude Opus 5 5dac80e13b an attached screenshot is shrunk to the size the model actually reads
An image goes into the message as base64 and STAYS there: every later turn re-sends every earlier
picture. So a screenshot is not paid for once, it is paid for on every turn for the rest of the
session.

Past 1568px on the long edge the model never sees the extra pixels — Anthropic downscales server-side
before it looks — so the width was carried up the wire, counted, and thrown away. Measured on one day
of one session: 25 screenshots, 10 over the line, 53,300 tokens of image data, 23% of it buying
nothing.

The ORIGINAL is still uploaded and still lands on disk untouched, so an agent that needs the detail can
Read it at full size. That is the right split: the conversation carries what is worth re-sending every
turn, the disk keeps what was captured.

Not re-encoded when it is already under the ceiling — a PNG round-tripped through a canvas is often
bigger than the one the screenshotter wrote.

This is a cost and size fix and is NOT confirmed as the cause of the "image too large" errors. What
was ruled out: no image today was near a per-image limit (largest 0.66 MB, 1662x2044), and the two
that failed were 679x869 and 1464x1407 — small. The remaining suspect is cumulative, which this
reduces without proving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:10:36 +01:00
pastilhasandClaude Opus 5 19bbbab907 publishing a plugin is pushing a tag
Three mechanisms where there should be one. The catalogue was filled by hand, and each host separately
polled twenty-one repositories with git to work out whether a release existed — a second source of
truth for a fact one place already knew. Shipping photos 2026.09.03.02 correctly and watching alpha
not notice for ten minutes is what started this.

The timer. scripts/marketplace-cron.sh, every minute, on the machine that publishes. Safe now only
because the previous commit made the sync read tags instead of working trees; over the old one this
would have published somebody's afternoon on a schedule. Nothing is written unless the release commit
differs from the one already published, so a tick with nothing to do is one query, one fetch per repo
and no writes — about 2.7s. One timestamped summary line per tick regardless, because a fresh timestamp
is the only thing that separates "nothing to publish" from "the timer died"; a named line only for a
plugin that moved.

The badge. `updateable` now compares the tag at HEAD with the tag the catalogue says is newest —
difference, not ordering, since parsing 2026.08.27.01 works right up until somebody tags v2 and then
fails silently. refreshRemote() and its two-minute window are gone with the polling they existed to
paper over. The git rule stays as the fallback for a plugin the store cannot speak for: an unregistered
checkout, or an unreachable store. That case now reads whatever refs the last pull left, which is the
accepted cost of not making every machine poll every repository forever.

fetchCatalogue()'s cache went from five minutes to one. It only drew tiles before; now the wait for a
release to appear would have been the sync's minute plus its five.

Also: the Update button names its destination whenever a release is known, not only on `new-release`.
An untagged checkout reports `untagged` and used to lose the tag with it, so the button said "Update"
and the owner had to guess where it landed — the marketplace knows, even for a checkout that has never
fetched it.

Verified: forced a stale row, the timer republished it inside 60s and logged one line. Badge decisions
checked against the live catalogue for seven plugins, including the two shapes that matter — at a tag
the store agrees with, and untagged with a release known only to the store. 9 release tests pass, 100%
of release.ts covered, four of them new and all against real repositories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:13:43 +01:00
pastilhasandClaude Opus 5 78e89ebcec the marketplace publishes releases, not working trees
The sync read `platform/plugins/` — the directories this machine has installed — and imported each
manifest off the working tree. So publishing meant "get officer-dev's checkout to the release, then
run the script", and nothing showed when that had not happened. Live proof at the time of writing:
av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26 were all in
the store, and none of the four has ever been tagged.

It is also why the job could not go on a timer. A cron over it published whatever was in a working
tree, on a schedule.

Now every registered plugin is mirrored bare into ~/.cache/officer-marketplace/mirrors and read AT ITS
NEWEST TAG — manifest, README and file list all out of `git cat-file`. No working tree in the path, so
publishing something uncommitted stops being a mistake anyone can make.

docs/marketplace.md proposed reading manifests over Gitea's API, which needed a sandbox or a committed
manifest.json. Git gives the same guarantee with neither, and the trust model is unchanged: we import
our own repositories' code. Third-party submissions still need one of those two.

The list of plugins now comes from the `plugins` registry table, which the docs always called the
authoritative half and which was in fact a by-product of what happened to be installed. That is what
lets `finances` be published without being installed here, and it spans orgs — fofos-admin lives under
fofos/, which an org listing would have missed. Adding one is `--add <clone url>`.

A plugin with no tag is left alone and named in the report, not deleted. It has made no release, so
there is nothing true to say about it; dropping its row would be a decision about somebody's plugin
dressed up as a refresh.

Also: `release_tag` on plugin_metadata, because the tag is the fact when a manifest version disagrees
with it; the sync warns on a mismatch. And icon URLs pin to the tag — `/raw/HEAD/` served whatever was
on the default branch, via a 303, so a plugin could show one icon and ship another.

Verified: 21/25 published from a release, 4 named as untagged, all 14 tag-pinned icons answer 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:05:50 +01:00
pastilhasandClaude Opus 5 8d39586de5 two minutes, not ten
The way this gets used is: ship a plugin, open the page, look for the badge. Ten
minutes of that is indistinguishable from the feature not working — a release
tagged at 12:42 was invisible at 12:45 because the last fetch was at 12:37, which
is correct and no comfort.

Bounded by the page being open: nothing fetches when nobody is looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:47:09 +01:00
pastilhasandClaude Opus 5 eaa92738dc give :section back — the splat took it from fifteen plugins
`/<route>` and `/<route>/:section` are what every plugin has always had, and
fifteen of them read useParams().section. Replacing the pair with a splat took
that parameter away from all of them at once: the screens still mounted and every
one lost its selection, which is worse than a 404 because nothing says so.

The splat stays, for depth. React-router ranks a static or dynamic segment above
it, so a plugin with one segment gets `section` again and only deeper paths fall
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:37:14 +01:00
pastilhasandClaude Opus 5 b2349525fd the update badge stops going stale
releaseState compares the tag at HEAD with the newest tag on origin/<branch> — a
remote-tracking ref, and nothing refreshed it. Tags arrived at clone and at the
fetch pullPlugin does, so you only learned an update existed BY UPDATING.

alpha had photos at 2026.08.27.01, last fetched 27 August, comparing this
morning's release against a snapshot from before it existed. Truthfully at the
latest, and useless.

refreshRemote fetches in the background: the request answers from the refs it has
and the next one is current. Rate-limited per checkout, one at a time, silent on
failure — the list still has a true answer without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:31:30 +01:00
pastilhasandClaude Opus 5 898209725a a plugin owns the routing under its mount
Reverts the manifest route table. Declaring a plugin's URLs to the platform put
the platform in the middle of a decision that is not its own, and it was more
code than what it replaced.

One splat where there were two routes. A splat matches zero segments, so the
bare route still works, and the plugin reads its own position off the location.
The platform owns where a plugin starts and nothing after it — the same line
mountPrefix already draws around its API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:17:33 +01:00
pastilhasandClaude Opus 5 1fe767ea77 a plugin screen can declare its routes
Every screen got `/<route>` and `/<route>/:section` and nothing deeper, so a
section had to live in the query string. A screen may now name its routes in
the manifest, and give one its own arrangement of the screen's panels.

Absent, the old pair is unchanged — which is every plugin but fofos-admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 04:57:18 +01:00
pastilhasandClaude Opus 5 43f32a7440 export useAuthoredLayout, for a workspace inside a panel
A nested workspace wants the same bargain PluginScreen makes: structure from source every render, only
sizes and zoom persisted. `useDashboardState` was the only export, and it persists the whole tree —
right for a dashboard somebody arranged, wrong for a layout its author ships, because the structure
would freeze at whatever it was the first time a screen was opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:02:37 +01:00
pastilhasandClaude Opus 5 cdbf72c7fe the file browser says when something is a link
An arrow beside the name, and the target in its tooltip.

`stat` follows a symlink, so the listing has always described what the link POINTS AT and never that
there was a link in the way. That is right for behaviour — a link to a folder must still say
`directory` so opening it works — and it is why `stat` cannot answer this question at all.

`readdir({ withFileTypes: true })` can, without a second syscall per entry: the kind comes off the
directory entry itself. `readlink` is asked only for the entries that are links, which in an ordinary
folder is none of them.

A BROKEN link now gets a row, and did not before. `stat` failed on it, the entry returned null, and it
disappeared from the listing — nothing said it was there, nothing could delete it, and the folder
quietly had fewer things in it than `ls` showed. It is drawn in the destructive colour with its dead
target in the tooltip.

The target is stored exactly as written rather than resolved. `../originals` moves with the folder and
`/home/…/originals` does not, and rewriting one into the other would hide that difference at the
moment somebody is looking in order to understand it.

An arrow, not a badge or a different icon: the icon says what the thing IS, and a symlink to a folder
is still a folder to whoever uses it. What the arrow adds is the indirection — which is what explains
why deleting it freed no space, or why two places show the same file.

Verified against real links: to a file, to a directory, absolute, and broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:10:21 +01:00
pastilhasandClaude Opus 5 ab2336516f dev-proxy: a stylesheet's url() is a URL
Two bugs, one line apart, both silent.

The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.

Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.

JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:42:14 +01:00
pastilhasandClaude Opus 5 9f243f569f native controls follow the theme
Nothing set `color-scheme`, so the browser drew every control it renders itself as if the page were
light. On a dark theme a <select>'s dropdown opened as a white sheet with the page's own pale text on
it — unreadable, and unreachable by any utility class, because that list is painted by the browser and
is not in the document.

`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with no
styleable interior: date and colour pickers, the spinner on a number input, and default scrollbars
where a theme has not replaced them.

The `dark` class decides it, because that is the signal that exists — 203 of the 231 themes set
applyDarkClass, and the provider puts it on <html> beside data-theme, so the two always agree about the
same element. Both are specificity 0-1-0 and themes import after this file, so a theme that disagrees
can say so. This is a default, not a decision taken away.

Explicit colours on select/option/optgroup underneath, because color-scheme gives the browser's generic
dark rather than the theme's. Where a browser honours them — Firefox, Chrome on Linux and Windows — the
list becomes the theme's own popover surface. Where it does not, Safari and Chrome on macOS use a
genuinely native menu and the scheme carries it, which is a great deal better than white on white.

Base-layer rules, so a utility still wins and `bg-transparent` on a select behaves as it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:38:59 +01:00
pastilhasandClaude Opus 5 160788780b the dev proxy did not rewrite srcset
Responsive images 404'd in every preview, and did it silently.

The attribute rule matches a whole attribute value, so it could never have handled srcset — that is a
comma-separated LIST of "url descriptor" pairs, and prefixing it as one value would mangle the first
descriptor onto the first URL. It did not match srcset at all.

Silently is the operative word. An <img> with a srcset also has a src, and the src WAS prefixed — but a
browser given a valid srcset chooses from it and never looks at src. So the markup read correctly, one
attribute pointed at the platform root, and the image was just missing. Found by a logo that would not
load in the fofos layout preview.

imagesrcset is rewritten too. That is the one on <link rel="preload" as="image">, easy to miss and
expensive to miss: a preload that 404s costs a request and leaves the real image undiscovered until the
body is parsed, which is the opposite of why anybody adds one.

Six tests, including the case that found it — src prefixed, srcset not, image missing — and a
protocol-relative candidate left alone, which is the rule everywhere else in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:10:58 +01:00
pastilhasandClaude Opus 5 41f54a292b the constraint goes on the grid child, not inside it
The previous fix put `min-w-0` on the scroll container, which was one level too low to stop anything.
DialogContent is a grid; the element that expands is its DIRECT child, and between the two sat a
`flex flex-col gap-3` wrapper with no constraint at all. It grew to fit the title and took the card out
of the dialog with it, exactly as before.

Found by mapping the element chain from DialogContent down rather than reasoning about which element
"should" be the one — the wrapper does not appear in any of the code you read while thinking about the
card, which is why two attempts missed it.

Also confirmed the previous fix was genuinely live before blaming it: the served chunk on fofos carried
the new class and not the old one. The code was right and the diagnosis was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 23:00:14 +01:00
pastilhasandClaude Opus 5 ae70dd5ea8 the download dialog stops growing to fit its titles
A long video title pushed the modal wider than itself and ran out of the card. The title had `truncate`
and its parents had `min-w-0 flex-1`, and none of it bit.

DialogContent is a GRID. A grid item defaults to `min-width: auto` — it refuses to shrink below its
content — so the list container expanded past the dialog's own `max-w-lg`, the card grew with it, and
`truncate` never had a width to truncate against. Every constraint below that point was measured against
a box that had already given up.

`82d24a8f` made the card a column, which was a real fix for a real half of this: the title, thumbnail
and button used to share a line and the title won. But a column still cannot be narrower than the
widest thing the grid above it will allow, so the fix held only until a title long enough came along.

Worth knowing beyond this dialog: every DialogContent in the app is that same grid, so any dialog
holding unbreakable content has the same trap waiting. Fixed here rather than in the shared component,
because changing the layout of every dialog in the platform to fix one modal is not a change to make
without asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:40:57 +01:00
pastilhas 060033322d deploy 2026.08.30.03: 7 of 7 hosts, fofos plugins:update back to 5/5 2026-08-30 11:45:49 +01:00
pastilhasandClaude Opus 5 c97da0292a release 2026.08.30.03
One commit — 537cc29c, fixing what the 2026.08.30.01 rollout found on fofos: a plugin declaring
`@tiptap/pm` was reported as a failed install. Nothing was broken on disk. That package has no root
entry point by design — it is tiptap's single pinned copy of ProseMirror and you import
`@tiptap/pm/state`, never the bare name — so the checker asked a question it could not answer.

Worth its own release rather than waiting: `bun run plugins:update` exits 1 on fofos every time until
this lands, so the plugin step of every future deploy fails there and reads as a new problem each time.

No schema change and no dependency change, with the two commands that prove it in the plan. docs:check
green. Only fofos has fofos-admin installed, so everywhere else this is a no-op in behaviour.

── Why .03 ──

2026.08.30.02 was tagged against a commit that was not on master: the rollout's own report had just been
pushed and the release was cut without pulling first. The branch push was rejected and the tag was not,
so a tag pointing off-branch existed for about ten seconds. The watcher claimed the version inside that
window, and a claim is permanent by design — so .02 is spent and carries a claim with no .done, which is
what "attempted and did not succeed" should look like.

No host moved. Every one stayed on 816f8695 throughout.

The lesson is in the ordering, and it is now the practice: pull, then push the branch, then tag only
once the branch has landed. Pushing both in one command means a rejected branch still publishes a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:38:06 +01:00
pastilhasandClaude Opus 5 d2c07a9fd2 deploy 2026.08.30.01: 7 of 7 hosts, officer-dev skipped as the host it ran on
fofos-admin failed its resolvability check on fofos — @tiptap/pm has no root
entry point, so the install was perfect and the checker was wrong. Fixed by
537cc29c, which is one commit past this tag and therefore deployed nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:23:23 +01:00
pastilhasandClaude Opus 5 537cc29c3e a package with no root entry point is not a broken install
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every declared package. That is
the right question for almost all of them and unanswerable for a few.

`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no "." — it exists only as
subpaths. You import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name Bun
correctly says it cannot, and this read that as a failed install.

It cost a deploy. fofos-admin declares `@tiptap/pm` because tiptap needs it present; the install was
perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin was
reported as failed on every host that has it.

The strong check stays first, because it is the one that catches a package which installed but cannot
be loaded on this machine — `sharp` resolving to a platform-specific binary is the shape it was written
for. Only a package that fails it is asked the weaker question: is it there. Present-but-unimportable
still fails, since it never reaches the second line.

Verified on the host rather than reasoned about: of the five packages fofos-admin declares, four
resolve and @tiptap/pm alone does not, and it is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:20:25 +01:00
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
76 changed files with 3535 additions and 318 deletions
+5 -5
View File
@@ -22,14 +22,14 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**108 open, 33 closed**, across 35 themes.
**110 open, 33 closed**, across 35 themes.
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 2 | 0 |
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
| [Backburner](todo/backburner/) | 3 | 0 |
| [Backburner](todo/backburner/) | 4 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 1 |
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
@@ -41,7 +41,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 0 |
| [Known bugs](todo/known-bugs/) | 7 | 4 |
| [Known bugs](todo/known-bugs/) | 8 | 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 |
@@ -71,6 +71,8 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-09-22` **caldav retries `brew install radicale` on every boot, and there is no such formula** — [known-bugs/radicale-cannot-be-installed-on-macos](todo/known-bugs/radicale-cannot-be-installed-on-macos.md)
- `2026-09-04` **M3E Canvas as a plugin — sketch a UI, hand the prompt to the agent** — [backburner/m3e-canvas-as-a-plugin](todo/backburner/m3e-canvas-as-a-plugin.md)
- `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)
@@ -79,5 +81,3 @@ the owner's OS user and can never be granted. Indirection there really is accide
- `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)
+7
View File
@@ -6,6 +6,7 @@
"name": "officer",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
"@anthropic-ai/sdk": "^0.71.0",
"@bitcoinerlab/secp256k1": "1.2.0",
"@getalby/sdk": "^8.0.3",
"@hookform/resolvers": "^5.2.2",
@@ -324,6 +325,8 @@
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.59", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-xPOUZZimZI5ChaO791olWGXqaRvCwOfj9/1micu42EL9czdcwiDm0WK1OGsqb2mZ7LSCoYWBB0ZHVKOxehemDA=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@@ -1616,6 +1619,8 @@
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -2290,6 +2295,8 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+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.
+114
View File
@@ -0,0 +1,114 @@
# 2026.08.30.01 — rollout report
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
One plugin failed on one host (`fofos-admin` on `fofos`) and it is a false alarm in the checker, not a
broken install. The owner fixed it mid-rollout in `537cc29c`, which is NOT in this tag. Detail below.
No schema change, no `bun install`, no `bun db:push` — the plan says so and shows the two
`git diff --name-only` commands behind the claim. Nothing was improvised.
## Per host
| Host | HEAD before | HEAD after | plugins:update | `officer` restart_time | /api/health | file-browser `ls` |
| --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | — | — | **SKIPPED — ran here** |
| `alpha` | `e9b71522` | `816f8695` | 15/15, exit 0 | 6 → 7 | 401 | 200, 77 entries |
| `fofos` | `601849be` | `816f8695` | **4/5, exit 1** | 3 → 4 | 401 | 200, 39 entries |
| `macbookpro` | `e9b71522` | `816f8695` | no plugins installed | 6 → 7 | 401 | 200, 62 entries |
| `macbruno` | `e9b71522` | `816f8695` | 1/1, exit 0 | 9 → 10 | 401 | 200, 41 entries |
| `production-pertento-bridge` | `e9b71522` | `816f8695` | 1/1, exit 0 | 7 → 8 | 401 | 200, 53 entries |
| `edge-pertento-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 8 → 9 | 401 | 200, 29 entries |
| `deadpool-rezio-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 25 → 26 | 401 | 200, 49 entries |
Every host landed on `816f8695`, exactly the tag. `fofos` started ahead of the previous tag and behind
this one, as the plan predicted, so its fast-forward moved 12 files rather than 23.
`401` on `/api/health` is healthy — the plan says so and all seven agreed. Ports were read from each
host's own `.env`, never assumed: 9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere.
## The file browser check
This release touched the listing loader, the clipboard and the row markup, so the plan asked for one
signed `/api/file-browser/ls` per host. **Seven hosts, seven `200`s**, every one with a plausible count
and none of them zero. The counts differ because the homes differ.
Signed as that host's own owner, read from its own `users` table rather than assumed —
`conceiba@gmail.com` on `macbruno`, `brunorezio@gmail.com` on `deadpool-rezio-bridge`,
`pastilhas@officer.dev` on the rest.
## Schema
**No schema verification was performed, because this release changes no schema.** No `db:push` step
exists in the plan and none was run, so there is nothing to verify by query. The clipboard is a key in
`user_state`, a JSONB column that already existed.
The plugin sidecars applied their OWN schemas during `plugins:update`, which belongs to the plugin
updater and not to this release: `music` on `alpha` (4 tables), `proxy-manager` on `fofos` (3) and on
`macbruno` (3), `offscale` on `edge-pertento-bridge` (1), `email` on `deadpool-rezio-bridge` (1).
## The one failure: `fofos-admin` on `fofos`
`bun run plugins:update` exited 1 with:
```
FAILED: packages installed, but @tiptap/pm cannot be resolved from …/plugins/fofos-admin
```
**The install is fine and the plugin is not broken.** Verified on the host rather than assumed:
- `plugins/node_modules/@tiptap/pm` is present, with its `dist` and the prosemirror packages.
- `@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths. Code imports `@tiptap/pm/state`, never the bare name.
- `Bun.resolveSync('@tiptap/pm', pluginDir)` therefore fails, while `@tiptap/react` and
`@tiptap/starter-kit` both resolve. `unresolvableDependencies` reads that as a failed install.
- `fofos-admin`'s own checkout is clean, so this is not the usual uncommitted-change cause.
Also verified there, and it is this release working as intended: `react` resolves out of the PLATFORM's
`node_modules`, not the plugins tree. The eviction added in `9719e367` did its job.
**The owner shipped the fix at 11:20 while the rollout was in flight — `537cc29c`, "a package with no
root entry point is not a broken install".** It is one commit PAST this tag and is therefore deployed
nowhere. `fofos` will keep failing that step on every `plugins:update` until a release carries it.
What this cost on `fofos`: `fofos-admin` did not finish its update — no assets publish, no access grant,
no `recorded at`. Its code was already at the latest commit, and the platform mounted `/fofos-admin`
normally. The other four plugins updated.
## A commit landed on master mid-rollout, and the tag absorbed it
`alpha`, `fofos`, `macbookpro`, `macbruno` and `production-pertento-bridge` fetched `origin/master` at
`816f8695`. `edge-pertento-bridge` and `deadpool-rezio-bridge` fetched it at `537cc29c`.
Because every host merged `--ff-only deploy/2026.08.30.01` and not the branch, **all seven are on the
tag and none carries the extra commit.** This is the exact failure of 2026-08-23, and merging the tag is
what prevented it.
## 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`. `macbruno` moved `proxy-manager`,
which declares no sidecar, so nothing extra bounced there.
After every restart, `pm2 jlist` showed **every `officer*` process `online` on all seven hosts**. None
was `errored` and none failed to come back.
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched. On
`production-pertento-bridge` the eight Pertento-owned processes — `Dashboard`, `Tracking`, `Uptime` and
the five `Crons *` — were filtered out of every read and never named in a restart.
## What I did not do
- **`officer-dev` — skipped entirely.** Not pulled, not restarted. The rollout ran inside its
`officer-claude-code` sidecar, and restarting it would have ended the session writing this report. Its
working tree already held this code. The owner restarts it deliberately, as the last step.
- **Did not fix the `fofos-admin` checker.** The fix exists in `537cc29c` and belongs to a release, not
to a rollout editing five hosts.
- **Did not re-run `plugins:update` on `fofos`** after diagnosing it. It would fail identically — the
code that decides is in the tag.
- **Did not run `bun run docs:check`.** Not the commander's job.
- **One untracked file noted, not touched:** `scripts/chk.ts` on `fofos`. Untracked is a note, not a
skip, and a fast-forward could never have touched it. Every host was clean on
`--untracked-files=no`.
+99
View File
@@ -0,0 +1,99 @@
# 2026.08.30.03 — a package with no root entry point is not a broken install
**One commit.** `537cc29c`, fixing what the 2026.08.30.01 rollout found on `fofos`.
**Why .03 and not .02.** `2026.08.30.02` was tagged against a commit that was not on `master` — the
rollout's own report had just been pushed and the release was cut without pulling first. The branch push
was rejected, the tag was not, and a tag pointing off-branch was published for about ten seconds before
being withdrawn. The watcher claimed the version inside that window.
A claim is permanent by design and that is correct: `.02` was attempted, so `.02` is spent and cannot be
deployed. It has a claim and no `.done`, which is exactly what "attempted and did not succeed" should
look like in the record. Nothing reached any host — every one stayed on `816f8695` throughout.
**No schema change and no dependency change.** Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 | grep -iE 'schema|/db/|databases' # nothing
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 -- package.json bun.lock # nothing
```
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
## What it fixes, and what it does not
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every package a plugin declares.
That is the right question for almost all of them and unanswerable for a few.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths, because it is tiptap's single pinned copy of the ProseMirror packages and you import
`@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name, Bun correctly says it cannot,
and the checker read that as a failed install.
**Nothing was ever broken on disk.** The install on `fofos` is complete — the directory, its `dist` and
thirteen prosemirror packages. The editor works. What failed was the verification, which then failed
`plugins:update`, which exits non-zero if any plugin failed.
The consequence worth shipping for: **`bun run plugins:update` exits 1 on `fofos` every time** until
this lands, so the plugin step of every future deploy fails there and looks like a new problem.
The strong check stays first, so a package that installed but cannot be loaded on this machine still
fails — `sharp` resolving to a platform-specific binary is the shape it was written for. Only a package
that fails it is asked the weaker question: is it there.
## 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.03`, `bun run plugins:update`), plus:
```bash
pm2 restart officer
```
`officer` only — `plugins:update` has already restarted the plugin sidecars.
**`fofos` is the host this release is for.** Its `plugins:update` failed 4/5 on the last rollout and
should now be 5/5, exit 0. That is the check that matters:
```
→ fofos-admin
packages: installed and resolvable
```
If `fofos` still reports 4/5 with `@tiptap/pm cannot be resolved`, this release did not work and that
is the finding — report it rather than working around it.
Everywhere else this is a no-op in behaviour: no other host has `fofos-admin` installed, so no other
host was failing and none should start.
### 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.
No file-browser check this time — this release touches one function in the plugin installer and
nothing a browser reaches.
## Per host
Every host went to `816f8695` an hour ago and all seven came back online, so they start from there.
| Host | Note |
| --- | --- |
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. It already holds this commit and its `officer` was restarted by hand at 11:23. |
| `alpha` | Ordinary target. No `fofos-admin`, so nothing changes in behaviour. |
| `fofos` | **The reason for this release.** Expect `plugins:update` to go 4/5 → 5/5. |
| `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**. |
| `edge-pertento-bridge` | Ordinary target. |
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
Note for whoever reads the report: `scripts/chk.ts` is untracked on `fofos` and was noted last time. It
is still a note, not a skip.
## What "done" looks like
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
`restart_time`. And for `fofos` specifically, the `fofos-admin` line reading
`packages: installed and resolvable`.
+55
View File
@@ -0,0 +1,55 @@
# 2026.08.30.03 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
Every target went `816f8695` → `c97da029` by `git merge --ff-only deploy/2026.08.30.03`. No host was
dirty in tracked files, none had to be skipped, and no merge was anything other than a fast-forward.
**The release did what it was cut for.** `fofos` went 4/5 → **5/5, exit 0**, with `fofos-admin` reading
`packages: installed and resolvable`. That was the one check that mattered and it passed.
**No schema step and no `bun install` step** — the plan has neither, and neither was improvised. So there
is no per-host schema query in this report, because this release introduced nothing to query for.
## Per host
| Host | HEAD before → after | `plugins:update` | `officer` restart_time | health |
| --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | **SKIPPED** — the host this ran on |
| `alpha` | `816f8695` → `c97da029` | 15/15 | 7 → 8, online | `401` (9000) |
| `fofos` | `816f8695` → `c97da029` | **5/5, exit 0** | 4 → 5, online | `401` (9000) |
| `macbookpro` | `816f8695` → `c97da029` | no plugins installed | 7 → 8, online | `401` (9010) |
| `macbruno` | `816f8695` → `c97da029` | 1/1 | 10 → 11, online | `401` (9000) |
| `production-pertento-bridge` | `816f8695` → `c97da029` | 1/1 | 8 → 9, online | `401` (9010) |
| `edge-pertento-bridge` | `816f8695` → `c97da029` | 2/2 | 9 → 10, online | `401` (9000) |
| `deadpool-rezio-bridge` | `816f8695` → `c97da029` | 2/2 | 26 → 27, online | `401` (9000) |
`401` on `/api/health` is healthy — the endpoint is behind auth.
## What is worth reading
- **`fofos` is fixed.** `plugins:update` there had failed 4/5 on every rollout since `@tiptap/pm` was
installed, and it now exits 0. The plugin step of the next deploy should be unremarkable on that host.
- **`macbookpro` needed a fetch retry.** The first `git fetch` failed with
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. That host
resolves `gitea.officer.dev` to the public address where `alpha` reaches it over the tailnet. The first
retry succeeded and the rollout continued normally. A later probe was refused again, so the window is
intermittent rather than closed. **Nothing to fix in the deploy; the remote on that host is the thing
that is wrong**, and pointing it at the tailnet address would remove a class of failure that costs a
retry on every release.
- **`scripts/chk.ts` is still untracked on `fofos`.** Noted, not a skip, exactly as the plan said. It has
survived two rollouts now; somebody may want to commit it or delete it.
- **`plugins:update` restarted sidecars on several hosts** before the `pm2 restart officer` the plan
asked for — `officer-music` on `alpha`, `officer-gitea` on `production-pertento-bridge`,
`officer-offscale` on `edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. That is the
documented behaviour of the plugins step, not something this rollout chose.
- **On `production-pertento-bridge` only `officer` was restarted.** Pertento's eight production processes
were not touched.
## What was not done
- `officer-dev` was not pulled, not updated and not restarted. It already holds `c97da029` and its
`officer` was restarted by hand at 11:23. **The owner restarts this host deliberately, as the last
step**, once happy with the rest.
- No `bun db:push`, no `bun install`, no direct SQL. The plan called for none.
- No file-browser check, per the plan.
+129
View File
@@ -0,0 +1,129 @@
# 2026.09.09.01 — ten days of platform work, and one new table
**Twenty-seven commits**, `deploy/2026.08.30.03..fafc1adb`. The fleet is ten days behind; this is a
catch-up release rather than a fix for anything found in the last one.
The larger threads in it, so a host that misbehaves has somewhere to point:
- **Plugin ports** — the platform can allocate a plugin a stable port and remember it (`518d329c`,
`d9110c37`). This is the schema change; see below.
- **Publishing is a tag** — the marketplace reads releases, not working trees (`78e89ebc`, `19bbbab9`).
- **Plugin routing** — a plugin owns the routing under its mount and can declare its own routes
(`89820972`, `1fe767ea`), with `:section` given back to the fifteen plugins the splat took it from
(`eaa92738`).
- **The code editor's tree and file browser** are exported for plugins to reuse (`fb530456`, `5fa3f3ec`,
`da07048f`).
- **`anthropicViaProxy`** (`fafc1adb`) — the boundary through which platform-side code, including a
plugin's server routes, reaches Claude on the owner's own subscription.
## The schema change — one new table, one nullable column
Verified rather than assumed:
```bash
git diff --name-only deploy/2026.08.30.03..master | grep -iE 'schema|databases/'
```
Two files, and they are **not** equally in scope:
1. **`officer_db` — `plugin_ports`, a brand-new table.** No foreign key onto it and no existing column
pointing at it, so this is not the create-then-constrain shape that `db:push` cannot do. A plain
`bun db:push` is enough, and it is a create on every host.
2. **`marketplace_db` — `plugins.release_tag`, a nullable text column.** **Out of scope for this
rollout.** `bun db:push` covers `officer_db` only, and the marketplace runs on exactly one machine:
```
officer-marketplace → officer-dev only (verified 2026-09-09, pm2 jlist on all six)
```
`officer-dev` is the host the rollout skips. So no target needs it, and nothing should improvise a
second push to reach it. It is named here so that when `officer-dev` is restarted deliberately, the
marketplace database is known to be the one outstanding piece.
## Dependency change — `bun install` is required
```bash
git diff --name-only deploy/2026.08.30.03..master -- package.json bun.lock # both changed
```
`@anthropic-ai/sdk@^0.71.0` was added (plus its `json-schema-to-ts` dependency). The lockfile is
committed and matches, so the frozen-lockfile default resolves from it — **plain `bun install`, never
`--no-frozen-lockfile`.** A host that cannot resolve from the lockfile is a finding, not something to
force past.
## Every host — the commands
The standard steps in `prompts/deploy-commander.md` are the rollout (dirty check — TRACKED only,
`git fetch origin --tags`, `git merge --ff-only deploy/2026.09.09.01`, `bun run plugins:update`), plus,
in this order:
```bash
bun install
bun db:push < /dev/null
pm2 restart officer
```
`bun install` **before** `plugins:update`, because the plugin installer runs against the platform's own
resolved modules. `db:push` after both, because `gen-plugin-schemas` runs inside it and reads the
plugins as they now are.
`< /dev/null` on the push is not decoration: stdin closed means a push that wants to ask a question
fails instead of hanging forever in an unattended run. This change should ask nothing — a create of a
table nothing else references — so **a prompt here is a finding.**
`pm2 restart officer` only. `plugins:update` has already restarted the plugin sidecars itself.
### Verify it came back — three checks, per host, by query
Exit 0 from `db:push` is not evidence; `drizzle-kit push` can fail and still exit 0.
1. **The table exists**, asked of that host's own database:
```bash
psql "$DB_URL" -tAc "select to_regclass('public.plugin_ports')" # → plugin_ports
```
Read the URL from that checkout's `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`, not
`POSTGRES_URL`.
2. **`pm2 jlist`** — `officer` is `online` and its `restart_time` moved.
3. **HTTP** — `/api/health` on the host's own port. `200` or `401` are both healthy. The port is in
`.env` and is **9010 on `macbookpro`**, 9000 elsewhere.
## Per host
| Host | Login | State before | Note |
| ----------------------- | -------------- | ------------------------------ | --------------------------------------------------------- |
| `alpha` | `pastilhas` | `19bbbab9`, 8 commits past the tag | Partially pulled at some point; the fast-forward is shorter than the others'. |
| `fofos` | `pastilhas` | `fafc1adb` — already at master | Already carries every commit; the merge is a no-op that only moves the tag onto it. Still runs install/push/restart. One untracked file — a note, not a skip. |
| `macbookpro` | `pastilhas` | `c97da029` (`2026.08.30.03`) | Laptop. Asleep is a SKIP with a note. `export PATH=$PATH:/opt/homebrew/bin` for `bun`, and run `pm2` as a separate command. Port **9010**. |
| `deadpool-rezio-bridge` | **`br`** | `c97da029` (`2026.08.30.03`) | Not `pastilhas`. |
### Skipped, deliberately — not failures
- **`officer-dev`** — the host the rollout runs on. Skipping it entirely is the rule; restarting
`officer` there ends the rollout mid-report. It is at `fafc1adb` and therefore **not behind** in code;
what it does still want is the marketplace column above, whenever the owner restarts it.
- **`macbruno`** — unreachable at cut time (`connect to host macbruno port 22: Connection timed out`,
verified 2026-09-09). A Mac, so most likely asleep.
- **Both Pertento hosts** — on the deliberately-skipped list in `docs/servers.md` since 2026-09-08.
Owner's call, no fault on either box.
## What "done" looks like
Four hosts attempted: `alpha`, `fofos`, `macbookpro`, `deadpool-rezio-bridge`. Each at
`deploy/2026.09.09.01`, `plugin_ports` present by query, `officer` back online, health answering on the
right port.
A host that fails does not stop the rollout. It gets its own line saying which step failed and what
never ran.
## Known and carried, not introduced here
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types). It is present on `master`
before this release's commits, it is type-only, and Bun strips types at run time — so it does not
affect the rollout. Named here so it is not discovered mid-deploy and mistaken for something this
release did.
+71
View File
@@ -0,0 +1,71 @@
# 2026.09.09.01 — report
**4 of 4 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
`macbruno` and both Pertento hosts skipped per the plan and `docs/servers.md`.
Every target ended at `be3139f9` — the commit `deploy/2026.09.09.01` points at. `plugin_ports` was
verified **by query on each host's own database**, not inferred from `db:push` exit codes.
**Two hosts were already at the tag before the rollout touched them, and the cause was a person, not a
process.** `alpha` was at `be3139f9` rather than the `19bbbab9` the plan recorded, and `fofos` was at
`be3139f9`. The deploy agent inferred `auto-pull.sh` on its minute crontab; **that inference is wrong
and is corrected here** so nobody spends time hunting a mechanism that was not involved.
What actually happened: the session that cut this release also rolled `alpha` by hand before being
stopped — the release is the releaser's job and the rollout is this commander's, and that line was
crossed. `fofos` was at `master` for an unrelated reason: it had been deployed to repeatedly through
the day during work on the `fofos-website` plugin.
So their merges were no-ops. Only `macbookpro` and `deadpool-rezio-bridge` were genuine fast-forwards,
and they are also the only two where `bun install` had work to do.
The `auto-pull` reading is worth keeping visible rather than deleting, because it is exactly the shape
of a plausible wrong answer: a real cron, on the right machine, with the right effect, that had nothing
to do with this. A host being unexpectedly current is evidence of *something*, and "the obvious
candidate" is not the same as the cause.
## Per host
| Host | HEAD before → after | `bun install` | `plugins:update` | `plugin_ports` by query | `officer` restart_time | health |
| --- | --- | --- | --- | --- | --- | --- |
| `officer-dev` | — | — | — | — | — | **SKIPPED** — the host this ran on |
| `alpha` | `be3139f9` → `be3139f9` | no changes | **16/17, exit 1** | ✓ present, 5 cols | 10 → 11, online | `401` (9000) |
| `fofos` | `be3139f9` → `be3139f9` | no changes | **4/5, exit 1** | ✓ present, 5 cols | 380 → 381, online | `401` (9000) |
| `macbookpro` | `c97da029` → `be3139f9` | **+ @anthropic-ai/sdk@0.71.2** | no plugins installed | ✓ present, 5 cols | 0 → 1, online | `401` (**9010**) |
| `deadpool-rezio-bridge` | `c97da029` → `be3139f9` | **+ @anthropic-ai/sdk@0.71.2** | 2/2, exit 0 | ✓ present, 5 cols | 0 → 1, online | `401` (9000) |
`401` on `/api/health` is healthy — the endpoint is behind auth. Ports were read from each `.env`.
The schema check run per host was `select to_regclass('public.plugin_ports')`, answered `plugin_ports`
on all four, with columns `id`, `app_name`, `name`, `port`, `allocated_at timestamptz`. Databases:
`officer_dev` on `alpha` and `macbookpro`, `officer` on `fofos` and `deadpool-rezio-bridge`.
No `db:push` prompted for anything — `< /dev/null` was used on every host, as the plan required.
## What the owner should act on
- **`plugins:update` failed on two hosts, both for uncommitted work in a plugin checkout.** Nothing was
discarded. Each is one `git commit` or `git checkout --` away from clean:
- `alpha` — `plugins/av-club` at `62972c1`, 3 modified: `docs/SECURITY.md`, `sidecar/config.ts`,
`sidecar/paths.ts`. That plugin did **not** update on `alpha` and is still on its old checkout.
- `fofos` — `plugins/fofos-website` at `7001eb0`, 1 modified: `frontend/Layout/Footer/Footer.tsx`.
Same consequence: that plugin did not update.
- **`alpha` could not fetch.** `git fetch origin --tags` failed with
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. Its
`origin` is `git@gitea.officer.dev`. It made no difference this time because the tag was already
local, but a host that genuinely needed the tag would have failed there. **The remote on that host is
the thing that is wrong**; pointing it at the tailnet address removes the whole class. The same
recommendation was made in the `2026.08.30.03` report about `macbookpro`, which fetched cleanly today.
- **`macbruno` was not attempted.** The plan recorded it unreachable at cut time (`Connection timed
out`, 2026-09-09) and named it skipped, so no retry budget was spent on it. It is at `c97da029` as far
as anyone knows and will be a real fast-forward whenever it is next reached.
- **`officer-dev` still wants the marketplace column.** `plugins.release_tag` in `marketplace_db` is
out of scope for this rollout by the plan's own words — `bun db:push` covers `officer_db` only, and
`officer-marketplace` runs on this host alone, which is the host the rollout skips. Whoever restarts
`officer-dev` deliberately should apply it then. Nothing improvised a second push to reach it.
## Not done, deliberately
- `officer-dev` — not pulled, not restarted, nothing touched. It is already at `be3139f9` in code.
- `macbruno`, `production-pertento-bridge`, `edge-pertento-bridge` — skipped, no fault on any of them.
- The `bunx tsgo` error at `src/servers/sidecar/claude/claude-manager.ts:667` was neither checked nor
fixed. The plan names it as pre-existing and type-only; nothing in this rollout would have surfaced it.
+70
View File
@@ -0,0 +1,70 @@
# 2026.09.14.01 — command-hq self-reported arm/fire/disarm
**One commit**, `deploy/2026.09.09.01..36db9a2a`. Backend-only, no schema change, no new dependency.
## What it is
Three new routes, `POST /command-hq/commanders/dir/:dirName/{arm,fire,disarm}` — a second, self-reported
door onto the existing `armCommandHqWatcher`/`recordCommandHqWatcherFired`/`disarmCommandHqWatcher`
functions, for a commander loop that outlives its own turn (detached, or a tracked background job that
dies with the turn anyway) and so never produces the harness's own `task:started`/`task:notification`
events. Authenticated with the commander's own long-lived `OFFICER_API_KEY`. Full account in
`docs/command-hq.md` → "Self-reported arm".
No route removed, no existing route's contract changed, no frontend change. The only way this release
does anything is a host restarting `officer` to pick up the new routes — a commander whose loop already
calls them (this machine's four) gets nothing from the rollout on any OTHER host, because those hosts'
own commanders, if any, don't call them yet.
## Verified, not assumed
```bash
git diff --name-only deploy/2026.09.09.01..HEAD | grep -iE 'schema|databases/' # → nothing
git diff --name-only deploy/2026.09.09.01..HEAD -- package.json bun.lock # → nothing
git diff --name-only deploy/2026.09.09.01..HEAD -- docs/servers.md # → nothing, host list unchanged
bun run docs:check # → green
```
No schema change, no dependency change, no host-list change since the last release. `package.json`
version bumped to match this tag and nothing else in it changed.
## Every host — the commands
Standard steps only, nothing extra: dirty check (TRACKED only), `git fetch origin --tags`,
`git merge --ff-only deploy/2026.09.14.01`, `bun run plugins:update`, `pm2 restart officer`. No
`bun install`, no `db:push` — neither is needed and running either is not a finding, just unnecessary.
### Verify it came back — two checks, per host
No schema table to query this time, so the usual three checks drop to two:
1. **`pm2 jlist`** — `officer` is `online` and its `restart_time` moved.
2. **HTTP** — `/api/health` on the host's own port. `200` or `401` are both healthy. Port is **9010 on
`macbookpro`**, 9000 elsewhere.
## Per host
| Host | Login | Note |
| ----------------------- | -------------- | ----------------------------------------------------------|
| `alpha` | `pastilhas` | |
| `fofos` | `pastilhas` | |
| `macbookpro` | `pastilhas` | Laptop. Asleep is a SKIP with a note. Port **9010**. |
| `deadpool-rezio-bridge` | **`br`** | Not `pastilhas`. |
### Skipped, deliberately — not failures
- **`officer-dev`** — the host the rollout runs on. Skip entirely, per the standard rule.
- **`macbruno`** — reachability unknown at cut time; check live, unreachable is a SKIP not a failure.
- **Both Pertento hosts** — deliberately-skipped list in `docs/servers.md` since 2026-09-08.
## What "done" looks like
Four hosts attempted: `alpha`, `fofos`, `macbookpro`, `deadpool-rezio-bridge`. Each at
`deploy/2026.09.14.01`, `officer` back online, health answering on the right port. A host that fails
does not stop the rollout — its own line, what failed, what never ran.
## Known and carried, not introduced here
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types) — present before this release,
type-only, stripped at run time. Named here so it isn't mistaken for something this release did.
+71
View File
@@ -0,0 +1,71 @@
# 2026.09.14.01 — report
**4 of 4 hosts succeeded.** `officer-dev`, `macbruno` and both Pertento hosts skipped, all deliberately.
Backend-only release (`be3139f9..4a37cfd2`): three new self-reported `command-hq` routes. No schema
change, so no `db:push` and no schema query — the plan's two checks (`pm2 jlist`, `/api/health`) are the
whole verification, and both ran on every host.
Every host went `be3139f9` (`deploy/2026.09.09.01`) → `4a37cfd2` (`deploy/2026.09.14.01`), by
`git merge --ff-only` onto the TAG, not `master`. Every checkout path was read from that host's own
`officer` `pm_cwd`, never assumed.
## Per host
| Host | Before | After | Dirty | plugins:update | Restarted | Back? |
| ----------------------- | ---------- | ---------- | ----- | -------------- | --------- | --------------------------- |
| `alpha` | `be3139f9` | `4a37cfd2` | clean | 17/17 | `officer` | online, 0→1, health `401` |
| `fofos` | `be3139f9` | `4a37cfd2` | clean¹| 5/5 | `officer` | online, 1→2, health `401` |
| `macbookpro` | `be3139f9` | `4a37cfd2` | clean | none installed | `officer` | online, 1→2, health `401` (:9010) |
| `deadpool-rezio-bridge` | `be3139f9` | `4a37cfd2` | clean | 3/3 | `officer` | online, 0→1, health `401` |
¹ `fofos` carries two UNTRACKED files — `flip-records.ts` and `scripts/chk.ts`. Untracked is a note, not
a skip: a fast-forward cannot touch them and nobody's work was discarded.
All four checkouts were clean of TRACKED changes, so no host was skipped for a dirty tree and every merge
was a genuine fast-forward.
`401` is healthy here — `/api/health` is authenticated, and the plan names `200` or `401` as pass. The
port was read from each host's own `.env` rather than assumed: `9010` on `macbookpro`, `9000` elsewhere.
## Steps that ran
Per host, in order: tracked-only dirty check → `git fetch origin --tags` → `git merge --ff-only
deploy/2026.09.14.01` → `bun run plugins:update` → `pm2 restart officer` → `pm2 jlist` → health.
No `bun install` and no `bun db:push` on any host. The plan says neither is needed; running either would
not have been a finding, but it was not needed and was not done.
`plugins:update` restarts sidecars itself. On `alpha` that is more processes moving than the one line
above suggests — the table's "restarted" column is what the PLAN asked for, not the full count of what
bounced.
## Skipped — none of these is a failure
- **`officer-dev`** — the host this rollout ran on. Skipped entirely per the standard rule: not pulled,
nothing restarted. **It is still on `be3139f9` and the owner restarts it deliberately.**
- **`macbruno`** — `ssh` timed out on port 22 (`Connection timed out`, 8s). The plan names it as
check-live, unreachable-is-a-skip. Not attempted further; it cost one probe, not a retry budget.
- **`production-pertento-bridge`, `edge-pertento-bridge`** — on the deliberately-skipped list in
`docs/servers.md` since 2026-09-08. Owner's call. Nothing spent on either.
## What the owner must act on
- **`officer-dev` has not been deployed.** It is the one host still on the previous release. This is the
designed outcome, not an oversight — restarting `officer` here would have killed the rollout mid-flight.
- **`macbruno` is behind by one release** and unreachable as of this rollout. It will need the same
four-step sequence whenever it is back.
- Nothing else. No host failed, no process came back `errored`, no step was refused.
## Carried, not introduced
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types). Named in the plan, present before
this release, type-only. Not checked again here and not caused by this rollout.
## About this run
First release under a new watcher. The rollout procedure is proven — this is the tenth report — but this
loop had never executed it before, so every host was narrated before it was touched. The claim was taken
by the watcher that spawned this agent; this agent did not re-claim, and recorded `done` only after the
fourth host came back healthy.
+39 -3
View File
@@ -62,11 +62,14 @@ for exact request/response shapes; the table below is the map, not the full cont
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms. This route still can't arm — see the `dir/:dirName` routes below for the one path that can from outside a running turn |
| DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
| POST | `/commanders/dir/:dirName/arm` | `{taskId}` | self-reported arm, for a loop the harness never sees fire — see "Self-reported arm" below. `:dirName` is the commander's own numeric id as a string, not the transcript uuid |
| POST | `/commanders/dir/:dirName/fire` | `{taskId}` | self-reported trigger; no-ops (`{ok:false}`) unless `taskId` matches the row's current `watcherTaskId` |
| POST | `/commanders/dir/:dirName/disarm` | — | self-reported clean exit; same effect as `PATCH {watcherArmed:false}`, resolved by dirName instead |
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
@@ -463,8 +466,9 @@ occur.
`recordCommandHqTurn` in `queries.ts`; exposed read-only on `GET /commanders` and
`GET /commanders/:sessionId` (`watcherLastFiredAt` survives as a name in the JSON response — it is just
computed there, `MAX(fired_at)`, not read off a column), and `PATCH /commanders/:sessionId` accepts
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
running turn has).
`{watcherArmed: false}` to disarm. Arming still has no route THERE — it needs a live task id, which only
a running turn has — but see "Self-reported arm" below for the route that exists precisely because a
loop that outlives its turn doesn't have one.
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
@@ -537,6 +541,38 @@ OWN user id, not the row it belonged to). Found by re-reading every call site th
after the fact, not by any tool — worth remembering the next time a signature changes shape rather than
just its types: a compiler that stays quiet is not the same as a diff that is correct.
### Self-reported arm — 2026-09-14
The mechanism above has a gap it never surfaced until four real commanders on the owner's own machine were
checked against it: `pendingArmWatcher` is consumed on the harness's `task:started` for the live session
that submitted the arm dialog, and stays valid only as long as that session's own tracked-background-task
plumbing does. A loop that needs to outlive its turn — `setsid`/`nohup`/`disown`, or any tracked background
job that the harness itself drops when the turn ends, which multiple real commanders here do on purpose,
one of them (`docs`-adjacent `WATCHERS.md` for the OCR watcher) as an explicit standing decision favouring
job-panel visibility over survival — never produces that event. `watcherArmed` stayed `false` and
`command_hq_watcher_fires` stayed empty forever for every one of them, even while the loop genuinely polled
and genuinely fired, repeatedly, for weeks. Not cosmetic: `GET /commanders`'s card already papers over the
`watcherArmed` boolean with a live pid check (`row.watcherArmed || liveLoopPid(...) !== null`, `router.ts`),
so the badge was always right — but nothing papers over an empty `command_hq_watcher_fires`, so
`watcherLastFiredAt` read `null` forever on every card for a commander that had fired dozens of times.
Fixed by giving the loop a second, independent way to say the same thing the harness's own tracking says
for a session-scoped commander: three routes under `/commanders/dir/:dirName/{arm,fire,disarm}`,
authenticated with the commander's own long-lived `OFFICER_API_KEY` (the same credential its loop already
holds and already uses for `/api/notify` and `/api/tasks/*/run` — nothing new minted), keyed on `dirName`
because that is what a loop already knows about itself without needing its own transcript uuid. `arm` and
`disarm` call `armCommandHqWatcher`/`disarmCommandHqWatcher` directly — the same functions `websocket.ts`
calls, just reached from a second door. `fire` keeps the identical staleness guard `task:notification`
already has (`watcherTaskId` must match), so a superseded instance of a loop — an old pid a crash left
running, now replaced by a freshly-armed one — can't keep writing fires for a watch it no longer is. The
task id itself is no longer harness-issued for this path: the loop mints its own (pid + start time is
enough) at the top of the script and reuses it for every `fire` call until it restarts.
This does not change anything about the original harness-driven path — a commander whose loop genuinely is
a tracked background job on a live session still arms exactly as before, and most of what's written above
about `pendingArmWatcher` and the noticing in `createMessageHandler` is unchanged. It adds a second, parallel
way to reach the same two DB functions, for the class of commander the first path structurally can't reach.
## Later — explicitly deferred, not forgotten
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
+13 -9
View File
@@ -29,17 +29,21 @@ Ranked by what actually blocks something.
## Staleness
- [ ] **Nothing refreshes the catalogue automatically.** Publishing a plugin update changes nothing until
somebody runs `bun --env-file=.env scripts/marketplace-sync.ts`. A Gitea webhook or a cron is the
obvious next step.
- [x] **Nothing refreshes the catalogue automatically.** Done 2026-09-03: `scripts/marketplace-cron.sh`
every minute. A Gitea webhook would make it a second instead, at the price of a public write route
on the one process here with no login — not worth it for a minute.
- [ ] **The sync reads local clones, not the remotes.** The machine running it must have every plugin
checked out, so the catalogue describes that machine's copies. The version that clones each repo
into a temp directory is a small change.
- [x] **The sync reads local clones, not the remotes.** Done 2026-09-03. Bare mirrors, read at the newest
tag, driven by the registry rather than by a directory listing. The catalogue no longer describes
any machine's working copies.
- [ ] **`rawUrl()` assumes Gitea and a `main` branch.** A GitHub-hosted plugin, or one whose default branch
is `master`, gets a 404 for its icon. The forge belongs in the registry rather than inferred from a
URL shape.
- [ ] **`rawUrl()` assumes Gitea.** A GitHub-hosted plugin gets a 404 for its icon. The forge belongs in
the registry rather than inferred from a URL shape. (The `main`-vs-`master` half of this is gone —
URLs pin to the release tag now.)
- [ ] **Four plugins have never been tagged**, so the store still shows rows the old sync took from a
working tree: av-club, kanban, openseo, fofos-admin. They are frozen until somebody tags them, and
the sync names them on every run somebody asks for.
## Scale
+82 -17
View File
@@ -10,8 +10,9 @@ from. **The website installs nothing** — installing means cloning a repository
starting a process on someone else's machine, so it happens entirely inside the owner's own Officer.
```
gitea.officer.dev/plugins/* the repositories (source of truth)
│ scripts/marketplace-sync.ts (manual, reads manifest.ts + README.md)
gitea.officer.dev/** the repositories (source of truth)
│ bare mirrors, read AT THE NEWEST TAG — src/servers/marketplace/sync.ts
│ every minute, scripts/marketplace-cron.sh
▼
officer_marketplace (postgres) plugins + plugin_metadata
│ src/servers/marketplace/server.tsx :9100
@@ -20,6 +21,7 @@ marketplace.officer.dev GET /api/plugins, /api/plugins/:appName
│ fetchCatalogue() live → cached → bundled
▼
an Officer install /marketplace → clone → push schema → mount
and the update badge — see "Publishing" below
```
## The parts
@@ -78,19 +80,87 @@ disappearing from the store.
the clone, the plugin's own manifest is what the platform reads. A metadata row that disagrees with the
repository is a marketplace bug, never a second source of truth.
## Sync
## Publishing
**Push a tag. That is the whole procedure.** A timer on officer-dev picks it up within a minute and every
Officer install sees it on the next page load.
```bash
bun --env-file=.env scripts/marketplace-sync.ts
bun --env-file=.env scripts/marketplace-sync.ts # all of them, now
bun --env-file=.env scripts/marketplace-sync.ts photos # one
bun --env-file=.env scripts/marketplace-sync.ts --add https://gitea.officer.dev/plugins/finances.git
bun --env-file=.env scripts/marketplace-sync.ts --force # re-publish rows this file's own logic changed
```
Reads every directory under `plugins/`, imports its `manifest.ts`, derives `has` from the directory
rather than from a declaration, reads `README.md`, and upserts. Re-running is always safe: it overwrites,
never merges.
### It reads releases, never a working tree
It rewrites SSH remotes to HTTPS. `example` and `offscale` had `git@gitea…` origins, which would have
handed every installer a URL that only works on the machine that published it — the install fails at
authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong".
Every registered plugin is mirrored bare into `~/.cache/officer-marketplace/mirrors` and read at its
newest tag — manifest, README and file list all out of `git cat-file`. There is no working tree in the
path, so publishing something uncommitted is not a mistake that can be made.
That is a change, and it was load-bearing. Until 2026-09-03 the sync imported `manifest.ts` off
`platform/plugins/` — the directories THIS machine has installed — so publishing meant "get officer-dev's
checkout to the release, then run the script", and nothing showed when that had not happened. On the day
it was rewritten the live store was advertising four plugins from untagged working copies: av-club
2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26, none of which had ever
been tagged. It is also why the job could not go on a timer before: a cron over it published whatever was
in a working tree, on a schedule.
### The registry decides which plugins, and where from
The list comes from the `plugins` table — the half this document has always called authoritative, and
which was in fact a by-product of what happened to be checked out. Now it decides. A plugin nobody has
installed here can still be published, and a plugin lives wherever its row says: `fofos-admin` is under
`fofos/`, not `plugins/`, which an org listing would have missed. Registering one is `--add`.
### A plugin with no tag is left alone
It has never made a release, so the sync has nothing true to say about it: the existing row stays and the
run names it. Deleting it would drop somebody's plugin out of the store on the day the rule changed.
Tagging it is the fix, and the report is the reminder.
### `release_tag`, and why it is not `version`
`version` is the manifest's own claim; `release_tag` is what was pushed. They match every time somebody
tags correctly, and when they do not, the tag is the fact — a manifest whose version nobody bumped would
otherwise make a real release invisible to anything comparing versions. The sync warns on a mismatch.
Icon URLs pin to the tag too. `/raw/HEAD/…` served whatever was on the default branch, via a 303, so a
plugin could show one icon in the store and ship another.
### The timer
`scripts/marketplace-cron.sh`, `* * * * *`, logging to `officer-dev/marketplace-sync.log`. One
timestamped summary line per tick — always, including the ticks that do nothing, because a fresh
timestamp is the only thing that distinguishes "nothing to publish" from "the timer died". A plugin-level
line appears only when one actually moved.
A tick costs about 2.7s with nothing to do: one query, one fetch per repository, no writes. Nothing is
written unless the release commit differs from the one already published.
A Gitea webhook would cut the delay from a minute to a second, at the price of a public write route and a
shared secret. A minute was never the problem anybody had — the complaint that started this was a release
that never appeared at all.
### Remotes
`--add` rewrites SSH to HTTPS. A `git@gitea…` origin in the catalogue hands every installer a URL that
only works on the machine that published it, and the install fails at authentication — which reads as
"the plugin is broken" rather than "the catalogue is wrong".
## The update badge reads the catalogue
`updateable` on `/api/plugins` compares the tag at the checkout's HEAD with the tag the catalogue says is
newest. Difference, not ordering: parsing `2026.08.27.01` works right up until somebody tags `v2`, and
then it fails silently.
Before this, each host ran a rate-limited background `git fetch` against all twenty-one repositories to
re-derive a fact one place already knew. That is gone. `releaseState()` still holds the git rule as a
fallback for a plugin the store cannot speak for — an unregistered checkout, or a store that could not be
reached — and in that case its refs are whatever the last `pullPlugin` left, which is the accepted cost.
`fetchCatalogue()`'s cache dropped from five minutes to one when the badge started depending on it:
otherwise the wait for a release to appear was the sync's minute plus the cache's five.
## The platform degrades, it does not break
@@ -111,13 +181,8 @@ The full list, ranked, is `docs/marketplace-todo.md`. The ones that shape the de
- **`[blocker]` Third-party submissions cannot use this sync.** It reads a manifest by importing it, which
is code execution. Fine for our own repositories; not a mechanism a stranger's plugin can go through.
Submissions need either a sandbox or a published `manifest.json` alongside the TypeScript one.
- **`[open]` Sync reads local clones, not the remotes.** The machine running it must have the plugins
checked out. The version that clones each repo into a temp directory is a small change and was left out
of the first pass.
- **`[open]` `rawUrl()` assumes Gitea and a `main` branch.** A GitHub-hosted plugin or one defaulting to
`master` gets a 404 for its icon. The forge should become part of the registry rather than inferred.
- **`[open]` `rawUrl()` assumes Gitea.** A GitHub-hosted plugin needs the other URL form. The forge should
become part of the registry rather than inferred from the URL.
- **`[open]` No versions table.** `plugin_metadata` is one row per plugin — a snapshot, not a history. A
store that can offer "install 1.2 instead of 1.3" needs a different shape.
- **`[open]` Nothing refreshes automatically.** Publishing a plugin update does not change the catalogue
until somebody runs the sync. A cron or a Gitea webhook is the obvious next step.
- **`[open]` No search, no categories, no counts.** Three plugins do not need them.
+16 -8
View File
@@ -21,7 +21,8 @@ ssh br@deadpool-rezio-bridge # not implied
## Officer platforms
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
Six, all running. **Verified 2026-08-27**, minus the two Pertento hosts moved to "Deliberately skipped" on
2026-09-08 — every remaining row below was read off the machine, not recalled.
| Host | Tailnet IP | Login | Port | Checkout | Domain |
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
@@ -30,8 +31,6 @@ Eight, all running. **Verified 2026-08-27** — every row below was read off the
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
@@ -70,12 +69,21 @@ ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
None today. `edge-pertento-bridge` was here from 2026-08-23 and came back up on 2026-08-24; it is a
deploy target again and its row is in the table above.
**Both Pertento hosts, since 2026-09-08** — `production-pertento-bridge` and `edge-pertento-bridge`. Owner's
call, not a fault found on either box. Details kept here rather than dropped, so putting them back is a
one-line move, not a re-derivation:
It was down long enough to matter, so expect it to be behind: when it returned it was 215 commits back at
`2026.08.22.02`, with no `roles` table and no commanders. A host coming off this list needs its schema
brought forward before it is treated as ordinary.
| Host | Tailnet IP | Login | Port | Checkout | Domain |
| ----------------------------- | ----------- | ------------ | -------- | ------------------------- | ----------------------- |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
`edge-pertento-bridge` was skipped once before, from 2026-08-23 to 2026-08-24 — but that was downtime, not
a choice, and it came back as a deploy target. This is a different reason and there is no return date.
It was down long enough to matter last time, so expect it to be behind whenever it returns: when it came
back on 2026-08-24 it was 215 commits back at `2026.08.22.02`, with no `roles` table and no commanders. A
host coming off this list needs its schema brought forward before it is treated as ordinary.
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.29.04",
"version": "2026.09.14.01",
"private": true,
"type": "module",
"workspaces": [
@@ -44,6 +44,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
"@anthropic-ai/sdk": "^0.71.0",
"@bitcoinerlab/secp256k1": "1.2.0",
"@getalby/sdk": "^8.0.3",
"@hookform/resolvers": "^5.2.2",
+19 -2
View File
@@ -32,7 +32,7 @@ import { CATALOGUE } from '../src/servers/app-store/catalogue';
import { PLATFORM_DIR } from '../src/servers/data-path';
import { discoverPlugins } from '../src/servers/plugins/discover';
import { pluginPm2Entry, type Pm2App } from '../src/servers/plugins/ecosystem';
import { listPluginInstalls, listSidecarInstalls } from 'officerdb';
import { listPluginInstalls, listPluginPorts, listSidecarInstalls } from 'officerdb';
const done = (message: string): never => {
console.log(`[gen:ecosystem] ${message}`);
@@ -92,7 +92,24 @@ const DISABLED = new Set(
async function pluginEntries(): Promise<Pm2App[]> {
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.appName));
return plugins.filter((p) => enabled.has(p.appName)).flatMap((p) => pluginPm2Entry(p) ?? []);
/*
Ports come from the database, not from a fresh allocation.
This script rebuilds the whole file from what is installed, and it must produce the SAME entries the
installer produced — allocating here would hand a plugin a different number than the one already
written into somebody's nginx configuration.
*/
const allocations = await listPluginPorts();
const byPlugin = new Map<string, Record<string, number>>();
for (const row of allocations) {
const current = byPlugin.get(row.appName) ?? {};
current[row.name] = row.port;
byPlugin.set(row.appName, current);
}
return plugins
.filter((p) => enabled.has(p.appName))
.flatMap((p) => pluginPm2Entry(p, byPlugin.get(p.appName)) ?? []);
}
/** Installed AND enabled. Unlike a plugin, every catalogue entry has a `process` — none are filtered out. */
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Publish every plugin release to the marketplace catalogue. Meant for cron, on the ONE machine that
# publishes.
#
# ── Why a timer at all, when a webhook would be instant ──
#
# The sync reads bare mirrors at their newest tag, so running it on a schedule cannot publish anything
# that was not pushed — which is exactly what made a timer unsafe before, and is no longer true. A Gitea
# webhook would cut the delay from a minute to a second, and it would also mean a public write route, a
# shared secret, and a second path into the same code. A minute is not the problem anybody had: the
# complaint that started this was a release that never appeared at all.
#
# ── What it costs ──
#
# About 2.7s per tick with nothing to do — one query, one fetch per repository, no writes. Gitea is on
# this same machine, so none of that leaves the box.
#
# ── The log ──
#
# One timestamped summary line per tick, ALWAYS, including the ticks that do nothing. A fresh timestamp
# is the only thing that distinguishes "nothing to publish" from "the timer died". Plugin-level lines
# appear only for a plugin that actually moved, which is what `--quiet` buys.
set -uo pipefail
PLATFORM="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG="${MARKETPLACE_SYNC_LOG:-$(dirname "$PLATFORM")/marketplace-sync.log}"
LOCK="/tmp/officer-marketplace-sync.lock"
# Cron's PATH is three entries and bun is in none of them.
export PATH="$HOME/.bun/bin:/usr/local/bin:/usr/bin:/bin"
cd "$PLATFORM" || exit 1
# Skip rather than queue. A tick that finds the previous one still running has nothing to add — the run
# in progress will publish whatever this one would have.
exec 9>"$LOCK"
flock -n 9 || exit 0
bun --env-file=.env scripts/marketplace-sync.ts --quiet >>"$LOG" 2>&1
# Bounded without logrotate, because this is one file on one machine and a config file for it would be
# more moving parts than the thing it manages.
if [ "$(wc -l <"$LOG")" -gt 20000 ]; then
tail -n 5000 "$LOG" >"$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
+77 -164
View File
@@ -1,43 +1,40 @@
#!/usr/bin/env bun
import { existsSync, readFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { upsertMetadata, upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries';
import { discoverPlugins } from '../src/servers/plugins/discover';
import { upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries';
import { syncAll, syncPlugin } from '../src/servers/marketplace/sync';
// Fill the marketplace database from the plugin repositories.
//
// ── What this reads, and why it is not the marketplace's own opinion ──
//
// Every field except "where to clone from" comes out of the plugin's `manifest.ts` and its README. The
// marketplace stores a COPY so the website can render without cloning three repositories per page load;
// the manifest stays the source of truth, and the platform re-reads it after cloning. A row here that
// disagrees with the repository is a stale cache, not a second opinion — which is why re-running this is
// always safe and never merges: it overwrites.
//
// ── Reading the manifest by importing it ──
//
// A manifest is TypeScript, not JSON, so it is imported and evaluated rather than parsed. That is code
// execution, and it is fine for OUR repositories and not fine as a general mechanism.
//
// `[blocker for third-party submissions]` A stranger's plugin cannot be imported by the machine that
// serves the catalogue. Whoever builds submissions has to either evaluate manifests in a sandbox or make
// authors publish a `manifest.json` alongside. Recorded rather than solved: nobody can submit yet.
//
// ── Local clones, for now ──
//
// It syncs from `plugins/` on this machine rather than by cloning each repo into a temp directory. That
// keeps tonight's scope to "get real data into the database" — and it means the machine running the sync
// must have the plugins checked out. See `docs/marketplace.md` → "Sync" for the version that clones.
/**
* Fill the marketplace catalogue from the plugins' releases.
*
* bun --env-file=.env scripts/marketplace-sync.ts every registered plugin
* bun --env-file=.env scripts/marketplace-sync.ts photos just one
* bun --env-file=.env scripts/marketplace-sync.ts --add <url> register a new one, then sync it
* bun --env-file=.env scripts/marketplace-sync.ts --force re-publish releases already published
* bun --env-file=.env scripts/marketplace-sync.ts --quiet print only what changed
*
* All the reasoning is in `src/servers/marketplace/sync.ts` — this file is the command line around it.
* It is a thin wrapper on purpose: the same functions are what the Gitea webhook calls, so a plugin
* published by hand and one published by pushing a tag go through exactly the same code.
*
* Re-running is always safe. It overwrites and never merges.
*/
type Sync = { appName: string; developer: string; repoUrl: string };
const argv = process.argv.slice(2);
/*
`--quiet` is what the timer runs: one line per plugin that actually moved, and a summary. Not silence —
the summary is always printed, because a log whose last line is old is the only way to tell "nothing to
publish" from "the timer died", and that question does come up.
*/
const quiet = argv.includes('--quiet');
const force = argv.includes('--force');
const args = argv.filter((a) => a !== '--quiet' && a !== '--force');
/**
* A clone URL the whole internet can use.
*
* `plugins/example` and `plugins/offscale` have `git@gitea.officer.dev:plugins/<name>.git` remotes,
* because whoever set them up had a key on that host. Publishing that to a catalogue would hand every
* installer a URL that only works on the machine it was published from — the install fails at
* authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong".
* Several plugin repos have `git@gitea.officer.dev:plugins/<name>.git` remotes, because whoever set them
* up had a key on that host. Registering one of those would hand every installer a URL that only works
* on the machine it was published from — the install fails at authentication, which reads as "the plugin
* is broken" rather than "the catalogue is wrong".
*/
export function toHttpsRemote(remote: string): string | null {
const trimmed = remote.trim();
@@ -60,142 +57,58 @@ export function toHttpsRemote(remote: string): string | null {
return null;
}
const git = (dir: string, args: string[]): string | null => {
const result = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
if (result.exitCode !== 0) return null;
return new TextDecoder().decode(result.stdout).trim() || null;
};
/*
Which plugins directory to publish FROM.
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
Publishing from it means the catalogue advertises the version this machine happens to be running
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
were tagged and the store went on showing 1.0.0.
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
working copy and published the same version. It was removed on 2026-08-28.
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
real git repo with its own tags, so the released manifest can be read out of the tag itself —
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
written yet, and until it is, this script advertises what is installed.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
async function syncOne(dir: string): Promise<Sync | null> {
const appName = basename(dir);
const manifestPath = join(dir, 'manifest.ts');
if (!existsSync(manifestPath)) {
console.warn(`[marketplace-sync] ${appName}: no manifest.ts — skipped`);
return null;
if (args[0] === '--add') {
const raw = args[1];
if (!raw) {
console.error('usage: marketplace-sync.ts --add <clone url>');
process.exit(1);
}
const remote = git(dir, ['remote', 'get-url', 'origin']);
const repoUrl = remote ? toHttpsRemote(remote) : null;
const repoUrl = toHttpsRemote(raw);
if (!repoUrl) {
console.warn(`[marketplace-sync] ${appName}: no usable https remote (${remote ?? 'none'}) — skipped`);
return null;
console.error(`[marketplace-sync] ${raw} is not a clone URL this can turn into https`);
process.exit(1);
}
const appName = repoUrl.replace(/\.git$/, '').split('/').pop() ?? '';
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(appName)) {
console.error(`[marketplace-sync] "${appName}" is not a usable app name`);
process.exit(1);
}
const { manifest } = (await import(manifestPath)) as { manifest: Record<string, unknown> };
if (!manifest) {
console.warn(`[marketplace-sync] ${appName}: manifest.ts exports no \`manifest\` — skipped`);
return null;
// Registered with a placeholder developer, then immediately synced — which reads the real publisher
// out of the released manifest and overwrites it. A repository with no tag stays registered and
// unpublished, which is the correct state for one that has not made a release yet.
await upsertPlugin({ appName, developer: 'unknown', repoUrl });
console.log(`[marketplace-sync] registered ${appName} → ${repoUrl}`);
const one = await syncPlugin(appName, force);
report([one]);
process.exit(one.status === 'failed' ? 1 : 0);
}
const results = args[0] ? [await syncPlugin(args[0], force)] : await syncAll(force);
report(results);
process.exit(results.some((r) => r.status === 'failed') ? 1 : 0);
function report(results: Awaited<ReturnType<typeof syncAll>>): void {
const stamp = new Date().toISOString();
for (const r of results) {
if (r.status === 'synced') console.log(`[marketplace-sync] ${r.appName} ← ${r.tag}`);
else if (r.status === 'failed') console.error(`[marketplace-sync] ${r.appName} failed: ${r.error}`);
else if (quiet) continue;
else if (r.status === 'unchanged') console.log(`[marketplace-sync] ${r.appName} = ${r.tag}`);
else console.warn(`[marketplace-sync] ${r.appName}: no tag — nothing published`);
}
// What the plugin SHIPS, read from the directory rather than declared — the same rule discovery
// follows, so a catalogue entry cannot claim a sidecar the repository does not have.
const has = {
api: existsSync(join(dir, 'api', 'router.ts')),
schema: existsSync(join(dir, 'db', 'schema.ts')),
sidecar: existsSync(join(dir, 'sidecar', 'index.ts')) || existsSync(join(dir, 'sidecar', 'index.mjs')),
web: existsSync(join(dir, 'web')),
};
const synced = results.filter((r) => r.status === 'synced').length;
const unchanged = results.filter((r) => r.status === 'unchanged').length;
const unreleased = results.filter((r) => r.status === 'unreleased').map((r) => r.appName);
const failed = results.filter((r) => r.status === 'failed').map((r) => r.appName);
const readmePath = ['README.md', 'readme.md'].map((n) => join(dir, n)).find((p) => existsSync(p));
const readme = readmePath ? readFileSync(readmePath, 'utf-8') : null;
const developer = String(manifest.publisher ?? 'unknown');
const pluginId = await upsertPlugin({ appName, developer, repoUrl });
await upsertMetadata(pluginId, {
version: String(manifest.version ?? '0.0.0'),
platform: String(manifest.platform ?? '>=1.0.0'),
label: String(manifest.label ?? appName),
summary: String(manifest.summary ?? ''),
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
// quietly disagreeing with the repository it claims to mirror.
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
color: (manifest.color as string) ?? null,
tile: (manifest.tile as string) ?? null,
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
// claimed when the file is actually there — a broken image is worse than a glyph.
iconUrl: existsSync(join(dir, 'assets', 'icon.png')) ? rawUrl(repoUrl, 'assets/icon.png') : null,
icon: (manifest.icon as string) ?? null,
permissions: manifest.permissions ?? [],
osDependencies: manifest.osDependencies ?? null,
has,
readme,
sourceCommit: git(dir, ['rev-parse', 'HEAD']),
});
console.log(
`[marketplace-sync] ${appName} ← ${repoUrl}${readme ? '' : ' (no README)'}${has.web ? '' : ' (no web/)'}`,
);
return { appName, developer, repoUrl };
console.log(`[marketplace-sync] ${stamp} — ${synced} published, ${unchanged} unchanged, ${unreleased.length} untagged`);
// Named rather than counted, and not under `--quiet`: "4 untagged" is a number nobody acts on, but a
// timer that reprints the same four names every minute is a log nobody reads. The names belong in a
// run somebody asked for.
if (unreleased.length && !quiet) {
console.warn(`[marketplace-sync] never tagged, so not updated: ${unreleased.join(', ')}`);
}
if (failed.length) console.error(`[marketplace-sync] failed: ${failed.join(', ')}`);
}
/**
* A raw-file URL for a Gitea repository.
*
* `HEAD`, not a branch name. This said `branch/main` and the comment here warned that "a repo whose
* default branch is `master` gets a 404" — which then happened: four plugin repositories were created
* on `master` and every one of them served a broken image in the marketplace on any server that reads
* the remote catalogue. A warning in a comment is not a guard.
*
* Gitea resolves `HEAD` to whatever the default branch is, so the question stops being asked. Verified
* against both spellings.
*
* `[open]` Still Gitea-shaped. A GitHub-hosted plugin needs the other form, and that wants the forge to
* be part of the registry rather than inferred from the URL.
*/
function rawUrl(repoUrl: string, path: string): string {
return `${repoUrl.replace(/\.git$/, '')}/raw/HEAD/${path}`;
}
const dirs = existsSync(PLUGINS_DIR)
? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })]
// Not a plugin: the shared install target for plugin npm packages, which lives here because bun
// resolves node_modules up the tree. `discoverPlugins` skips it for the same reason.
.filter((d) => d.replace(/\/$/, '') !== 'node_modules')
.map((d) => join(PLUGINS_DIR, d))
: [];
if (dirs.length === 0) {
console.log(`[marketplace-sync] no plugin directories under ${PLUGINS_DIR} — nothing to sync`);
process.exit(0);
}
// Discovery is not used to FIND them — a plugin that fails discovery is exactly the kind of thing a
// catalogue should still be able to describe — but its broken list is worth surfacing here.
const { broken } = await discoverPlugins().catch(() => ({ broken: [] as { appName: string; error: string }[] }));
for (const { appName, error } of broken) console.warn(`[marketplace-sync] ${appName} is broken on disk: ${error}`);
const synced: Sync[] = [];
for (const dir of dirs) {
const result = await syncOne(dir).catch((err) => {
console.error(`[marketplace-sync] ${basename(dir)} failed: ${err instanceof Error ? err.message : err}`);
return null;
});
if (result) synced.push(result);
}
console.log(`[marketplace-sync] ${synced.length}/${dirs.length} plugin(s) in the catalogue`);
process.exit(0);
+31 -7
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.
@@ -87,20 +97,34 @@ export function App() {
404 on a route that worked yesterday reads as the feature having been removed. */}
<Route path="/plugins" element={<PluginsRedirect />} />
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
what is installed, because a bundler cannot follow a runtime import specifier. */}
{/* One route per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
{/*
Three routes, and the third is the only new one.
`/<route>` and `/<route>/:section` are what every plugin has always had, and fifteen of
them read `useParams().section`. A splat ALONE replaced that for a day and took the
parameter away from all of them at once — the screens still mounted and every one of them
lost its selection, which is a worse failure than a 404 because nothing says so.
The splat is added for depth rather than instead of them: `/fofos-admin/layout/header` was
not expressible before, and a plugin that wants its own subtree reads the location.
React-router ranks a static or dynamic segment above a splat, so a plugin with one segment
still gets `section` and only deeper paths fall through.
*/}
{installedPlugins.flatMap((plugin) => [
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
// panels read `useParams` themselves, so which section is open is the URL rather than state
// passed between them.
<Route
key={`${plugin.screenId}-section`}
path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
<Route
key={`${plugin.screenId}-deep`}
path={`${plugin.route}/*`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
])}
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
@@ -14,6 +14,14 @@ export type CatalogueEntry = {
appName: string;
publisher: string;
version: string;
/**
* The git tag this entry was published from.
*
* Optional on the wire, not in the database, and the two mean different things. Here it is absent for
* a row that predates release-based publishing OR for a catalogue too old to send the field at all —
* so a consumer must treat "no tag" as "cannot tell" and fall back to whatever it knew before.
*/
releaseTag?: string;
platform: string;
label: string;
summary: string;
@@ -52,6 +60,7 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
appName: row.appName,
publisher: row.developer,
version: m.version,
...(m.releaseTag ? { releaseTag: m.releaseTag } : {}),
platform: m.platform,
label: m.label,
summary: m.summary,
@@ -106,6 +115,36 @@ export async function catalogueEntry(appName: string): Promise<CatalogueEntry |
return { ...entry, ...(row.metadata.readme ? { readme: row.metadata.readme } : {}) };
}
/**
* A registry row, plus the one piece of metadata the sync needs before deciding to do any work.
*
* `sourceCommit` is what the last sync published, and it is here rather than fetched per plugin so a
* run that has nothing to do costs one query instead of twenty-five. Null means never synced.
*/
export type RegistryRow = { id: number; appName: string; repoUrl: string; sourceCommit: string | null };
/**
* Every registered plugin — identity and clone URL, no metadata.
*
* This is what the sync iterates, and it is the reason the registry is called authoritative: the
* catalogue lists what somebody deliberately registered, not what happens to be checked out on the
* machine that runs the job. Unlisted rows are included; hiding a plugin from the public API is not the
* same as no longer tracking it, and one taken down for a bad release has to be able to come back
* current.
*/
export async function listRegistry(): Promise<RegistryRow[]> {
return marketplaceDb
.select({
id: plugins.id,
appName: plugins.appName,
repoUrl: plugins.repoUrl,
sourceCommit: pluginMetadata.sourceCommit,
})
.from(plugins)
.leftJoin(pluginMetadata, eq(pluginMetadata.pluginId, plugins.id))
.orderBy(plugins.appName);
}
// ── Writes. Used by the sync script, never by the public API ──
type RegisterParams = { appName: string; developer: string; repoUrl: string };
@@ -73,6 +73,19 @@ export const pluginMetadata = pgTable(
.references(() => plugins.id, { onDelete: 'cascade' }),
version: text('version').notNull(),
/**
* The git tag this row was read from — what "published" actually means.
*
* `version` above is the manifest's own claim, and the two are the same string every time somebody
* tags correctly. They are kept apart because when they DISAGREE the tag is the fact: it is what was
* pushed, and a manifest whose version nobody bumped would otherwise make a real release invisible
* to anything comparing versions.
*
* Nullable because rows existed before releases were the unit of publishing. A null one is a
* snapshot taken from a working tree by the old sync, and every consumer should read it as "unknown"
* rather than "no release".
*/
releaseTag: text('release_tag'),
/** The platform range it declares support for, e.g. `>=1.0.0`. */
platform: text('platform').notNull(),
label: text('label').notNull(),
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { db } from '../db';
import { pluginInstalls } from './schema';
import { pluginInstalls, pluginPorts } from './schema';
export type PluginInstall = typeof pluginInstalls.$inferSelect;
@@ -47,3 +47,90 @@ export async function removePluginInstall(appName: string): Promise<boolean> {
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
return rows.length > 0;
}
// ── Ports the platform hands out and remembers ────────────────────────────────────────────────────
export type PluginPort = typeof pluginPorts.$inferSelect;
/** Every allocation, for the settings screen and for anyone asking what is spoken for. */
export async function listPluginPorts(): Promise<PluginPort[]> {
return db.select().from(pluginPorts).orderBy(pluginPorts.port);
}
/** What this plugin has been given, as `{ name: port }`. Empty when it has asked for nothing. */
export async function getPluginPorts(appName: string): Promise<Record<string, number>> {
const rows = await db.select().from(pluginPorts).where(eq(pluginPorts.appName, appName));
return Object.fromEntries(rows.map((r) => [r.name, r.port]));
}
/**
* Find a port nothing is using, by asking the kernel and then checking our own book.
*
* Binding `port: 0` is the only honest way to know a port is free — a list of what we have handed out
* says nothing about what else on this machine is listening, and a hardcoded range says nothing about
* either. So the kernel picks, we let go, and the caller binds it a moment later.
*
* ── The gap between letting go and binding ──
*
* Real, and unavoidable without holding the socket open across a process boundary. Something else can
* take the port in between. That is why allocation is not the last word: a sidecar that cannot bind its
* remembered port must say so loudly rather than quietly choosing another, because the number is written
* down in configuration this database cannot see.
*/
async function freePort(taken: Set<number>): Promise<number> {
for (let attempt = 0; attempt < 20; attempt++) {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const port = probe.port;
probe.stop(true);
if (port && !taken.has(port)) return port;
}
throw new Error('could not find a free port after 20 attempts');
}
/**
* The port this plugin was given for `name`, allocating one the first time.
*
* Idempotent and stable: the same plugin and name always gets the same number back, which is the entire
* point — see the note on `pluginPorts`. Re-installing, upgrading and restarting all return what was
* given the first time.
*/
export async function allocatePluginPort(appName: string, name: string): Promise<number> {
const [existing] = await db
.select()
.from(pluginPorts)
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
if (existing) return existing.port;
/*
Racing another install is possible and is handled by the unique index rather than by a lock: two
installs running at once can be handed the same number by two different kernel probes, and exactly
one of the inserts will win. The loser tries again with a fresh number rather than failing, because
"somebody else installed a plugin at the same moment" is not a reason to refuse.
*/
for (let attempt = 0; attempt < 5; attempt++) {
const taken = new Set((await db.select().from(pluginPorts)).map((r) => r.port));
const port = await freePort(taken);
try {
const [row] = await db.insert(pluginPorts).values({ appName, name, port }).returning();
return row!.port;
} catch {
const [now] = await db
.select()
.from(pluginPorts)
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
if (now) return now.port; // the other writer was allocating the same thing
}
}
throw new Error(`could not allocate a port for ${appName}/${name}`);
}
/**
* Give up a plugin's ports. NOT called on uninstall — see the note in schema.ts.
*
* The number lives in configuration this database cannot see, so handing it to something else because a
* plugin was briefly removed points that configuration at the wrong service. Only `purge` does this.
*/
export async function releasePluginPorts(appName: string): Promise<number> {
const rows = await db.delete(pluginPorts).where(eq(pluginPorts.appName, appName)).returning();
return rows.length;
}
+55 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { pgTable, serial, text, integer, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
// Which plugins are installed on this machine, and whether they should be mounted.
//
@@ -56,3 +56,57 @@ export const pluginInstalls = pgTable(
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
],
);
/**
* Ports the platform has given out to plugins, and remembers.
*
* ── Why the platform allocates these when it allocates nothing else ──
*
* A sidecar's own port is not allocated: it binds `port: 0`, the kernel picks one, and it announces the
* number over the registration socket. That is right for what it is — a private detail between two
* processes, on loopback, re-chosen every restart, and nothing outside Officer ever needs to know it.
*
* A port something ELSE connects to is a different thing entirely. A plugin serving a site has to be
* reachable from nginx, which means the number must be stable across restarts and must be knowable by a
* human writing a proxy configuration. Ephemeral fails both.
*
* Doing it here rather than in each plugin's own settings buys the one thing per-plugin config cannot:
* **two plugins cannot be given the same number**, and the platform knows which are spoken for. That is
* `uq_plugin_ports_port` below, and it is the whole argument for this table.
*
* ── Kept when a plugin is uninstalled ──
*
* Deliberate. The number is written down somewhere this database cannot see — an nginx proxy host, a
* firewall rule, somebody's notes — so handing it to a different plugin because this one was briefly
* removed would point that configuration at the wrong service. Reinstalling gets the same port back.
* `purge` is the deliberate act that gives it up.
*/
export const pluginPorts = pgTable(
'plugin_ports',
{
id: serial('id').primaryKey(),
/** The plugin's app name — its directory, as everywhere else. */
appName: text('app_name').notNull(),
/**
* Which of the plugin's ports this is, as the manifest named it.
*
* A plugin may want more than one — a site and a preview of it — so the number alone is not an
* identity. Reaches the sidecar as `PORT_<NAME>` in its environment, uppercased.
*/
name: text('name').notNull(),
port: integer('port').notNull(),
allocatedAt: timestamp('allocated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One allocation per named port per plugin.
uniqueIndex('uq_plugin_ports_app_name_name').on(t.appName, t.name),
/*
No two plugins on the same number, said structurally.
This is the reason the table exists rather than a convention people follow. A collision here is not
a duplicate row, it is two services silently fighting over a listener, where the loser fails to bind
at startup and the winner is whichever happened to start first.
*/
uniqueIndex('uq_plugin_ports_port').on(t.port),
],
);
+50
View File
@@ -0,0 +1,50 @@
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC_PROXY_URL } from './officer-url.mjs';
import { readProxySecretFromDisk } from './sidecar/claude/state';
/**
* The sanctioned way for PLATFORM-SIDE code — including a plugin's server routes — to call Claude.
*
* ── Why this module exists ──
*
* `officer-anthropic-proxy` swaps a local secret for the owner's OAuth bearer, and that secret lives
* in `sidecar/claude-state.json` — platform-internal state whose location, format and permissions are
* this repo's to change. The first caller outside the sidecar (fofos-website's translate route) read
* the file directly, which worked and meant the sidecar could no longer move its own state without
* breaking a plugin. This module is the boundary: plugins import IT, and only platform code touches
* the file.
*
* ── Why a module and not an HTTP route ──
*
* Every caller so far is server-side code in this same process, already behind its own gate. An HTTP
* endpoint that spends the owner's subscription would be NEW surface — reachable by anything holding a
* session, needing its own registry entry and permission — bought for no caller that exists. The day
* a browser-side caller appears, the route is a thin wrapper over this plus that registry entry.
*
* ── The secret is read per call ──
*
* A fresh install writes it through a 30s debounce, so anything that cached at boot could hold an
* empty string forever. Reading late costs one small file read and removes the failure mode.
*/
/**
* A client pointed at the proxy, or null when the secret is not on disk yet.
*
* Null rather than a throw, because "not yet" is an ordinary state on a fresh install — the caller
* says which process is missing, in its own words, with its own status code.
*/
/*
Re-exported so a caller's `instanceof` works.
A plugin that imported '@anthropic-ai/sdk' itself could resolve a SECOND copy from the plugins'
own node_modules — and an error thrown by the platform's copy is not an instance of the other
one's classes, so `catch (e) { e instanceof RateLimitError }` silently stops matching. One import
path, one copy, one class identity.
*/
export { default as Anthropic } from '@anthropic-ai/sdk';
export function anthropicViaProxy(): Anthropic | null {
const secret = readProxySecretFromDisk();
if (!secret) return null;
return new Anthropic({ apiKey: secret, baseURL: ANTHROPIC_PROXY_URL });
}
+88 -3
View File
@@ -41,9 +41,12 @@ import {
setCommanderSessionId,
listCommandHqSessions,
getCommandHqSession,
getCommandHqSessionById,
setCommandHqSessionCategory,
purgeCommandHqSessions,
deleteCommandHqSession,
armCommandHqWatcher,
recordCommandHqWatcherFired,
disarmCommandHqWatcher,
getCommandHqCostSummary,
listCommandHqTurns,
@@ -721,9 +724,9 @@ commandHqRouter.post('/commanders', async (ctx) => {
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
* rename action calls — there is no separate name field on this table to disagree with it.
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
* this route is not it.
* `false` is accepted here: arming through THIS route would need a live task id, which only the turn
* that starts the watcher has. A loop reporting its own task id arms through `POST .../dir/:dirName/arm`
* below instead — that route exists precisely because this one can't take it.
*/
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
const user = ctx.get('user');
@@ -751,6 +754,88 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
return ctx.json({ ok: true });
});
/**
* The self-report counterpart to `armCommandHqWatcher`/`recordCommandHqWatcherFired`'s harness-driven
* calls in `websocket.ts`. Those fire off a `task:started`/`task:notification` event from the harness's
* OWN background-task tracking on the live session that submitted the arm dialog — which never happens
* for a loop that outlives its turn (`setsid`/`nohup`, or a tracked background job that dies with the
* turn anyway, which is most of them). `watcher_armed` stayed false and `command_hq_watcher_fires` stayed
* empty forever for exactly that class of commander — see `docs/command-hq.md` → "Self-reported arm".
*
* Authenticated with the commander's own `OFFICER_API_KEY` (minted per-commander in `credential.ts`), the
* same door its loop already uses for `/api/notify` and `/api/tasks/*​/run`. Keyed on `:dirName`, not
* `:sessionId` — a loop knows its own directory name (it's the folder it's running from; `dirName` is
* literally `String(id)`, see `createCommander`) and has no reason to also know its transcript's uuid.
*/
commandHqRouter.post('/commanders/dir/:dirName/arm', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('dirName'));
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
const row = await getCommandHqSessionById(user.id, id);
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
const taskId = body.taskId?.trim();
if (!taskId) throw errors.BAD_REQUEST('taskId required');
await armCommandHqWatcher(user.id, row.sessionId, taskId);
return ctx.json({ ok: true });
});
/**
* `fire`'s own guard mirrors the harness-driven path exactly: only records a trigger when this row is
* CURRENTLY armed with a matching task id, so an old instance of a loop that a fresh restart superseded
* (new pid, new self-generated task id) can't keep writing fires for a watch it no longer is — the same
* protection `websocket.ts`'s `task:notification` handler gets from checking `watcherTaskId === taskId`.
*/
commandHqRouter.post('/commanders/dir/:dirName/fire', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('dirName'));
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
const row = await getCommandHqSessionById(user.id, id);
if (!row) throw errors.NOT_FOUND('not a commander');
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
const taskId = body.taskId?.trim();
if (!taskId) throw errors.BAD_REQUEST('taskId required');
if (!row.watcherArmed || row.watcherTaskId !== taskId) {
return ctx.json({ ok: false, reason: 'not the current armed task' });
}
await recordCommandHqWatcherFired(row.id, taskId);
return ctx.json({ ok: true });
});
/**
* The loop's own clean-exit counterpart to `arm` — best-effort, same as every other disarm path: a loop
* killed without running its trap still leaves `watcher_armed: true` until the next arm or an explicit
* disarm, exactly as it already could before this route existed.
*
* Takes the SAME `taskId` guard as `fire`, and for the same reason: a restart replaces the old instance
* with a fresh arm before the old one's process necessarily notices it's been superseded (an inotify
* loop's kill can outrace the new one's `arm` call by seconds). An unguarded disarm from the OLD
* instance's exit trap would then clear the NEW instance's `watcher_armed` a moment after it was set —
* caught in exactly this shape while restarting a real duplicate on this machine, 2026-09-14. `taskId`
* is optional so an out-of-band disarm (nothing self-reported ever armed this row) still works — the
* guard only applies once there is a task id on the row to disagree with.
*/
commandHqRouter.post('/commanders/dir/:dirName/disarm', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('dirName'));
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
const row = await getCommandHqSessionById(user.id, id);
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
const taskId = body.taskId?.trim();
if (taskId && row.watcherTaskId && row.watcherTaskId !== taskId) {
return ctx.json({ ok: false, reason: 'not the current armed task' });
}
await disarmCommandHqWatcher(user.id, row.sessionId);
return ctx.json({ ok: true });
});
/**
* DELETE /command-hq/commanders/:sessionId
*
+77 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite';
import { proxyOverrideScript, rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
const BASE = '/api/dev-proxy/3001';
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
@@ -58,6 +58,43 @@ describe('rewriteHtml', () => {
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>'));
});
test('every candidate in a srcset is prefixed, descriptors intact', () => {
expect(rewriteHtml('<img srcset="/a.webp 96w, /b.webp 320w">', BASE)).toContain(
`srcset="${BASE}/a.webp 96w, ${BASE}/b.webp 320w"`,
);
});
/*
The failure this was found by. An <img> with a srcset also has a src, and the src WAS prefixed — but
a browser given a valid srcset picks from it and never reads src. So the logo was missing while the
markup looked right.
*/
test('a srcset is chosen over src by the browser, so prefixing src alone is not enough', () => {
const out = rewriteHtml('<img src="/logo.webp" srcset="/logo.webp 96w">', BASE);
expect(out).toContain(`src="${BASE}/logo.webp"`);
expect(out).toContain(`srcset="${BASE}/logo.webp 96w"`);
});
test('a candidate with no descriptor is prefixed too', () => {
expect(rewriteHtml('<img srcset="/only.webp">', BASE)).toContain(`srcset="${BASE}/only.webp"`);
});
test('imagesrcset on a preload link is prefixed — the one that is easy to miss', () => {
expect(rewriteHtml('<link rel="preload" as="image" imagesrcset="/hero.webp 1280w">', BASE)).toContain(
`imagesrcset="${BASE}/hero.webp 1280w"`,
);
});
test('a protocol-relative candidate is left alone, like everywhere else here', () => {
expect(rewriteHtml('<img srcset="//cdn.example.com/a.webp 96w, /b.webp 320w">', BASE)).toContain(
`srcset="//cdn.example.com/a.webp 96w, ${BASE}/b.webp 320w"`,
);
});
test('`..` is collapsed in a srcset candidate as well', () => {
expect(rewriteHtml('<img srcset="/../../x.webp 96w">', BASE)).toContain(`srcset="${BASE}/x.webp 96w"`);
});
test('a query string and hash survive normalisation', () => {
expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']);
});
@@ -175,3 +212,42 @@ describe('the injected location reporter', () => {
expect(r.posted).toHaveLength(2);
});
});
describe('stylesheets', () => {
test('a quoted url is prefixed — the form every generator emits', () => {
expect(rewriteCss('@font-face{src:url("/fonts/Averta.woff2") format("woff2")}', BASE)).toContain(
`url("${BASE}/fonts/Averta.woff2")`,
);
});
test('single quotes and bare urls too', () => {
expect(rewriteCss("a{background:url('/bg.png')}", BASE)).toContain(`url('${BASE}/bg.png')`);
expect(rewriteCss('a{background:url(/bg.png)}', BASE)).toContain(`url(${BASE}/bg.png)`);
});
test('the quote style is preserved, not normalised', () => {
expect(rewriteCss('a{background:url("/bg.png")}', BASE)).not.toContain("url('");
});
test('a url that is already absolute is left alone', () => {
const external = 'a{background:url("https://cdn.example.com/bg.png")}';
expect(rewriteCss(external, BASE)).toBe(external);
const protocolRelative = 'a{background:url("//cdn.example.com/bg.png")}';
expect(rewriteCss(protocolRelative, BASE)).toBe(protocolRelative);
});
test('a relative url is left alone — it already resolves against the stylesheet', () => {
const relative = 'a{background:url("./bg.png")}';
expect(rewriteCss(relative, BASE)).toBe(relative);
});
test('a stylesheet still gets the /_bun/ rule', () => {
expect(rewriteCss('@import "/_bun/x.css";', BASE)).toContain(`${BASE}/_bun/x.css`);
});
test('inline CSS in HTML gets the same treatment, quotes included', () => {
expect(rewriteHtml('<div style=\'background:url("/a.png")\'></div>', BASE)).toContain(
`url("${BASE}/a.png")`,
);
});
});
+74 -3
View File
@@ -89,7 +89,38 @@ const normalisePath = (path: string): string => {
/** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */
const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`;
// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override
/**
* A `srcset`, which is a LIST and cannot be prefixed as one value.
*
* `srcset="/a.webp 96w, /b.webp 2x"` — comma-separated candidates, each a URL plus an optional
* descriptor. The attribute rule below matches a whole attribute value, so it can only ever have
* rewritten the first URL and left the descriptor mangled onto it; it does not match `srcset` at all,
* which is why responsive images 404'd silently.
*
* Silently is the operative word. A `<picture>` or an `<img srcset>` still has a `src`, and that one WAS
* prefixed — but a browser given a valid srcset chooses from it and never looks at src. So the markup
* appeared correct, one attribute pointed at the platform root, and the image was simply missing.
*
* Split on commas rather than parsed: a URL cannot contain an unescaped comma in this position, and the
* descriptor never does.
*/
const prefixSrcset = (base: string, list: string): string =>
list
.split(',')
.map((candidate) => {
const trimmed = candidate.trim();
if (!trimmed) return '';
// The URL is everything up to the first space; the rest is `96w`, `2x`, or nothing.
const space = trimmed.search(/\s/);
const url = space === -1 ? trimmed : trimmed.slice(0, space);
const descriptor = space === -1 ? '' : trimmed.slice(space);
// Same rule as everywhere else here: root-absolute only, never protocol-relative.
return url.startsWith('/') && !url.startsWith('//') ? `${prefix(base, url)}${descriptor}` : trimmed;
})
.filter(Boolean)
.join(', ');
// HTML: rewrite src/href/action/srcset attributes with absolute paths + inject fetch/XHR override
export const rewriteHtml = (html: string, base: string) =>
html
.replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`)
@@ -97,12 +128,52 @@ export const rewriteHtml = (html: string, base: string) =>
/(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi,
(_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`,
)
.replace(/url\((\/(?!\/)[^)"']*)\)/g, (_match, path: string) => `url(${prefix(base, path)})`);
/*
`srcset` and `imagesrcset`, which the attribute rule above cannot do — see prefixSrcset.
// JS/CSS: only `/_bun/` paths.
`imagesrcset` is the one on `<link rel="preload" as="image">`. It is easy to miss and expensive
to miss: a preload that 404s costs a request AND leaves the real image undiscovered until the
body is parsed, which is the opposite of what the preload was added for.
*/
.replace(
/(imagesrcset|srcset)=("|')([^"']*)\2/gi,
(_match, attr: string, quote: string, list: string) => `${attr}=${quote}${prefixSrcset(base, list)}${quote}`,
)
.replace(CSS_URL, cssUrlPrefixer(base));
/*
`url(/…)` — a font, a background image, an `@import`. CSS allows the target bare, single-quoted or
double-quoted, and all three mean the same thing, so all three have to match. The quote used is
captured and put back rather than normalised: rewriting is not the place to have an opinion about
someone else's stylesheet.
This rule missed every QUOTED url until 2026-08-31, which is a bad way to fail. `url(/x.png)` was
rewritten and `url("/x.png")` was not, so whether a stylesheet worked through the proxy came down to
the formatting preference of whatever generated it.
*/
const CSS_URL = /url\(\s*(["']?)(\/(?!\/)[^)"']*)\1\s*\)/g;
const cssUrlPrefixer =
(base: string) =>
(_match: string, quote: string, path: string): string =>
`url(${quote}${prefix(base, path)}${quote})`;
// JS: only `/_bun/` paths.
//
// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are
// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it
// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to
// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that.
export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`);
/*
A STYLESHEET gets more than that, because a stylesheet's absolute paths are unambiguous: `url(/…)` is
a URL and nothing else, so the argument for being narrow in JavaScript does not apply here.
This was the whole of it for a long time, and the symptom was specific: a site's own `@font-face` rules
kept pointing at `/fonts/…`, which inside the frame is the PLATFORM's root, so every face 404'd and the
preview quietly rendered in a system fallback. Nothing looked broken — it just looked like a different
site than the one being previewed, which is the one thing a preview must never do.
*/
export const rewriteCss = (text: string, base: string) =>
rewriteAssetPaths(text, base).replace(CSS_URL, cssUrlPrefixer(base));
+8 -3
View File
@@ -1,7 +1,7 @@
import { createRouter } from '../../create-router';
import { resolveAuthToken } from '../../auth-token';
import { isSuperAdmin } from '../../super-admin';
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite';
import { rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
/**
* See a dev server running on THIS machine's loopback, from a browser that is somewhere else.
@@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => {
const type = res.headers.get('content-type') ?? '';
const isHtml = type.includes('text/html');
const rewritable = isHtml || type.includes('javascript') || type.includes('text/css');
const isCss = type.includes('text/css');
const rewritable = isHtml || isCss || type.includes('javascript');
const headers = new Headers(res.headers);
// Anything framed here is the owner's own dev server; the platform frames it deliberately.
@@ -169,7 +170,11 @@ devProxyRouter.all('/:port/*', async (ctx) => {
}
const text = await res.text();
const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix);
const body = isHtml
? rewriteHtml(text, prefix)
: isCss
? rewriteCss(text, prefix)
: rewriteAssetPaths(text, prefix);
// Both are wrong after a rewrite: the length changed, and the body was decoded on the way through.
headers.delete('content-length');
headers.delete('content-encoding');
+53 -8
View File
@@ -1,6 +1,7 @@
import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { readdir, lstat, readlink, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home';
@@ -188,9 +189,17 @@ router.get('/ls', async (ctx) => {
await cleanOldCacheDirs(rootDir);
}
let names: string[];
/*
`withFileTypes`, so a symlink can be spotted without a second syscall per entry.
The kind comes off the directory entry itself on every filesystem that reports it, and where it
does not Node falls back to an lstat it would otherwise have had to make anyway. `stat` below
FOLLOWS the link — which is right, and is why it cannot answer this question: a symlink to a folder
must still say `directory` so that opening it works.
*/
let names: Dirent[];
try {
names = await readdir(absPath);
names = await readdir(absPath, { withFileTypes: true });
} catch (ex) {
// A missing directory resets the browser to the root, which is the right answer for a stale path.
//
@@ -213,18 +222,44 @@ router.get('/ls', async (ctx) => {
const claimed = await pluginFileTypes();
const entries = await Promise.all(
names.map(async (name) => {
names.map(async (entry) => {
const name = entry.name;
const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe)
if (!isInside(rootDir, fullPath)) return null;
/*
Where a link POINTS, exactly as it was written.
Not resolved to an absolute path: a relative target is a fact about the link, and rewriting it
would hide that `../originals` moves with the folder while `/home/…/originals` does not. Only
asked for the entries that are links, which in an ordinary folder is none of them.
*/
const target = entry.isSymbolicLink() ? await readlink(fullPath).catch(() => null) : null;
const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory();
/*
A BROKEN link still gets a row, and did not before.
`stat` follows, so a link whose target is gone fails here and the entry returned null — the file
vanished from the listing entirely. Nothing said it was there, nothing could delete it, and the
folder simply had fewer things in it than `ls` showed. `lstat` describes the link itself, which
exists whatever it points at.
*/
const own = s ?? (target !== null ? await lstat(fullPath).catch(() => null) : null);
if (!own) return null;
const isDirectory = s?.isDirectory() ?? false;
return {
name,
type: isDirectory ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
size: own.size,
modifiedAt: own.mtimeMs,
/** Where it points, or absent when it is an ordinary file. See the note above. */
symlink: target,
/** A link whose target is gone. `type` is `file` for want of anything truer to say. */
broken: target !== null && s === null ? true : undefined,
/*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire.
@@ -398,6 +433,16 @@ router.post('/rename', async (ctx) => {
*
* 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.
*
* It was only ever half true, and the half that was missing is the half a browser can see. The DISK key
* has always held mtime and size, so the server regenerated correctly every time. The URL held the path
* and the box size and nothing else — so a browser told to keep the answer for a year kept it for a
* year, and a file replaced or renamed into a name already drawn showed the picture that used to be
* there. Nothing cleared it but somebody who knew to hard-reload.
*
* The client now sends `v=<mtime>-<size>` — see `getThumbUrl`. Nothing here reads it: this route stats
* the file anyway, and a parameter the server depended on would be a second source of truth about which
* file is meant. It is in the URL to be part of the URL, which is the whole job.
*/
router.get('/thumb', async (ctx) => {
const user = ctx.get('user');
+4 -2
View File
@@ -4,7 +4,7 @@ import { isSuperAdmin } from '../../super-admin';
import { mountPrefix } from '../../plugins/manifest';
import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
import { fetchCatalogue, fetchReadme, publishedTags } from '../../plugins/marketplace';
import { findOrphanTables } from '../../plugins/orphans';
import { fetchPlugin } from '../../plugins/fetch';
import { discoverPlugins } from '../../plugins/discover';
@@ -57,7 +57,9 @@ pluginsRouter.get('/catalogue/:appName/readme', async (ctx) => {
});
pluginsRouter.get('/', async (ctx) => {
const { states, broken } = await snapshotPlugins();
// The catalogue, for the update badge — cached, never throws, and falls back to the git rule per
// plugin when the store has nothing to say. See `plugins/release.ts`.
const { states, broken } = await snapshotPlugins(await publishedTags());
// Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
// rendering differently — it is the difference between "off" and "broken".
+17 -1
View File
@@ -59,7 +59,23 @@ export async function isKnownToPm2(name: string, cwd: string): Promise<boolean>
*/
export async function startProcess(name: string, cwd: string): Promise<Pm2Result> {
const known = await isKnownToPm2(name, cwd);
const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name];
/*
Through the ECOSYSTEM FILE either way, and that is the whole point of this line.
`pm2 start <name>` restarts a known process with the environment PM2 already holds for it, and never
looks at the file again. That was fine while an entry was just a script path — it is not now that an
entry carries `env`, because a plugin whose allocated port changed would have its new number written
to the file and go on running with the old one.
Seen exactly once, on the day ports were added: the ecosystem said PORT_SITE=34507, the process was
serving 3011, and nothing anywhere reported a disagreement.
`restart <file> --only <name>` re-reads the entry; `--update-env` makes PM2 replace the stored
environment rather than merge into it, so a variable REMOVED from the entry actually goes away.
*/
const args = known
? ['restart', ECOSYSTEM, '--only', name, '--update-env']
: ['start', ECOSYSTEM, '--only', name];
const { code, out } = await pm2(args, cwd);
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
+8 -3
View File
@@ -18,9 +18,14 @@ import { catalogueEntry, listCatalogue } from '../../databases/marketplace_db/sr
*
* ── Read-only, and structurally so ──
*
* There are two GET routes and no others. Registering a plugin is `scripts/marketplace-sync.ts`, run by
* hand against the database. That is not a temporary shortcut standing in for a write API: while
* submissions do not exist, a public write surface is all risk and no feature.
* There are two GET routes and no others. Everything that WRITES the catalogue — registering a plugin,
* publishing a release — is `scripts/marketplace-sync.ts` against the database, on the one machine that
* publishes. That is not a temporary shortcut standing in for a write API: while submissions do not
* exist, a public write surface is all risk and no feature.
*
* It stayed that way when publishing went onto a timer, which is the moment it would have been easy to
* add a `POST /api/sync` for a Gitea webhook. A minute of delay is cheaper than a write route on the one
* process here that has no login.
*
* ── CORS is wide open, deliberately ──
*
+301
View File
@@ -0,0 +1,301 @@
import { existsSync, mkdirSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import {
listRegistry,
type RegistryRow,
upsertMetadata,
upsertPlugin,
} from '../../databases/marketplace_db/src/plugins/queries';
/**
* Fill the catalogue from what each plugin has RELEASED.
*
* ── The bug this file exists to remove ──
*
* Until 2026-09-03 the sync read `platform/plugins/` — the directories this machine has INSTALLED — and
* imported each `manifest.ts` off the working tree. So "publish" meant "make sure officer-dev's checkout
* happens to be at the release, then run the script", and nobody could see when it was not. On the day
* this was rewritten the live catalogue was advertising four plugins from untagged working copies:
* av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26, none of
* which had ever been tagged. The store was describing somebody's afternoon.
*
* That is also why the old script could not be put on a timer: a cron over it would have published
* whatever was sitting in a working tree, on a schedule.
*
* ── What replaces it: a bare mirror, read at a tag ──
*
* Every plugin in the registry is mirrored (`git clone --mirror`) into a cache directory, refreshed, and
* read AT ITS NEWEST TAG — the manifest, the README, the file list, all out of `git cat-file`. There is
* no working tree anywhere in this path, so publishing something uncommitted is not a mistake that can
* be made. A mirror is cheap: the whole set clones in a couple of seconds and refreshes in well under
* one.
*
* `docs/marketplace.md` proposed instead reading manifests over Gitea's API, which needed either a
* sandbox to evaluate a fetched string or a committed `manifest.json` for every plugin. Git gives the
* same guarantee — content at a named release, independent of any checkout — with neither, and the trust
* model is unchanged from today: we import our own repositories' code.
*
* `[still a blocker for third-party submissions]` It is unchanged BECAUSE it is unchanged. Importing a
* stranger's manifest is code execution no matter which ref it came from. Submissions still need a
* sandbox or a published `manifest.json`.
*
* ── Which plugins, and where from: the registry, and only the registry ──
*
* The list comes from the `plugins` table, which `docs/marketplace.md` has always called the
* authoritative, hand-curated half. It was not actually being used that way — the old sync discovered
* plugins by listing a directory, so the registry was a by-product of whatever officer-dev had
* installed. Now it decides, which means:
*
* · a plugin nobody has installed here can still be published (`finances` is in exactly that state),
* · a plugin lives wherever its registry row says, across orgs — `fofos-admin` is under `fofos/`,
* not `plugins/`, so an org listing would have missed it,
* · and adding one is a deliberate act: `marketplace-sync.ts --add <https clone url>`.
*
* ── A plugin with no tag is left ALONE, not removed ──
*
* It has never made a release, so this file has nothing true to say about it and says nothing: the
* existing metadata row stays, and the run reports it. Deleting the row would drop it out of the store
* on the day the rule changed, which is a decision about somebody's plugin dressed up as a refresh.
* Tagging it is the fix, and the report is there to ask for one.
*/
export type SyncOutcome =
| { appName: string; status: 'synced'; tag: string; version: string }
/** The release on the remote is the one already published. Nothing was written. */
| { appName: string; status: 'unchanged'; tag: string }
| { appName: string; status: 'unreleased' }
| { appName: string; status: 'failed'; error: string };
/*
Where the mirrors live.
A fixed path under the user's cache directory, NOT one derived from the environment. This runs three
ways — by hand with `--env-file=.env`, from the webhook inside `officer-marketplace`, and from cron —
and those three do not have the same environment. A path that moved with `DATA_PATH` would have each
of them re-cloning twenty-five repositories into its own copy, and the "is it already mirrored" check
would answer differently depending on who asked.
Not in the source tree, because it is a cache of remote repositories and a `.gitignore` entry is a
worse way to keep several hundred megabytes out of a repo than not putting them there.
*/
const MIRROR_DIR =
process.env.MARKETPLACE_MIRROR_DIR ?? join(homedir(), '.cache', 'officer-marketplace', 'mirrors');
const GIT_TIMEOUT_MS = 60_000;
type Git = { ok: true; out: string } | { ok: false; err: string };
async function git(args: string[], cwd?: string): Promise<Git> {
// A missing `cwd` makes posix_spawn fail with ENOENT, which reads as "git is not installed" and sent
// a first run of this looking for git for ten minutes. It means the mirror is not there yet.
if (cwd && !existsSync(cwd)) return { ok: false, err: `${cwd} does not exist` };
const proc = Bun.spawn(['git', ...args], {
cwd,
stdout: 'pipe',
stderr: 'pipe',
// Never inherited: a mirror fetch that hits a host asking for a password would otherwise sit on a
// terminal prompt forever, and in the webhook server there is no terminal to sit on.
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '/bin/true' },
signal: AbortSignal.timeout(GIT_TIMEOUT_MS),
});
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return (await proc.exited) === 0 ? { ok: true, out: out.trim() } : { ok: false, err: err.trim() || 'git failed' };
}
/** The mirror for a plugin, cloned if this is the first time and refreshed if it is not. */
async function mirror(appName: string, repoUrl: string): Promise<string> {
mkdirSync(MIRROR_DIR, { recursive: true });
const dir = join(MIRROR_DIR, `${appName}.git`);
// Re-cloned rather than repaired when it is not a healthy bare repo: an interrupted clone leaves a
// directory that every later command fails against, and there is nothing in it worth keeping.
const existing = await git(['rev-parse', '--is-bare-repository'], dir);
if (!existing.ok || existing.out !== 'true') {
if (existsSync(dir)) await Bun.$`rm -rf ${dir}`.quiet().nothrow();
const cloned = await git(['clone', '--mirror', '--quiet', repoUrl, dir]);
if (!cloned.ok) throw new Error(`clone failed: ${cloned.err}`);
return dir;
}
// The registry is allowed to move a plugin. Setting the URL every time is one command and removes the
// failure where a repo is relocated and the mirror keeps refreshing from where it used to be.
await git(['remote', 'set-url', 'origin', repoUrl], dir);
// `--prune --prune-tags`: a deleted or MOVED tag has to disappear here too. Without it, a tag that was
// pushed by mistake and deleted would stay in the mirror forever and could go on being published as
// the newest release — a store advertising a version its repository no longer has.
const fetched = await git(['fetch', '--quiet', '--prune', '--prune-tags', '--tags', 'origin'], dir);
if (!fetched.ok) throw new Error(`fetch failed: ${fetched.err}`);
return dir;
}
/**
* The newest release, or null for a plugin that has never made one.
*
* Asked of git rather than by sorting version strings, which is the same rule `servers/plugins/release.ts`
* follows and for the same reason: parsing `2026.08.27.01` works right up until somebody tags `v2`, and
* then fails silently. `--abbrev=0` gives the closest tag reachable from HEAD, and a mirror's HEAD is the
* remote's default branch, so this is "the newest release on the branch the repository points at".
*/
async function newestTag(dir: string): Promise<string | null> {
const described = await git(['describe', '--tags', '--abbrev=0', 'HEAD'], dir);
return described.ok ? described.out : null;
}
/** A file at a ref, or null when it is not in that tree. */
async function fileAt(dir: string, ref: string, path: string): Promise<string | null> {
const shown = await git(['cat-file', '-p', `${ref}:${path}`], dir);
return shown.ok ? shown.out : null;
}
/**
* The manifest at a release, as an object.
*
* Written to a temp file and imported, because a manifest is TypeScript. Keyed by the COMMIT, so the
* module cache is correct rather than merely convenient: the same commit is the same file, and a tag
* moved to a different commit gets a different path and a fresh import. That matters in the webhook
* server, which is long-lived and would otherwise serve the first version of a manifest it ever read.
*
* The `import type { PluginManifest }` at the top of every manifest is erased by the transpiler, so the
* file has no runtime dependency on the platform's path aliases and imports fine from anywhere.
*/
async function manifestAt(dir: string, ref: string, appName: string, commit: string): Promise<Record<string, unknown>> {
const source = await fileAt(dir, ref, 'manifest.ts');
if (source === null) throw new Error(`no manifest.ts at ${ref}`);
const scratch = join(tmpdir(), 'officer-marketplace-manifests');
mkdirSync(scratch, { recursive: true });
const path = join(scratch, `${appName}-${commit}.ts`);
await Bun.write(path, source);
const mod = (await import(path)) as { manifest?: Record<string, unknown> };
if (!mod.manifest) throw new Error(`manifest.ts at ${ref} exports no \`manifest\``);
return mod.manifest;
}
/**
* A raw-file URL for a Gitea repository, PINNED TO THE RELEASE.
*
* It used to be `/raw/HEAD/…`, which works only because Gitea 303s it to `/raw/branch/HEAD/…`; the
* artwork it served was whatever was on the default branch, so a plugin could show one icon in the store
* and ship another. `/raw/tag/<tag>/…` answers 200 directly and shows the icon that belongs to the
* version being advertised.
*
* `[open]` Still Gitea-shaped. A GitHub-hosted plugin needs the other form, and that wants the forge to
* be part of the registry rather than inferred from the URL.
*/
function rawUrl(repoUrl: string, tag: string, path: string): string {
return `${repoUrl.replace(/\.git$/, '')}/raw/tag/${encodeURIComponent(tag)}/${path}`;
}
/*
`force` re-publishes a release that is already published.
Needed because "unchanged" is judged on the plugin's commit, and that is only half the input: the other
half is THIS file. Change how a field is derived — as the switch to tag-pinned icon URLs did — and every
row is stale while every commit still matches. Then the run to fix them all is `--force`.
*/
async function syncRegistered(row: RegistryRow, force: boolean): Promise<SyncOutcome> {
const { appName, repoUrl } = row;
const dir = await mirror(appName, repoUrl);
const tag = await newestTag(dir);
if (!tag) return { appName, status: 'unreleased' };
const commitAt = await git(['rev-list', '-n', '1', tag], dir);
if (!commitAt.ok) throw new Error(`cannot resolve ${tag}`);
const commit = commitAt.out;
// Before the manifest is read, not after: this is what makes a per-minute run affordable. Twenty-five
// repositories with nothing new cost one query, twenty-five fetches of nothing, and no writes at all.
if (!force && row.sourceCommit === commit) return { appName, status: 'unchanged', tag };
const manifest = await manifestAt(dir, tag, appName, commit);
// What the plugin SHIPS, read from the tree at the release rather than from a declaration — the same
// rule discovery follows on disk, so a catalogue entry cannot claim a sidecar the release does not
// have.
const listed = await git(['ls-tree', '-r', '--name-only', tag], dir);
const files = new Set(listed.ok ? listed.out.split('\n') : []);
const has = {
api: files.has('api/router.ts'),
schema: files.has('db/schema.ts'),
sidecar: files.has('sidecar/index.ts') || files.has('sidecar/index.mjs'),
web: [...files].some((f) => f.startsWith('web/')),
};
const readme = (await fileAt(dir, tag, 'README.md')) ?? (await fileAt(dir, tag, 'readme.md'));
const version = String(manifest.version ?? tag);
if (version !== tag) {
// Not an error and not corrected here. The tag is what was released and the manifest is what the
// plugin will report about itself once installed; when they disagree, an update badge that compares
// versions goes quiet. Saying so is the only way anyone finds out.
console.warn(`[marketplace-sync] ${appName}: tag ${tag} but manifest says ${version} — they should match`);
}
const developer = String(manifest.publisher ?? 'unknown');
const pluginId = await upsertPlugin({ appName, developer, repoUrl });
await upsertMetadata(pluginId, {
version,
releaseTag: tag,
platform: String(manifest.platform ?? '>=1.0.0'),
label: String(manifest.label ?? appName),
summary: String(manifest.summary ?? ''),
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
// quietly disagreeing with the repository it claims to mirror.
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
color: (manifest.color as string) ?? null,
tile: (manifest.tile as string) ?? null,
iconUrl: files.has('assets/icon.png') ? rawUrl(repoUrl, tag, 'assets/icon.png') : null,
icon: (manifest.icon as string) ?? null,
permissions: manifest.permissions ?? [],
osDependencies: manifest.osDependencies ?? null,
has,
readme,
sourceCommit: commit,
});
return { appName, status: 'synced', tag, version };
}
/** One plugin by app name. Unknown to the registry is a failure, not a silent no-op. */
export async function syncPlugin(appName: string, force = false): Promise<SyncOutcome> {
const row = (await listRegistry()).find((r) => r.appName === appName);
if (!row) return { appName, status: 'failed', error: 'not in the registry' };
return syncRegistered(row, force).catch((err) => ({
appName,
status: 'failed' as const,
error: err instanceof Error ? err.message : String(err),
}));
}
/**
* Every registered plugin, one at a time.
*
* Sequential on purpose. Twenty-four concurrent fetches against one Gitea is a thundering herd for a job
* nothing is waiting on, and the whole set finishes in about a second anyway.
*
* Unlisted rows are included: `listed` hides a plugin from the public API, usually after a bad release,
* and it has to be able to come back with current metadata rather than with whatever was true when it
* was pulled.
*/
export async function syncAll(force = false): Promise<SyncOutcome[]> {
const rows = await listRegistry();
const out: SyncOutcome[] = [];
for (const row of rows) {
out.push(
await syncRegistered(row, force).catch((err) => ({
appName: row.appName,
status: 'failed' as const,
error: err instanceof Error ? err.message : String(err),
})),
);
}
return out;
}
+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);
+44 -11
View File
@@ -30,8 +30,22 @@ import type { DiscoveredPlugin } from './manifest';
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
export type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };
/**
* One PM2 app entry. Matches what `services.sh` emits for the core processes.
*
* `env` carries the ports the platform allocated for this plugin — `PORT_<NAME>`. It is written into the
* ecosystem file rather than sent over the registration socket because a socket answer arrives after the
* process has already had to decide what to bind, and pm2 is the only thing that can tell a process
* something before it starts.
*/
export type Pm2App = {
name: string;
script: string;
args?: string;
cwd: string;
watch: boolean;
env?: Record<string, string>;
};
/**
* The header to write when there is no file to take one from.
@@ -81,12 +95,24 @@ function writeEcosystemApps(apps: Pm2App[]): void {
const marker = existing.indexOf('module.exports');
const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;
const lines = apps.map(
(app) =>
const lines = apps.map((app) => {
/*
Values are emitted as JSON rather than quoted by hand. A port is a number today, but this is the
seam where a path or a URL arrives eventually, and one apostrophe in a value would produce a file
PM2 cannot parse — which takes every process on the machine down, not just this plugin's.
*/
const env =
app.env && Object.keys(app.env).length
? `, env: { ${Object.entries(app.env)
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
.join(', ')} }`
: '';
return (
` { name: '${app.name}', script: '${app.script}'` +
(app.args ? `, args: '${app.args}'` : '') +
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
);
`, cwd: '${app.cwd}', watch: ${app.watch}${env} },`
);
});
writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
}
@@ -98,7 +124,7 @@ function writeEcosystemApps(apps: Pm2App[]): void {
* the database says is installed and enabled. Neither should duplicate the other's idea of what a
* plugin's entry looks like.
*/
export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
export function pluginPm2Entry(plugin: DiscoveredPlugin, ports?: Record<string, number>): Pm2App | null {
if (!plugin.sidecar) return null;
const name = pluginProcessName(plugin.appName);
@@ -108,9 +134,16 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
: plugin.sidecar.script;
// `PORT_<NAME>`, uppercased. Absent entirely when a plugin asked for none, which is nearly all of them.
const env = Object.fromEntries(
Object.entries(ports ?? {}).map(([name, port]) => [`PORT_${name.toUpperCase().replace(/-/g, '_')}`, String(port)]),
);
const base = { name, cwd: PLATFORM_DIR, watch: false, ...(Object.keys(env).length ? { env } : {}) };
return plugin.sidecar.runtime === 'node'
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
: { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };
? { ...base, script: 'node', args: script }
: { ...base, script: 'bun', args: `run ${script}` };
}
/**
@@ -119,8 +152,8 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
* Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
*/
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
const entry = pluginPm2Entry(plugin);
export function addPluginToEcosystem(plugin: DiscoveredPlugin, ports?: Record<string, number>): boolean {
const entry = pluginPm2Entry(plugin, ports);
if (!entry) return false;
const apps = readEcosystemApps().filter((a) => a.name !== entry.name);
+51 -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. */
@@ -183,6 +212,27 @@ export async function rebuildFrontend(): Promise<BuildResult> {
plugins: [tailwind],
minify: process.env.NODE_ENV === 'production',
sourcemap: 'none',
/*
WITHOUT THIS A PLUGIN CAN TAKE THE WHOLE APP DOWN WITH ONE LINE.
`scripts/build/web.ts` — the platform's own bundle — defines `process.env.NODE_ENV`. This build
defined nothing, so any `process.*` in a plugin's frontend was emitted verbatim into a browser
chunk and threw `ReferenceError: process is not defined` at module evaluation. One throwing module
fails the whole graph, so the symptom is not a broken panel, it is a blank platform.
Reached by vendored code rather than by anyone writing Node in a component: m3e-canvas carries four
files from a Next.js original that read `process.env.NEXT_PUBLIC_BASE_PATH`. Vendored trees stay
close to upstream here (see the hls.js note in CLAUDE.md), so the build is the right place to
absorb it — and it protects every plugin rather than the one that happened to hit it.
Both keys, and the order does not matter: the longest match wins, so `NODE_ENV` keeps its real
value and every other `process.env.X` becomes `({}).X`, which is `undefined` — exactly what the
`?? ''` in that vendored code expects. Verified against `bun build` rather than assumed.
*/
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV === 'production' ? 'production' : 'development'),
'process.env': '({})',
},
});
if (!result.success) {
return {
+35 -2
View File
@@ -15,7 +15,7 @@ import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb';
import { allocatePluginPort, releasePluginPorts, revokeGrantsForPermissions } from 'officerdb';
import { grantPluginToAllRoles } from './grant-on-install';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { refreshPluginMounts, snapshotPlugins } from './mount';
@@ -453,7 +453,29 @@ export async function installPlugin(
);
if (hasSidecar(plugin)) {
addPluginToEcosystem(plugin);
/*
Ports the manifest asked the platform to own, allocated before the entry is written because the
entry carries them.
Idempotent: a plugin that already has an allocation gets the same number back, so re-installing
and upgrading do not move a port somebody has written into an nginx proxy host. See
`pluginPorts` in officerdb for why the platform allocates these when it allocates nothing else.
*/
const ports: Record<string, number> = {};
for (const declared of plugin.manifest.extraPorts ?? []) {
ports[declared.name] = await allocatePluginPort(appName, declared.name);
}
if (Object.keys(ports).length) {
await step(
steps,
onStep,
`ports: ${Object.entries(ports)
.map(([name, port]) => `${name}=${port}`)
.join(', ')}`,
);
}
addPluginToEcosystem(plugin, ports);
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
@@ -609,6 +631,17 @@ export async function purgePlugin(
}
}
/*
Ports go here and NOT on uninstall, which is the deliberate asymmetry.
An allocated number is written down where this database cannot see it — an nginx proxy host, a
firewall rule, somebody's notes. Uninstalling is reversible and often temporary, so giving the number
up there would let a later plugin take it and point that configuration at the wrong service. Purge is
the act that says the plugin is gone for good, and that is when the number goes back in the pool.
*/
const released = await releasePluginPorts(appName);
if (released) await step(steps, onStep, `ports released: ${released}`);
await rm(plugin.dir, { recursive: true, force: true });
await step(steps, onStep, `deleted ${plugin.dir}`);
+102 -1
View File
@@ -405,6 +405,51 @@ export type PluginManifest = {
*/
protocolHeaders?: string[];
/**
* Ports this plugin needs the PLATFORM to allocate and remember.
*
* ── Not the same thing as a sidecar's own port ──
*
* A sidecar already gets a port, and nothing here changes that: it binds `port: 0`, the kernel picks
* one, and it announces the number over the registration socket. That port is a private detail between
* two processes — loopback, re-chosen on every restart, and nothing outside Officer ever needs it.
*
* This is for a port something ELSE connects to. A plugin serving a website has to be reachable from
* nginx, which means the number must survive a restart and must be knowable by whoever writes the proxy
* configuration. Ephemeral fails both, which is why the first site plugin had to hardcode 3011 and hope.
*
* ── What the platform guarantees, and what it does not ──
*
* Guaranteed: the same plugin and name always get the same number back, and no two plugins are ever
* given the same one — that second part is a unique index, not a convention, because a collision is two
* services fighting over a listener rather than a duplicate row.
*
* NOT guaranteed: that the port is still free when the sidecar starts. Something else on the machine
* can take it while Officer is down. A sidecar that cannot bind its remembered port must fail loudly
* rather than quietly choose another, because the number is written down where this platform cannot
* see it.
*
* ── How it reaches the sidecar ──
*
* As `PORT_<NAME>` in its environment, uppercased, written into the pm2 entry at install. The sidecar
* reads it rather than being told over the socket, because a socket answer would arrive after the
* process has already had to decide what to bind.
*
* ```ts
* extraPorts: [{ name: 'site' }] // → PORT_SITE=<n> in the sidecar's environment
* ```
*
* A manifest does NOT get to ask for a particular number, and that is deliberate. A preference sounds
* helpful and fails silently: when the wished-for port is taken the plugin is handed a different one,
* the install succeeds, and whatever was configured against the old number keeps pointing at nothing.
* Nobody is told. Drawing a fresh one every time makes the number always something you had to go and
* read, which is the state that keeps the configuration honest.
*
* Binding is still the sidecar's business, including which interface. A port that exists to be reached
* from a container cannot be loopback, and the platform has no way to know which a plugin means.
*/
extraPorts?: { name: string }[];
/**
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
*
@@ -523,6 +568,31 @@ export type PluginManifest = {
*/
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[];
/**
@@ -611,7 +681,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;
};
/**
@@ -919,6 +1002,24 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
}
}
}
if (manifest.extraPorts !== undefined) {
if (!Array.isArray(manifest.extraPorts)) {
problems.push('extraPorts must be an array');
} else {
const seen = new Set<string>();
for (const [i, entry] of manifest.extraPorts.entries()) {
const name = (entry as { name?: unknown } | null)?.name;
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,30}$/.test(name)) {
problems.push(`extraPorts[${i}].name must be a lowercase name, e.g. "site"`);
continue;
}
// The name becomes an environment variable, so two that differ only by case would collide there
// while looking distinct here.
if (seen.has(name)) problems.push(`extraPorts[${i}] repeats the name "${name}"`);
seen.add(name);
}
}
}
if (manifest.protocolHeaders !== undefined) {
if (!Array.isArray(manifest.protocolHeaders)) {
problems.push('protocolHeaders must be an array of header names');
+34 -1
View File
@@ -39,6 +39,15 @@ export type CatalogueEntry = {
appName: string;
publisher: string;
version: string;
/**
* The git tag this entry was published from — the newest RELEASE, as opposed to whatever a manifest
* claims about itself.
*
* Optional, and absent means "cannot tell" rather than "no release": an older catalogue does not send
* the field at all, and a plugin that has never been tagged has nothing to put in it. Both send the
* badge back to the git rule.
*/
releaseTag?: string;
platform: string;
label: string;
summary: string;
@@ -556,7 +565,15 @@ const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.offi
* plugin published this morning shows up this morning. The cache is per PROCESS and dies with it — a
* restart is always a fresh look.
*/
const CACHE_TTL_MS = 5 * 60 * 1000;
/*
One minute, and the number changed when the badge started depending on it.
Five was fine while this only drew the store's tiles; it is not fine now that "is there an update" is
answered from the catalogue, because the wait for a release to appear became the sync's minute PLUS
this one. The cost is one small request per process per minute, and only while somebody is looking at
a page that lists plugins.
*/
const CACHE_TTL_MS = 60 * 1000;
/**
* How long to wait for the store before giving up on it.
@@ -639,6 +656,22 @@ export async function fetchCatalogue(): Promise<CatalogueEntry[]> {
}
}
/**
* What the store says the newest release of each plugin is, keyed by app name.
*
* This is the fact the update badge is built on. It replaces every host in the fleet running `git fetch`
* against twenty-one repositories to re-derive something the catalogue already knows — see
* `plugins/release.ts` for the rule and for what happens to a plugin that has no entry here.
*
* A missing key and a null value mean the same thing to a caller and are kept apart for the reader: no
* entry at all (unregistered, or a store that could not be reached) versus an entry with no release yet.
* Both fall through to the git rule, which is the only honest answer when the store cannot speak.
*/
export async function publishedTags(): Promise<Map<string, string | null>> {
const entries = await fetchCatalogue().catch(() => []);
return new Map(entries.map((e) => [e.appName, e.releaseTag ?? null]));
}
/** One entry by app name, or null. Used by install to find where to clone from. */
export async function catalogueEntry(appName: string): Promise<CatalogueEntry | null> {
const all = await fetchCatalogue().catch(() => BUNDLED);
+8 -2
View File
@@ -59,7 +59,13 @@ export type PluginsSnapshot = {
* the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the
* database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss.
*/
export async function snapshotPlugins(): Promise<PluginsSnapshot> {
/**
* @param published Newest released tag per plugin, from the marketplace — `publishedTags()`. Passed by
* the one caller that renders update badges, and by nothing else on purpose: every other caller wants
* "what is on disk", and boot is among them. Making this function fetch the catalogue itself would put
* a network request with a timeout in front of mounting plugins on a machine with no internet.
*/
export async function snapshotPlugins(published?: Map<string, string | null>): Promise<PluginsSnapshot> {
const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]);
const byName = new Map(installs.map((row) => [row.appName, row]));
@@ -72,7 +78,7 @@ export async function snapshotPlugins(): Promise<PluginsSnapshot> {
plugin,
install,
outdated: !!install && install.version !== plugin.manifest.version,
release: await releaseState(plugin.dir),
release: await releaseState(plugin.dir, published?.get(plugin.appName) ?? null),
};
}),
);
+68 -3
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 };
}
@@ -135,12 +181,31 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] {
const names = Object.keys(plugin.manifest.npmDependencies ?? {});
return names.filter((name) => {
// The strong check: it imports. This is what catches a package that installed but cannot be loaded
// on this machine — `sharp` resolving to a platform-specific binary is the shape it was written for.
try {
Bun.resolveSync(name, plugin.dir);
return false;
} catch {
return true;
// fall through
}
/*
Some packages have NO root entry point, and that is not a fault.
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
subpaths, and you import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name,
Bun correctly says it cannot, and the check above read that as a broken install.
It cost a deploy: fofos-admin declares `@tiptap/pm` because tiptap needs it present, the install
was perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin
was reported as failed on every host that had it.
So a package that does not resolve is asked the weaker question instead: is it THERE. Present but
unimportable still fails, because the strong check ran first and only a package with no root entry
reaches this line.
*/
return !existsSync(join(PLUGINS_DIR, 'node_modules', name, 'package.json'));
});
}
+55
View File
@@ -94,6 +94,61 @@ describe('releaseState', () => {
expect(s.updateable).toBe(true);
});
/*
The catalogue path. These are the ones that matter now: a host no longer fetches to find out what the
newest release is, so every assertion below is about the store's tag winning over local refs — and
about the fallback still being there when it has nothing to say.
*/
test('the published tag decides, without the checkout having ever fetched it', async () => {
const { work, origin, clone } = pair('published-newer');
// The clone is at v1 and has NEVER seen v2 — no fetch, no remote ref for it. Exactly the state a
// host is in a minute after somebody else tagged a release.
publish(work, origin, 'two', 'v1');
git(clone, 'fetch', '-q', '--tags', 'origin');
git(clone, 'checkout', '-q', 'v1');
const s = await releaseState(clone, 'v2');
expect(s.tagAtHead).toBe('v1');
expect(s.newestTag).toBe('v2');
expect(s.updateable).toBe(true);
expect(s.reason).toBe('new-release');
});
test('the published tag matching HEAD is up to date', async () => {
const { work, origin, clone } = pair('published-same');
publish(work, origin, 'two', 'v1');
git(clone, 'fetch', '-q', '--tags', 'origin');
git(clone, 'checkout', '-q', 'v1');
const s = await releaseState(clone, 'v1');
expect(s.updateable).toBe(false);
expect(s.reason).toBe('at-latest');
});
test('an untagged checkout is updateable AND says what it would land on', async () => {
const { clone } = pair('published-untagged');
const s = await releaseState(clone, 'v9');
expect(s.tagAtHead).toBeNull();
// The old code returned null here, so the button read "Update" with nothing after it.
expect(s.newestTag).toBe('v9');
expect(s.updateable).toBe(true);
expect(s.reason).toBe('untagged');
});
test('no published tag falls back to the git rule rather than to a guess', async () => {
const { work, origin, clone } = pair('no-entry');
publish(work, origin, 'two', 'v1');
git(clone, 'fetch', '-q', '--tags', 'origin');
git(clone, 'checkout', '-q', 'v1');
// Null, as it is for a plugin nobody registered or a store that could not be reached.
const s = await releaseState(clone, null);
expect(s.tagAtHead).toBe('v1');
expect(s.newestTag).toBe('v1');
expect(s.updateable).toBe(false);
});
test('a directory that is not a checkout is not updateable', async () => {
const d = join(root, 'plain');
mkdirSync(d, { recursive: true });
+41 -6
View File
@@ -29,12 +29,32 @@ import { join } from 'node:path';
* is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on.
* That holds for any tag names anyone ever chooses, including ones that do not sort.
*
* ── What this does NOT do ──
* ── Where "the newest tag" comes from: the catalogue, not this machine's refs ──
*
* It never fetches. Every answer comes from refs already on disk, because this is called once per plugin
* on a request that renders a list — twenty-one network round trips would make the plugins screen feel
* broken. Tags arrive at clone and at the fetch `pullPlugin` already does, so "check for updates" is a
* consequence of updating rather than a separate poll.
* Every answer here is local, and this is called once per plugin on a request that renders a list —
* twenty-one network round trips would make the plugins screen feel broken. That left one hard problem:
* refs on disk go stale. Tags arrived at clone and at the fetch `pullPlugin` does, so you learned an
* update existed BY UPDATING. A machine that had not touched a plugin since August compared this
* morning's release against a snapshot from before it existed and said, truthfully and uselessly, that
* it was at the latest.
*
* That was patched with a rate-limited background `git fetch` per checkout, which worked and was the
* wrong shape: every host in the fleet polling twenty-one repositories to re-derive a fact one place
* already knows. The marketplace publishes releases now — see `servers/marketplace/sync.ts` — so the
* newest release is simply asked of the catalogue, which every host already fetches for the store.
*
* published tag (catalogue) vs tag at HEAD (this checkout) → differ, so there is an update
*
* DIFFERENCE, not ordering. `2026.08.27.01` parses fine until somebody tags `v2`, and then it fails
* silently. Two strings that are not equal is a comparison that cannot rot.
*
* ── The git rule stays, for a plugin the store cannot speak for ──
*
* A checkout of something unpublished — a plugin being written, one never registered — has no catalogue
* entry, and the fallback below is the old rule against local remote-tracking refs. Those refs are no
* longer refreshed here, so for that case this can miss a tag until something else fetches. That is the
* accepted cost: the alternative was every machine polling every repository forever to cover a case that
* only arises on a developer's own box, where `git fetch` is a thing they were about to run anyway.
*/
export type ReleaseState = {
@@ -53,7 +73,12 @@ const run = async (dir: string, args: string[]): Promise<string | null> => {
return (await proc.exited) === 0 ? out.trim() || null : null;
};
export async function releaseState(dir: string): Promise<ReleaseState> {
/**
* @param publishedTag The tag the marketplace says is this plugin's newest release, or null when the
* catalogue has nothing to say — an unregistered plugin, one never tagged, or a store that could not
* be reached. Null falls through to the git rule rather than to a guess.
*/
export async function releaseState(dir: string, publishedTag: string | null = null): Promise<ReleaseState> {
const none = { tagAtHead: null, newestTag: null };
if (!existsSync(join(dir, '.git'))) {
// Copied in by hand or bundled. Nothing to compare, and nothing to pull either — `pullPlugin` says
@@ -64,6 +89,16 @@ export async function releaseState(dir: string): Promise<ReleaseState> {
// `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and
// therefore returns null, when HEAD is between releases — which is the first half of the rule.
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
if (publishedTag) {
// Reported even when HEAD is untagged, so the UI can say what pressing Update lands on. The old
// code returned a bare null here and the button read "Update" with nothing after it.
if (!tagAtHead) return { tagAtHead: null, newestTag: publishedTag, updateable: true, reason: 'untagged' };
return publishedTag === tagAtHead
? { tagAtHead, newestTag: publishedTag, updateable: false, reason: 'at-latest' }
: { tagAtHead, newestTag: publishedTag, updateable: true, reason: 'new-release' };
}
if (!tagAtHead) return { ...none, updateable: true, reason: 'untagged' };
// Asked of the REMOTE branch, not of HEAD: a tag pushed after this checkout was made is not reachable
+5 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, readdirSync } from 'node:fs';
import { publishedTags } from './marketplace';
import { releaseState } from './release';
import { join } from 'node:path';
import { PLUGINS_DIR } from './discover';
@@ -144,7 +145,10 @@ export async function updatePlugin(
Checked BEFORE the pull, because a pull is what would make the answer stale.
*/
const state = await releaseState(join(PLUGINS_DIR, appName));
// Against the CATALOGUE, like the badge. Without it this asked local remote-tracking refs, which are
// no longer refreshed in the background — so a plugin at the latest tag it knows about would have
// short-circuited here and told the owner it was up to date without checking anything.
const state = await releaseState(join(PLUGINS_DIR, appName), (await publishedTags()).get(appName) ?? null);
if (!state.updateable && state.reason === 'at-latest') {
steps.push(`already at ${state.tagAtHead}, the newest release`);
onStep?.(steps[steps.length - 1]!);
+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})`);
@@ -0,0 +1,74 @@
/**
* A screenshot, shrunk to the largest size the model will actually look at.
*
* ── Why there is a ceiling at all ──
*
* An attached image goes to the model as base64 INSIDE the message, and it stays there: every later
* turn in the conversation re-sends every earlier picture. So a screenshot is not paid for once, it is
* paid for on every turn for the rest of the session — in request bytes and in tokens.
*
* ── Why 1568 specifically ──
*
* It is the point past which Anthropic downscales server-side. A 2539px-wide screenshot is resized to
* 1568 before the model sees a pixel of it, so the extra width was never read — it was carried up the
* wire, counted, and thrown away. Sending it already small changes nothing about what the model can
* see and stops paying for what it cannot.
*
* Measured on one day of this session: 25 screenshots, 10 of them over the line, 53,300 tokens of
* image data — 23% of which bought nothing.
*
* ── The ORIGINAL is still uploaded ──
*
* This makes the copy that travels in the prompt. The file that lands on disk is untouched, so an agent
* that needs the detail can `Read` it at full size — which is the right split: the conversation carries
* what is worth re-reading every turn, and the disk keeps what was actually captured.
*/
const MAX_EDGE = 1568;
/** JPEG for photographs would be smaller, and a screenshot of text is exactly where it looks worst. */
const TYPE = 'image/png';
export async function downscaleForPrompt(file: File): Promise<string> {
const bitmap = await createImageBitmap(file).catch(() => null);
if (!bitmap) return readAsDataUrl(file);
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
// Already small enough. Re-encoding it would cost quality and could cost SIZE — a PNG round-tripped
// through a canvas is often bigger than the one the screenshotter wrote.
if (scale === 1) {
bitmap.close();
return readAsDataUrl(file);
}
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
bitmap.close();
return readAsDataUrl(file);
}
// `high` because this is text: the default is a box filter, and screenshot text resampled with one
// comes out fringed in a way that is harder to read than the same text smaller.
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
return canvas.toDataURL(TYPE);
}
/** The file as it is, for anything this cannot or should not shrink. */
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { downscaleForPrompt } from './components/downscale';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { Attachment } from './types';
@@ -52,11 +53,22 @@ export function useAttachments(params?: UseAttachmentsParams) {
if (params?.sessionId) formData.append('sessionId', params.sessionId);
formData.append('provider', 'chat');
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
/*
The original is uploaded and a SMALLER copy is what travels in the prompt.
The server's `dataUrl` is the file byte for byte, and that copy is re-sent on every turn for the
rest of the conversation. Past 1568px on the long edge the model never sees the extra pixels —
they are downscaled server-side before it looks — so the width was being carried and counted and
then thrown away. See downscale.ts.
Both, deliberately: the disk keeps what was captured, so `Read` can still open it at full size.
*/
const [res, dataUrl] = await Promise.all([
client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData),
downscaleForPrompt(file),
]);
setAttachments((prev) =>
prev.map((a, i) =>
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
),
prev.map((a, i) => (i === idx ? { ...a, dataUrl, attachmentId: res.attachmentId, loading: false } : a)),
);
} catch {
setAttachments((prev) => prev.filter((_, i) => i !== idx));
@@ -21,6 +21,14 @@ type FileTreeProps = {
* an agent had just written to showing the listing from before its turn.
*/
refreshToken?: number;
/**
* A file to reveal: every directory on the way to it opens, loading as it goes.
*
* For a tree whose selection is driven from OUTSIDE — a nav beside it, a URL — where a folder left
* shut around the open file hides the one thing being looked at. Expanding is still disclosure and
* still remembered, so anything opened by hand stays open.
*/
revealPath?: string | null;
};
type TreeNodeProps = {
@@ -31,6 +39,7 @@ type TreeNodeProps = {
searchForFile?: ((path: string) => string) | null;
depth: number;
refreshToken?: number;
revealPath?: string | null;
};
const FileIcon = ({ name }: { name: string }) => {
@@ -44,7 +53,16 @@ const sortEntries = (entries: DirEntry[]) => {
return [...dirs, ...files];
};
const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, refreshToken }: TreeNodeProps) => {
const TreeNode = ({
entry,
parentPath,
root,
onOpenFile,
searchForFile,
depth,
refreshToken,
revealPath,
}: TreeNodeProps) => {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<DirEntry[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -73,6 +91,32 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
};
}, [refreshToken]);
/*
On the way to the revealed file: open, and load if this is the first time.
Only ever opens. A node that closes itself when the selection moves elsewhere would collapse the
tree under somebody as they clicked around it.
*/
const onTheWay = !!revealPath && isDir && revealPath.startsWith(`${fullPath}/`);
useEffect(() => {
if (!onTheWay) return;
setExpanded(true);
if (children !== null) return;
let alive = true;
listDirRef
.current(fullPath)
.then((res) => alive && setChildren(sortEntries(res.entries)))
.catch(() => alive && setChildren([]));
return () => {
alive = false;
};
// `children` is deliberately absent: this must run when the PATH changes, not when the load it
// started completes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onTheWay, fullPath]);
const handleClick = async () => {
if (!isDir) {
onOpenFile(fullPath, entry.name);
@@ -140,6 +184,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
searchForFile={searchForFile}
depth={depth + 1}
refreshToken={refreshToken}
revealPath={revealPath}
/>
))}
</div>
@@ -148,7 +193,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
);
};
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken, revealPath }: FileTreeProps) => {
const { t } = useTranslation();
const { listDir } = useFilesAPI(root);
const [entries, setEntries] = useState<DirEntry[] | null>(null);
@@ -208,6 +253,7 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshTok
searchForFile={searchForFile}
depth={0}
refreshToken={refreshToken}
revealPath={revealPath}
/>
))}
</div>
@@ -5,6 +5,9 @@ import { CodeEditorHeader } from './CodeEditorHeader';
import { panelName } from '../../locales';
export { CodeEditorView } from './CodeEditor';
// The tree on its own, for a panel that wants the editor's file list against its own directory rather
// than the whole editor. It only LISTS — opening is the caller's callback.
export { FileTree } from './FileTree';
export const appRegistryMetas: AppRegistryMeta[] = [
{
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { Breadcrumb } from './components/Breadcrumb';
import { Toolbar } from './components/Toolbar';
import { UploadProgress } from './components/UploadProgress';
@@ -6,7 +7,7 @@ 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;
@@ -27,6 +28,22 @@ 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;
/**
* The folder being shown, whenever it changes.
*
* For a chooser whose answer is a FOLDER rather than a file. Folders navigate rather than being
* pickable — getting to a file requires it — so the only way to say "this one" is to be standing in
* it, and the caller needs to know which one that is to put a button on it.
*/
onPathChange?: (path: string) => void;
};
export const FileBrowserApp = ({
@@ -36,9 +53,19 @@ export const FileBrowserApp = ({
urlPath,
panelId,
pathParam,
onPickFile,
onPathChange,
}: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam);
const { handleNavigate } = fileBrowserManager;
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
const { handleNavigate, currentPath } = fileBrowserManager;
// Reported from here rather than threaded through the hook: the hook's job is the folder, and one
// more positional argument on a six-argument call is how a signature stops being readable.
const report = useRef(onPathChange);
report.current = onPathChange;
useEffect(() => {
report.current?.(currentPath);
}, [currentPath]);
return (
<div className="flex flex-col h-full overflow-hidden">
@@ -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';
@@ -61,6 +63,8 @@ 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,
@@ -68,6 +72,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
rootOverride,
selected,
clipboard,
refresh,
handleOpen,
handleDelete,
handleRename,
@@ -167,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({
@@ -269,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}
@@ -277,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}
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import {
Folder,
Link2,
Trash2,
Pencil,
MoreVertical,
@@ -23,6 +24,7 @@ import {
FileArchive,
Pin,
PinOff,
Sparkles,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { Thumbnail } from './Thumbnail';
@@ -50,7 +52,7 @@ import { cardStyle } from '@/components/Card';
import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskGroup, TaskSummary } from '../../useTasks';
import type { AgentGroup, AgentSummary } from '../../useAgents';
import { getFileType } from '../../../FileViewer';
import { getFileType, thumbVersion } from '../../../FileViewer';
import { useTranslation } from '../../../../locales';
import { useFormat } from 'i18n';
@@ -84,9 +86,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;
@@ -177,6 +195,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;
@@ -212,6 +231,7 @@ const MenuItems = ({
canPaste,
onOpen,
onOpenInEditor,
pluginActions,
onDelete,
onStartRename,
onChat,
@@ -254,7 +274,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;
@@ -305,6 +325,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">
@@ -565,6 +593,7 @@ export const FileItem = ({
viewMode,
selected,
anySelected,
pluginActions,
selectedCount,
isCut,
onOpen,
@@ -589,6 +618,8 @@ export const FileItem = ({
onCompress,
onDuplicate,
onPasteInto,
onDragStart,
onDropInto,
onTogglePin,
isPinned,
canPaste,
@@ -601,6 +632,21 @@ export const FileItem = ({
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory';
/*
A link, and where it goes.
Drawn as an arrow beside the name rather than as a badge or a different icon: the icon already says
what the thing IS — a folder, a photograph — and a symlink to a folder is still a folder to anyone
using it. What the arrow adds is that there is an indirection in the way, which is the thing that
explains why deleting it did not free any space, or why two places show the same file.
The target goes in `title`. It is often long and absolute, so it belongs in a tooltip rather than in
a row that has to fit a name, a type, a size and a date.
*/
const linkLabel = entry.symlink
? `${entry.broken ? 'Broken symlink' : 'Symlink'} → ${entry.symlink}`
: undefined;
const icon = isDir ? (
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
) : (
@@ -624,7 +670,16 @@ export const FileItem = ({
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}>
<Thumbnail
path={path}
root={root}
size={size}
// Which version of the file, so replacing or renaming one does not leave the old picture in a
// browser cache that was told it could keep it for a year. See `Thumbnail`.
version={thumbVersion(entry)}
isVideo={kind === 'video'}
className={className}
>
{node}
</Thumbnail>
);
@@ -735,6 +790,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,
@@ -757,6 +816,24 @@ 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">{listVisual}</span>
@@ -768,7 +845,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
<span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)}
</div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
@@ -804,6 +892,15 @@ export const FileItem = ({
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)} />
@@ -825,7 +922,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)}
/>
) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
<span className="flex w-full min-w-0 items-center justify-center gap-1">
<span className="truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)}
<span className="text-xs text-duck-dark/50">
@@ -52,6 +52,7 @@ export const Thumbnail = ({
path,
root,
size,
version,
isVideo,
className,
children,
@@ -59,6 +60,16 @@ export const Thumbnail = ({
/** Root-relative, the same path every other file-browser call uses. */
path: string;
root?: string;
/**
* Which VERSION of the file this is — `mtime-size`, from the listing.
*
* The response is `immutable` for a year, and the URL used to carry only the path, so a browser that
* had drawn a thumbnail for a name went on drawing it however the file behind that name changed. A
* rename into a name already seen showed the picture that used to be there, and nothing short of a
* hard reload cleared it. The SERVER was never wrong — its own cache key has always held mtime and
* size — the URL simply did not say which file it meant. See `getThumbUrl`.
*/
version?: 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. */
@@ -79,7 +90,8 @@ export const Thumbnail = ({
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}`;
const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
return (
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
@@ -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>
)}
</>
);
@@ -213,7 +213,11 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div>
</form>
) : (
<div className="flex flex-col gap-3">
// `min-w-0` on the DIRECT grid child. DialogContent is a grid, and a grid item defaults to
// `min-width: auto` — so this is the element that grows to fit a long title and takes the
// card out of the dialog with it. The constraint has to be here; on the scroll container
// inside it was one level too low to stop anything.
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<button
type="button"
@@ -248,7 +252,19 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div>
)}
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1">
{/*
`min-w-0` is the one that makes `truncate` work, three levels down.
DialogContent is a GRID, and a grid item defaults to `min-width: auto` — it refuses to
shrink below its content. So a long title pushed this container wider than the dialog's
own `max-w-lg`, the card grew with it, and the `truncate` on the title never had a width
to truncate against. The text simply ran out of the modal.
Making the card a column fixed a different half of this and was not enough: a column still
cannot be narrower than the widest thing a grid will let it be. The constraint has to come
from above, and this is the highest point where it was missing.
*/}
<div className="flex max-h-[52vh] min-w-0 flex-col gap-2 overflow-y-auto pr-1">
{entries.map((e, i) => (
<div
key={`${e.url}-${i}`}
@@ -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';
@@ -36,12 +37,17 @@ export const FILES_PATH_PARAM = 'path';
* 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();
@@ -192,7 +198,25 @@ export const useFileBrowserApp = (
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);
@@ -223,6 +247,10 @@ export const useFileBrowserApp = (
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}`);
@@ -230,8 +258,29 @@ export const useFileBrowserApp = (
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) {
@@ -239,6 +288,7 @@ export const useFileBrowserApp = (
return;
}
setEntries(data.entries);
shownPath.current = currentPathRef.current;
if (data.rootDir) setRootDir(data.rootDir);
} catch (err: any) {
console.error('[FileBrowser] refresh error:', err);
@@ -302,6 +352,50 @@ export const useFileBrowserApp = (
};
}, [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) => {
@@ -475,6 +569,21 @@ export const useFileBrowserApp = (
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
@@ -970,6 +1079,78 @@ export const useFileBrowserApp = (
}
};
/*
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++;
@@ -995,6 +1176,28 @@ export const useFileBrowserApp = (
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;
@@ -1088,7 +1291,12 @@ export const useFileBrowserApp = (
// 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';
@@ -1,7 +1,7 @@
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 { getFileType, getThumbUrl, thumbVersion } from './file-types';
import { useFileViewer } from './FileViewerContext';
/**
@@ -128,7 +128,7 @@ export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
>
{entry.thumb ? (
<img
src={getThumbUrl(path, root)}
src={getThumbUrl(path, root, 256, thumbVersion(entry))}
alt=""
loading="lazy"
decoding="async"
@@ -165,14 +165,31 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
*
* 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.
*
* `version` is what makes the year-long `immutable` on that response honest. The SERVER's cache key has
* always held the file's mtime and size; this URL held only the path, so a browser that had once drawn
* a thumbnail for a name kept drawing it for a year however the file behind that name changed. Upload,
* rename something else into the name, replace a photograph — the picture on screen stayed. Pass
* `mtime-size` and a changed file is simply a different URL. The server ignores the parameter; it stats
* the file regardless.
*/
export function getThumbUrl(filePath: string, root?: string, size = 256): string {
export function getThumbUrl(filePath: string, root?: string, size = 256, version?: string): 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}`;
const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
}
/**
* What identifies this VERSION of a file, for a cache key — the same two facts the server hashes.
*
* One place, because the grid and the carousel strip must agree: two spellings would mean two URLs for
* one file, which is two renders and two cache entries for no reason.
*/
export const thumbVersion = (entry: { modifiedAt?: number; size?: number }): string | undefined =>
Number.isFinite(entry.modifiedAt) && Number.isFinite(entry.size) ? `${entry.modifiedAt}-${entry.size}` : undefined;
export function getAudioTracksUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
@@ -4,7 +4,7 @@ export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody';
export { useFileViewer } from './FileViewerContext';
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
export { getFileType, getLang, getExt, getArchiveBaseName, thumbVersion, ARCHIVE_EXTS, type FileType } from './file-types';
// No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard:
// it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header`
@@ -534,9 +534,11 @@ export const PluginDetail = () => {
how you teach someone to stop pressing it. */}
{plugin.updateable && (
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
{plugin.releaseReason === 'new-release' && plugin.newestTag
? `Update to ${plugin.newestTag}`
: 'Update'}
{/* Named whenever a release is known, not only on `new-release`. An untagged checkout
reports `untagged`, and it used to lose the destination with it — the button said
"Update" and the owner had to guess where it landed. The marketplace knows the newest
release even for a checkout that has never fetched it. */}
{plugin.newestTag ? `Update to ${plugin.newestTag}` : 'Update'}
</Button>
)}
{/* Said out loud rather than shown as an absent button. "No Update button" and "up to date"
@@ -205,6 +205,20 @@ export type DirEntry = {
* response, which simply means no thumbnail — the file icon, exactly as before.
*/
thumb?: 'image' | 'video' | null;
/**
* Where this points, when it is a symlink — exactly as the link was written.
*
* Relative targets stay relative. `../originals` and `/home/…/originals` behave differently when the
* folder moves, and resolving one into the other would hide the difference at the moment somebody is
* looking to understand it.
*
* `type` still describes the TARGET, so a link to a folder opens like a folder. This says only that
* there is a link in the way. Absent on an older response, which reads as "not a link" — the same
* thing every entry said before.
*/
symlink?: string | null;
/** The target is gone. The row is drawn anyway, because the link itself is really there. */
broken?: boolean;
};
type ListDirResponse = {
+4 -1
View File
@@ -36,7 +36,7 @@ export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory';
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor';
export { CodeEditorView, FileTree } from './apps/CodeEditor';
// Same for /system-monitor, where the param is the scope rather than a section.
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
@@ -59,6 +59,9 @@ export {
useRecentFiles,
usePinnedFiles,
FileBrowserApp,
registerFileActions,
type FileAction,
type PickedFile,
FileBrowserPanelWrapper,
FileBrowserWidget,
TaskRunnerModal,
@@ -542,7 +542,10 @@
"type": "Typ",
"size": "Größe",
"date": "Datum"
}
},
"toCopy": "zu kopieren",
"toMove": "zu verschieben",
"clearClipboard": "Zwischenablage leeren"
},
"taskRunner": {
"stop": "Stopp",
@@ -542,7 +542,10 @@
"type": "Type",
"size": "Size",
"date": "Date"
}
},
"toCopy": "to copy",
"toMove": "to move",
"clearClipboard": "Clear clipboard"
},
"taskRunner": {
"stop": "Stop",
@@ -542,7 +542,10 @@
"type": "Tipo",
"size": "Tamaño",
"date": "Fecha"
}
},
"toCopy": "para copiar",
"toMove": "para mover",
"clearClipboard": "Vaciar portapapeles"
},
"taskRunner": {
"stop": "Detener",
@@ -542,7 +542,10 @@
"type": "Type",
"size": "Taille",
"date": "Date"
}
},
"toCopy": "à copier",
"toMove": "à déplacer",
"clearClipboard": "Vider le presse-papiers"
},
"taskRunner": {
"stop": "Arrêter",
@@ -542,7 +542,10 @@
"type": "Tipo",
"size": "Tamanho",
"date": "Data"
}
},
"toCopy": "a copiar",
"toMove": "a mover",
"clearClipboard": "Limpar área de transferência"
},
"taskRunner": {
"stop": "Parar",
@@ -542,7 +542,10 @@
"type": "Typ",
"size": "Storlek",
"date": "Datum"
}
},
"toCopy": "att kopiera",
"toMove": "att flytta",
"clearClipboard": "Töm urklipp"
},
"taskRunner": {
"stop": "Stoppa",
+9
View File
@@ -2,6 +2,15 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState';
export { useDashboardState } from './useDashboardState';
/*
Exported for a workspace nested INSIDE a panel — see plugins/fofos-admin/web/WorkPanel.tsx.
`useDashboardState` persists the whole tree, which is right for a dashboard somebody arranged and
wrong for a layout its author ships: the structure would be frozen at whatever it was the first time
a screen was opened. `useAuthoredLayout` keeps the structure in source and persists only sizes and
zoom, which is what a nested authored layout wants for the same reason `PluginScreen` does.
*/
export { useAuthoredLayout } from './useAuthoredLayout';
export { useModels, useVisibleModels, useUserVisibleModels, useEnabledModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
export { useAccessPolicy } from './useAccessPolicy';
+45
View File
@@ -204,6 +204,51 @@
background-attachment: fixed;
background-size: cover;
}
/*
NATIVE CONTROLS FOLLOW THE THEME.
------------------------------------------------------------------------------------------
Nothing here set `color-scheme`, so the browser drew every control it renders itself as if the page
were light — on a dark theme, a `<select>`'s dropdown opened as a white sheet with the page's own
pale text on it. Unreadable, and unreachable by any utility class, because that list is painted by
the browser and is not in the document.
`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with
no styleable interior: date and colour pickers, the spinner on a number input, and default
scrollbars where a theme has not replaced them.
── Why the `dark` class decides it ──
It is the signal that exists. 203 of the 231 themes set `applyDarkClass`, and the provider puts the
class on <html> beside `data-theme`, so the two are always in agreement about the same element.
Both are specificity 0-1-0 and themes are imported after this file, so a theme that disagrees can
simply say so — this is a default, not a decision taken away.
── And the explicit colours underneath ──
`color-scheme` gives the browser's generic dark, not the theme's. Where a browser honours colours on
`<option>` — Firefox, and Chrome on Linux and Windows — these make the list the theme's own popover
surface instead. Where it does not (Safari, Chrome on macOS, which use a genuinely native menu), the
scheme above is what carries it, and generic dark is a great deal better than white-on-white.
A utility still wins: these are base-layer rules, so `bg-transparent` on a select behaves exactly as
it did.
*/
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
select,
option,
optgroup {
background-color: var(--popover);
color: var(--popover-foreground);
}
}
/*
+14 -1
View File
@@ -149,7 +149,20 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
would be indefensible.
*/
const target = event.target as HTMLElement | null;
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
/*
A CODE EDITOR is typing too, and it does not look like it.
Monaco's modern input surface is neither a textarea nor contenteditable — it is a plain div the
EditContext API writes into — so a tagName test misses it entirely and `ctrl+shift+←/→` cycled
the theme while somebody was selecting words in a file. `.monaco-editor` is the class its root
always carries; `closest` because the event lands on whatever is inside it.
*/
const typing = Boolean(
target &&
(target.isContentEditable ||
/^(input|textarea|select)$/i.test(target.tagName) ||
target.closest?.('.monaco-editor, [data-typing]')),
);
/*
Star or unstar the CURRENT theme, from anywhere, list open or not.
+159
View File
@@ -0,0 +1,159 @@
---
id: backburner/m3e-canvas-as-a-plugin
title: "M3E Canvas as a plugin — sketch a UI, hand the prompt to the agent"
theme: backburner
status: open
created: 2026-09-04
date_source: stated
order: 4
---
**M3E Canvas as a plugin.** [github.com/lnkiai/m3e-canvas](https://github.com/lnkiai/m3e-canvas) — a
browser design tool for Material 3 Expressive screens that exports the design as a natural-language
prompt for an AI coding tool. MIT.
The upstream question is now **answered** — see below; vendoring is the path. The rest is still open.
This card holds the measurements so the decision is made from numbers rather than from a README. Everything below was read from the code at **`cfde698`, 2026-09-04**.
## Why it is interesting
Its output is *a prompt you paste into Claude*. Officer is the thing that removes the paste — the export
button could open a chat session with the prompt already in it, pointed at a project directory on this
machine. **That last step is the only part Officer uniquely adds, and it is the whole reason to bother.**
The canvas already works standalone today.
## What it actually is
| | |
| --- | --- |
| size | 65 files, **14,822 lines** of TS/TSX |
| deps | `next` 16.3.4, `react` 19.2.8, `motion` ^13.1.1, `html-to-image` ^1.11.13 |
| licence | MIT |
| storage | `localStorage` only, no backend |
**Next.js is not actually there.** The entire codebase imports it **once**, and it is a type-only import:
`import type { Metadata, Viewport } from "next"` in `app/layout.tsx`. Eighteen files carry `'use client'`.
So it is a client-side React SPA that happens to be scaffolded in Next — there is nothing to strip, and
porting means giving it a different mount point.
That is the finding that changes the estimate. An earlier read of the README concluded "weeks of work,
own a fork forever", and that was wrong.
## The fork line falls on a file boundary
`app/page.tsx` imports about **twenty modules, every one a named export** through the `@/` alias, and
reaches into no internals. `lib/tokens.ts` is the shared spine with 16 importers. `app/` holds exactly
one file that matters.
| | lines | |
| --- | --- | --- |
| `app/page.tsx` | 3,433 | the whole editor: 15 components, 103 hook calls. **Yours.** |
| `lib/ai.ts` | ~200 | **Delete it** — stores provider API keys in `localStorage`. Officer has a secret store and the agent. |
| `lib/tokens.ts` | 1,614 | Material 3 tokens. Data. Vendor. |
| `components/M3Node.tsx` | 1,233 | the component renderer. Vendor. |
| `components/Inspector.tsx` | 1,171 | Vendor. |
| `lib/prompt.ts` | 1,151 | the export engine. Vendor. |
| `components/Preview.tsx` | 899 | Vendor. |
| `components/ui.tsx` | 882 | Vendor. |
| rest | ~4,200 | `Mobile`, `i18n`, `tidy`, `Toolbar`, `color`, `shapes`, `theme`, `project`. Vendor. |
**~3,600 lines owned, ~11,200 vendored** — and the split means something. What is vendored is *"what a
Material 3 component looks like and how to describe it in words"*, where upstream's value accumulates and
we have no wish to innovate. What is owned is *"where does this live in the workspace, whose is it, and
what happens on export"*, where upstream can never help because it does not know Officer exists.
**So deep integration and tracking upstream are not in tension.** The integration work lives entirely in
the one file that was always going to be rewritten.
## The export point is clean
```ts
buildPrompt(doc: Doc, widths: Record<string, number>, onlyFrameId?: string, lang: Lang): string
effectivePrompt(doc, widths, lang): string
```
Two pure functions in `lib/prompt.ts`. `Doc` in, string out, no side effects. No `postMessage` bridge and
no clipboard interception needed — **call the function.** Today it ends in
`navigator.clipboard.writeText` in three places; that is what gets replaced.
`Doc` is plain serialisable JSON (`lib/project.ts` already downloads and reloads it), so persisting to a
plugin table instead of `localStorage` is nearly free. Current keys: `DOC_KEY` and `UI_KEY` in
`page.tsx`, `m3e:ai`, and `m3e:sec:<id>`.
## The upstream problem, measured
This is the part that makes it a backburner card rather than a build.
- **37 commits, 3 days old** — first 2026-09-02, and **22 of them landed on 2026-09-04**, the day this
was read.
- **Two human authors** (`lnkiai` 25, `CQMHV` 10) plus dependabot.
It is under heavy active development *right now*. The export surface will churn, and no amount of
discipline on our side changes that. "Vendor it and track upstream" assumes an upstream that has settled,
and this one has not started.
### If it is vendored anyway
`web/vendor/m3e/`, committed with upstream URL, commit SHA and date in a README beside it. **The
precedent is already in the tree** — `plugins/jellyfin/web/vendor/` and `public/vendor/pako/`.
The piece that makes it survivable is **an adapter**: Officer's panels import from one `adapter.ts` that
re-exports from `vendor/`, never from `vendor/` directly. An upstream rename then costs one file instead
of twenty. Updating is: re-copy, read the diff, fix the seam.
## Asked upstream, 2026-09-04. Declined — vendoring is the path
[Issue #55](https://github.com/lnkiai/m3e-canvas/issues/55) asked for the seam;
[PR #56](https://github.com/lnkiai/m3e-canvas/pull/56) implemented the smaller half of it. Both closed
unmerged on 2026-09-05. The reply is worth reading in full, but the two reasons were:
1. **The PR did not actually unblock embedding.** It wired one of the three clipboard writes —
`app/page.tsx` — and left `components/Inspector.tsx:443` and `components/PromptPanel.tsx:35`
writing directly. So it "adds a public surface I'd have to keep stable" without delivering the
thing it was for. **That was our error, not a judgement call**: the issue text named all three
sites and the PR wired one.
2. **Embedding is not on their roadmap, and it is a single-user need so far.** Fair on both counts.
**What they said they would want** if that changes, quoted because it is the specification for any
future attempt:
> If I decide to support embedding I'd want to do it in one piece: the `Canvas` extraction plus the
> adapter wired into every export path. Your design for the adapter is sound and I'd start from it
> then.
So the adapter design survives — it is validated by the person who would have to maintain it — and the
work it needs is known: **the file move plus all three export paths**, not one of them.
The branch is on the fork at [andrepadez/m3e-canvas `embeddable-canvas`](https://github.com/andrepadez/m3e-canvas/tree/embeddable-canvas),
two commits, typecheck and build green. Worth keeping: if this is vendored, that is the diff to apply
on top, extended to the other two call sites.
### What this settles
**Vendor, do not depend.** The upstream door is open in principle and shut in practice, so the fork is
the only route to the integration — which is what the split below was already designed for. Nothing
about the file-boundary analysis changes; only the hope of upstreaming the shell does.
## If it is picked up anyway
**Open an issue asking upstream to make the seam explicit.** If `page.tsx` grew an entry point taking the
persistence layer and the export sink — `<Canvas storage={…} onExport={…} />` — we would **depend rather
than fork**, and the 3,600 owned lines shrink to a few hundred.
**Answered: no, for now.** Recorded above. Asking cost an afternoon and bought a specification for
what upstream would accept, plus confirmation that the adapter shape is right — which is a good return
on a declined PR.
## Open questions
1. **Is this a design tool that lives in Officer, or the front end of "sketch it and the agent builds
it"?** If the second, `page.tsx` is where we deliberately diverge, and adopting the fork is the right
frame from day one rather than something discovered in six months.
2. **Whose designs are they?** `Doc` is per-user data. Multi-user makes that a permissions question, and
the plugin table answers it in a way `localStorage` cannot.
3. **Does the phone editor survive the port?** `components/Mobile.tsx` (534 lines) is a separate
phone-optimised editor. Officer's mobile story is a native app, so this may be redundant or may be the
best part.
4. **An iframe was considered and rejected.** It buys a day and costs the function call, the theming, the
persistence, and forces back the clipboard round-trip the whole idea exists to remove. Recorded so it
is not re-proposed as the cheap option.
@@ -0,0 +1,54 @@
---
id: known-bugs/radicale-cannot-be-installed-on-macos
title: 'caldav retries `brew install radicale` on every boot, and there is no such formula'
theme: known-bugs
status: open
created: 2026-09-22
date_source: stated
order: 8
---
**On macOS, caldav fails its own dependency install on every single boot**, logging a line that reads like
a transient error and is permanent:
```
[caldav] radicale install failed (exit 1): … Warning: No available formula with the name "radicale".
```
Confirmed on `macbookpro` (macOS 26.6.2) 2026-09-22 — four consecutive boots, four identical failures.
**Two things are wrong and only one is caldav's.** `plugins/caldav/manifest.ts` declares
`packages: { apt, pacman, dnf }` with **no `brew` entry**, so the installer falls back to the binary name
and runs `brew install radicale`. And the fallback cannot succeed anyway: **radicale is not a Homebrew
formula.** It is a Python application, installed with `pipx install radicale` or `uv tool install radicale`.
Homebrew's suggestion in the log — "Did you mean radicle or cadical?" — is two unrelated projects.
So the manifest has nothing correct to put in a `brew` field, which is the actual gap:
`PackageManager` is `'apt' | 'pacman' | 'dnf' | 'brew'` (`plugins/manifest.ts`). There is no way to express
"this comes from pipx", and no way to say "not available on this platform" — so the only outcomes are a
wrong guess retried forever, or silence.
Consequences, in order of how much they matter: caldav does not work on that machine at all (no calendars,
no contacts — the manifest's own `reason` says so); the failure repeats every boot with no backoff and no
memory; and the log line is indistinguishable from something worth chasing, which is how it cost a
debugging session that had been opened about something else entirely.
Worth deciding rather than patching: either `PackageManager` grows a `pipx`/`uv` member, or
`osDependencies` grows a way to declare a platform where a binary is genuinely unavailable, so the
installer can say it once and stop.
---
**Found alongside, and NOT a bug — recorded so the next person does not chase it.** The same log carried
`[plugins] frontend rebuild failed: ResolveMessage: Could not resolve: "motion/react"`, which looked like
the real failure and was already over. It appeared on exactly one boot: the m3e-canvas plugin had landed on
disk before `plugins/node_modules/motion` existed. Verified on the machine afterwards — `motion` is listed
in the generated `plugins/package.json`, installed, and `import('motion/react')` resolves with 398 exports;
the current boot rebuilds cleanly.
**The gap it exposes is real even though it healed.** `installNpmDependencies` is called from exactly one
place, `plugins/install.ts`, the install flow. Boot (`plugins/mount.ts`) rebuilds the plugin frontend and
never installs anything. So a plugin that reaches `plugins/` without that flow completing — cloned by hand,
or an install interrupted — leaves every subsequent boot failing the rebuild with a message that says
_"Maybe you need to bun install"_ while nothing ever will. Here the install finished and the next boot was
fine; on a machine where it does not, the platform cannot recover on its own.