Compare commits

..
21 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
34 changed files with 1335 additions and 56 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=="],
+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
+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.30.03",
"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. */
@@ -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
*
+10
View File
@@ -433,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');
+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}` };
+43 -10
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);
+21
View File
@@ -212,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}`);
+63
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.
*
@@ -957,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');
@@ -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';
@@ -35,6 +36,14 @@ type FileBrowserAppProps = {
* 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 = ({
@@ -45,9 +54,18 @@ export const FileBrowserApp = ({
panelId,
pathParam,
onPickFile,
onPathChange,
}: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
const { handleNavigate } = fileBrowserManager;
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">
@@ -52,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';
@@ -670,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>
);
@@ -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,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`
+1 -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';
+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.