03fbb1189f7ad6f43b63c9d37fac9288cd0c2665
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
03fbb1189f |
release 2026.08.29.03 — the todo board leaves, a plugin can claim a file type
no schema change and no dependency change, and the plan says so with the two commands that prove it rather than the claim alone. so there is no db:push step and no bun install step, and the plan says neither should be improvised. ten commits, all code. THE BEHAVIOUR CHANGE IS NAMED RATHER THAN DISCOVERED: /todos now 404s. the board is the kanban plugin, in the marketplace, installed nowhere by default — the intended state. `role_permissions` rows naming `todos` are orphaned and the plan says explicitly NOT to clean them up: absence denies, a row naming a permission that does not exist grants nothing, and a data edit nobody asked for does not belong in a rollout. the plan adds one verification this release earns: an `ls` call per host, because the listing route changed to union plugin-claimed file types with core's own. a 500 there is a failed host. surveyed the fleet while writing it. av-club is installed on fofos ALONE, so fofos is the only host that gains HEIC — everywhere else the file browser is exactly what it was, which is the property the extension point was built for and worth stating so its absence elsewhere is not read as a fault. edge-pertento-bridge did not answer the survey. not listed as skipped on that evidence — one quiet moment is not a down host, and the last time this fleet assumed otherwise it cost four attempts against a machine that was fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
21665ed024 |
HEIC opens, thumbnails retry, and both views ask for the same picture
three reports, three causes, none of them the one I would have guessed. "FAILED TO LOAD IMAGE" ON DOUBLE-CLICK. the viewer pointed `<img>` at /raw — the HEIC bytes — and no browser decodes those. `render` was declared on av-club and nothing called it. so core gains /file-browser/render, which forwards a CLAIMED type to the plugin that claimed it, and the viewer uses it for exactly the formats no browser can show. that list lives client-side and is a fact about BROWSERS, not about plugins: chrome and firefox have never decoded HEIC and safari only does on apple's own platforms. everything else keeps using /raw, which streams, supports ranges and paints progressively — all of which a rendered copy gives up. not cached on disk, unlike /thumb: a full-size render is large, looked at once, and keyed on a width the viewer picks. caching it would fill a disk to make the second look at one photograph faster. "SOMETIMES DON'T ARRIVE THE FIRST TIME". two causes and the second one made the first irreparable. the client treated any error as final, so one failure meant a file icon until the page was reloaded. it retries three times over about seven seconds now, backing off, with the attempt in the `key` so the element actually remounts — changing only the src leaves the browser's own failed-request cache in play and the retry is not one. and the SERVER remembered the failure. that is right for sharp and ffmpeg, where the file is the problem and will fail identically forever — it is what stops one bad video spawning work on every scroll. it is wrong for a PLUGIN, which fails while it restarts, while it is busy behind its own limit, and for a few seconds after an update. those pass. cached, no client retry could ever get past them, because the answer came from memory without the plugin being asked again. producer failures are no longer remembered. "THE THUMBNAIL SHOULD BE THE SAME IN LIST OR GRID". it was not, and that was mine: the list asked for 128px and the grid for 256, which are different cache keys. for a jpeg that is milliseconds. for a HEIC it is four seconds per file, so switching views showed icons where the other view had just shown photographs. one size now — the list draws at 28px and the grid at 80, so both downscale either way and 256 is sharp on a retina screen at both. verified: render 589KB webp from a 1.4MB HEIC, png correctly 415, thumb 12KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
111618bd7f |
a plugin can claim a file type the platform cannot read
the file browser's thumbnailer read what sharp and ffmpeg read, and that list was a constant in core. HEIC forced the question: sharp's bundled libheif has no HEVC decoder, and teaching core to shell out to ImageMagick means the PLATFORM acquiring a system dependency for one format. that is a plugin's job. so a manifest may declare `fileTypes` — extensions, plus a thumbnail path and a render path on its own sidecar. `ls` unions those with core's, so a file only promises a thumbnail when something can actually produce one. `/thumb` forwards a claimed type to whoever claimed it. CORE DOES NOT KNOW av-club EXISTS. it reads a claim at runtime from whatever is installed and enabled, calls a path by name, and falls back to a file icon when nothing claims the type. no import, no name, no special case. PROVEN BY DISABLING IT: heic reports thumb=image with av-club enabled, thumb=none disabled, image again re-enabled. that is the whole requirement — the file browser is what it was without the plugin — and it is a test rather than a claim. the plugin's bytes are cached by CORE, under the same key everything else uses, so a HEIC thumbnail is produced once and then served from disk like any other. that is why `buildThumbnail` takes a producer rather than there being a second path beside it: the cache key with its mtime and size, the concurrency cap and the remembered failures all live in that one function, and a parallel path would be a second place to forget the negative caching. TWO THINGS FOUND BY WIRING IT UP. a claimed-but-not-running sidecar is skipped rather than recorded, or `ls` promises a thumbnail that cannot be fetched while a plugin restarts. and the sidecar authenticates: it accepts `x-officer-user` from anything on loopback, by its own design, so core sends the account the request is FOR — passing anything else would be lying to a plugin that scopes per user. verified: HEIC 2 MB in, 12 KB out, 5.4s cold and 101ms cached; png and mp4 unchanged; 1665 tests; docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
9cb875ca79 |
core stops claiming HEIC it cannot decode
the revert above took the ImageMagick path back out of core, where it should never have gone: decoding HEIC needs ImageMagick and libheif-plugin-libde265, and the core has no business acquiring a system dependency for one format. that is a plugin's job. this removes the other half of the same mistake. `heic` and `heif` were in core's extension list from the day the thumbnailer was written, which made `ls` promise a thumbnail sharp cannot produce — so every HEIC in a folder cost a request that 422'd before falling back to the icon, and the promise was the only reason the request happened. so without a plugin the file browser is now what it was: a file icon for a HEIC, and no wasted round trip earning it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
c5b95becd2 |
Revert "HEIC gets a thumbnail, through the decoder that can actually read it"
This reverts commit
|
||
|
|
f4b9596a88 |
HEIC gets a thumbnail, through the decoder that can actually read it
sharp ships its own libvips and libheif built WITHOUT the HEVC decoder, so a HEIC parses its header, reports 4032x3024, and then fails on the pixels with "bad seek". that reads like a corrupt file and is not one — every photograph an iPhone takes is HEVC-coded HEIF, so it is most of what arrives from a phone. ImageMagick links the SYSTEM libheif, which has the codec. so a `.heic` goes through `convert` to a lossless PNG and sharp takes it from there. PNG rather than JPEG for the intermediate so the single real encode is the final one and the thumbnail is not a copy of a copy. removed in a `finally` either way — a failed decode must not leave a stray file a later run mistakes for a finished thumbnail. this is deliberately the same approach av-club has used for weeks (sidecar/sharp/decodable.ts), on these exact files. copied rather than shared, because the platform must not import from a plugin — the dependency only runs the other way, and fifteen lines of spawn is cheaper than inverting it. `convert` exits 0 having written nothing when the codec is missing, so the OUTPUT FILE is the test rather than the exit code. that is not defensive: it is precisely the failure this exists to handle. IT NEEDS `libheif-plugin-libde265`, which is not in a default Ubuntu install — libheif packages its codecs separately and ships the AV1 one, not the HEVC one. declared in av-club's manifest today, and installed on this host to prove the path. without it, `convert` fails the same way sharp does and the browser falls back to the file icon, which is the correct degradation rather than a break. the in-memory failure cache did exactly what its comment said it would: the HEIC stayed 422 after the codec was installed, until a restart. "a restart is the natural moment to give a file another chance after somebody has installed a codec" — written weeks ago, met today. verified: a real iPhone HEIC, 2 MB in, 12 KB out, 5.3s cold and 22ms cached, and the image is right. png, svg and mp4 unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
09518e1e1f |
the three files fb1cf0f6 was supposed to carry
|
||
|
|
500bb925eb |
the directory picker becomes shared, because two plugins wanted it
it was written inside fofos-admin, and then kanban's project form asked for a path and got a TEXT FIELD — because a plugin cannot import from a sibling plugin and I took the cheap way out rather than moving it. that is the wrong trade for a component whose entire job is not letting somebody type a path wrong. nothing in it was fofos-specific: it browses the owner's home over the platform's own /file-browser/ls and /mkdir. so it moves to the officerdev barrel that plugins already import from, and both use the one. it takes the platform's `errorText` rather than fofos-admin's `readableError`, which knew about that plugin's three particular failures — unconfigured, unreachable, refused. here there are two: cannot read that folder, or not allowed. copying it instead would have left two of them to drift, and the version that drifts is always the one somebody is looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a970cb7c5f |
kanban joins the catalogue
it was on disk and discovered but not in BUNDLED, so the plugins page classified it as "a developer working on one" rather than something anybody could install. that is the file's own three-state vocabulary working correctly on an entry I forgot to write — building the plugin and creating its repository does not put it in a catalogue that is still a hand-maintained constant. the entry repeats the manifest because a remote plugin has no directory to read: everything shown BEFORE installing has to be declared here, including the two things worth seeing before consenting — the permissions it wants and the binaries it would make the machine acquire. kanban wants one permission and no binaries. `schema: true` for one table, and the entry says what is in it: a list of FOLDERS. the items are markdown on disk and this plugin keeps no copy, so an uninstall with "delete its data" ticked loses the list and never a line of anybody's work. that is the thing somebody should be able to read before they install it. `sidecar: false` — it reads and writes files with node's own fs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fb1cf0f6f1 |
the todo board leaves the platform
`/todos` is now the `kanban` plugin. removed here: the router, the screen, the route, the TodoBoard panel, its AppRegistry entry and the `todos` permission. WHAT STAYED, and it is a seam rather than an oversight: `todo/` and `scripts/todo-index.ts`. that folder is the platform's OWN backlog, committed with the platform's own code, and the CLI regenerates TODO.md from a terminal with no Officer running. moving either would have made this repository's own documentation depend on a plugin being installed. so `todo-tree.ts` stays too, with one caller instead of two, and there are now two copies of that logic which will drift. recorded in the file itself and in the plugin's README rather than discovered in six months. the honest fix on the day it matters is to delete the CLI and let the plugin own the format. THE TOTALITY CHECK EARNED ITS KEEP. removing the router left `todos` in permissions/registry.ts and the server REFUSED TO BOOT — exactly what that guard is for. a stale permission naming a route that no longer exists is the kind of thing that otherwise sits there for months granting access to nothing. docs:check then caught two more: the generated platform skill still listed `/todos`, and AGENTS.md claimed 21 permission entries against a registry now holding 20. both fixed, and the AGENTS.md note now explains the drop the same way it already explains caldav's. verified: server boots, `bun todo:index` still regenerates TODO.md, 1665 tests pass (up from 1657 — the ported tree tests run in the plugin now), typecheck clean, docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a947df8d9d | deploy report for 2026.08.29.02 — 7 of 7, plugin columns everywhere | ||
|
|
78bf63c209 |
release 2026.08.29.02 — a plugin can ship a commander
THIS ONE CHANGES THE DATABASE, and the plan says so with the command that proves
what the change is rather than the claim alone:
command_hq_sessions
+ plugin text null
+ plugin_config jsonb null
both nullable, no default, no constraint — so every existing row is valid the
moment they exist and there is nothing to backfill. no rename, nothing dropped, no
create-then-constrain, and therefore no --force needed or permitted.
THE PLAN DEMANDS A QUERY, NOT AN EXIT CODE. drizzle-kit push can fail and still
exit 0, so step 3 is a verification that must print exactly `plugin plugin_config`
and whose OUTPUT goes in the report per host — not "schema ✓". I ran that exact
command here before asking seven hosts to run it.
NO bun install step: package.json and bun.lock are untouched since .01. that also
sidesteps the step-order bug the last rollout found, where the standard
plugins:update runs before a plan's install and imports a package that is not there
yet.
the backup step reads the env variable name rather than assuming it. several hosts
use OFFICERDEV_POSTGRES_URL and not POSTGRES_URL, which is the kind of detail that
turns a backup step into a file containing an error message.
av-club's commander template reaches each host through the standard plugins:update
step rather than through this plan — worth saying, because av-club moving is
expected here rather than a surprise.
gate before tagging: typecheck clean, docs:check green, locale parity green. the
ModelSelector test still fails and is still not mine — same chat code, untouched by
this range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
347acf0171 |
Command HQ shows what plugins offer, and what has not been started
the UI half. a row per plugin that ships commanders, below the owner's own
categories and separated from them — because they are not the same kind of thing.
a category is the owner's filing and they may rename it; a plugin is where
something came from and they may not. mixed into one list, "av-club" reads as a
folder somebody made, and the first thing you do with a folder you made is rename
it.
offers sit ABOVE the grid rather than in it. a commander in the grid runs, has a
transcript and has cost money; an offer is a description on disk. drawn as the same
card with a badge, the two look interchangeable, and the first consequence is
somebody hunting for the transcript of a thing that has never run.
an offer STAYS after it is used. it is a template and there is no reason to have
only one — two folders watched for two formats is two commanders from one offer,
and hiding it would make the second look impossible.
the form is drawn from the template's own `inputs`. nothing in the component knows
what av-club wants: options become buttons, boolean becomes yes/no, everything else
is a field. the button names what is missing rather than marking it red.
TWO REAL BUGS FOUND BY WIRING IT UP, both in the list route:
`?category=plugin:av-club` reached `Number('plugin:av-club')` — NaN — and the query
compared `category_id = NaN`. that matches nothing and looks exactly like "this
plugin has no commanders", which is the worst way for a filter to fail. parsed
before the numeric branch now.
and the grid DROPS every row without a transcript. that was right when the only way
to get a row was to start a conversation; it is wrong now, because a commander from
a template arrives deliberately not running — so the thing somebody just created was
invisible, under a toast saying it worked. a row with a `plugin` and no session is
now projected from the row itself, with the zeroes that are true of it, and sorted
FIRST: it is the one that needs a decision. ordinary rows keep the old behaviour,
since a hand-made row with no transcript is a conversation that failed to start and
the design refuses to draw a tombstone for it.
four locale keys, all six languages, parity test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
766b18b7f9 |
create a commander from a plugin's template, and refuse to arm a blank one
second half of the backend. GET /command-hq/plugin-commanders lists what the
installed plugins offer; POST /commanders takes {plugin, template, config} and
builds one from it.
the list is SEPARATE from GET /commanders, which returns what exists. merging a
thing that runs with a thing that could be made, distinguished by a flag, means
every consumer has to remember which it is holding.
`watch_dir` doubles as the reference path when a template supplies one. asking for
a reference path AND a watch directory is asking the same question twice and
inviting the two answers to disagree.
THREE FILES, EACH A COPY rather than a reference into the plugin:
state/config.env what the owner answered, in the form the watcher sources
watch.sh copied, so plugins:update cannot change the script a commander
is already running — a watcher whose code moves under it while
armed is a thing nobody could debug
AGENTS.md the template's prose, appended to the brief already written
config.env is generated with every value SINGLE-QUOTED, and that is not decoration:
WATCH_DIR is a path somebody typed, and this fleet has directories called `ABOUT US`
and `70´S`. unquoted, the first space ends the assignment and the watcher watches
the wrong folder — with `rm` downstream of that. verified by sourcing both that path
and one containing an apostrophe in a real shell.
THE ARM GUARD LIVES IN armCommandHqWatcher, not in a route. arming is not a button:
it happens when the agent starts a tracked background job and the platform notices.
a check anywhere else is one the agent routes around by simply starting its loop,
which is exactly what it does. a row with no template is unaffected.
verified end to end on the running server: the template listed, a commander created
from it, watch.sh + config.env (mode 600) + the brief on disk, config.env sourcing
cleanly, and the watcher refusing to start when config.env is absent — which is the
configure-before-running rule enforcing itself at the last line of defence.
still to come: the Command HQ section that shows the offers and draws the form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e4f5e03105 |
a plugin can offer a commander, and offering is not starting
first half: the discovery and the two columns. no routes and no UI yet. PRESENCE IS THE DECLARATION. a plugin with commanders/<name>/COMMANDER.md offers one, and manifest.ts says nothing — same as tasks/ and skills/, and for the same reason: the directory already says it, and a second place to say it is a second place for the two to disagree. read in place through capabilitySources, so only an installed AND ENABLED plugin offers anything and plugins:update changes a template without republishing. A TEMPLATE IS NOT A COMMANDER. what a plugin ships is an OFFER: nothing runs, nothing is scheduled, no directory exists until an owner creates one from it. that is the shape asked for — a plugin may propose recurring work, only a person may start it. `plugin` IS A COLUMN, not a reserved category name. provenance should be structural: a category called "av-club" is a string an owner can rename, merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones with nothing on disk saying otherwise. it also survives an uninstall, which is the point — the commander is still there and the UI can say where it came from. `plugin_config` holds the owner's answers because the form has to be RE-OPENABLE. somebody changing which directory is watched should see the one it is watching, not an empty form. the watcher script is generated from this, so this is the source of truth rather than the script. inputs reuse TASK.md's vocabulary exactly, so the existing form renderer draws them and the existing validation applies. "which folder, which extensions, what output" is the same question a task asks. `missingInputs` is the configure-before-running rule, and it is checked at ARM time rather than at creation. a half-configured commander that exists and is visible is useful; an ARMED one is what would run somebody's disk against a blank setting. columns verified live after db:push: plugin text, plugin_config jsonb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
3b0083aec2 |
deploy report for 2026.08.29.01 — 7 of 7, sharp loads everywhere
Every target host ended at the tag with libvips 8.18.6 and ffmpeg present; macbruno gained ffmpeg via the plan's per-host brew step. Schema unchanged and verified as such, so no db:push ran anywhere. One deviation: plugins:update runs before bun install in the standard steps, and it imports the server graph, which now reaches thumbnails.ts -> sharp. That fails on any host where sharp is genuinely new. Ran install first instead — same steps, other order — and recorded it as a finding for the next plan rather than editing the prompt mid-rollout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
58f50a5240 |
release 2026.08.29.01 — sharp becomes infrastructure
every other deploy this month was code. this one changes what a machine must have,
so the plan has two verification steps no previous plan needed and one host gets a
command none of the others do.
SCHEMA UNCHANGED, and the plan says so with the command that proves it rather than
the claim on its own:
git diff --name-only deploy/2026.08.28.01..HEAD | grep -iE 'schema|/db/|databases'
returns nothing. no db:push step exists and none should be improvised. the last
time a plan of mine asserted this without checking, `dav_app_passwords` had moved
to a plugin and a push would have dropped it on six hosts.
BUN INSTALL IS REQUIRED, not optional — package.json and bun.lock both moved. and
it gets a SEPARATE verification step, because sharp does not compile, it downloads
a prebuilt libvips for this exact platform and arch: the install can succeed and
the module still be unusable, and the symptom is not an error anywhere. it is a
file browser drawing generic icons over a folder of photographs.
FFMPEG IS CHECKED because it was never declared. it has been a hard dependency of
the file browser since long before thumbnails — subtitles, audio extraction, audio
tracks — and worked because every machine was set up by hand. surveyed all seven
hosts today: present everywhere except macbruno, which is the one per-host command
in the plan.
fofos already runs this code and was the test host before the tag. it found a real
bug that only a deploy could: `.ts` was in the video extension list, so every
TypeScript file in every repository drew a play badge and spawned an ffmpeg.
known and NOT fixed here: one test fails, ModelSelector's default selection. it is
in chat code this release does not touch, the file is unchanged across every commit
in this range, and it last moved in
|
||
|
|
43a4463978 |
the platform skill generator emits an identifier, not a label
c24859c fixed every skill's `name` to be lowercase-hyphenated and equal to its directory, with the human name moving to `title`. it fixed the FILES. this generator writes one of those files and was missed. so the file on disk was correct and running `bun run skills:platform` put the old shape back — which made `docs:check` fail on a line count that could not be fixed by regenerating, the worst possible place for a drift check to point. found while cutting a release, because the procedure says leave docs:check green and it was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
de425d9d68 |
.ts is TypeScript here, not a transport stream
caught by deploying: fofos reported `test-setup.ts` as a video. `.ts` is a real video extension — MPEG transport stream — and it was in the list for that reason, but on any machine Officer runs on it is TypeScript approximately always. every source file in every repository was drawing a play badge and spawning an ffmpeg that could only fail. the two ways of being wrong are not the same size. excluded, a genuine transport stream shows a file icon; included, browsing a source tree runs a decoder per file and negative-caches the lot. nobody keeps broadcast captures in a folder they browse from here. m2ts and mts added while in there — the container people actually have, from a camera rather than a tuner, and neither collides with anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22a4c65776 |
sharp is a declared dependency, and setup checks both it and ffmpeg
sharp does the images now; ffmpeg keeps the video. each does the job it is for — sharp is libvips in-process, no process to spawn and no pipe, which is an order of magnitude less work for the common case of a folder of photographs. a video has no frame until something decodes one, and that is ffmpeg's and nobody else's. added through the procedure bunfig.toml describes, since there is no --no-frozen-lockfile flag in bun 1.3.14: package.json edited, frozenLockfile commented out for ONE command, lockfile diff read, setting put back, and a frozen install confirmed clean afterwards. WHAT THE LOCKFILE DIFF ACTUALLY SAID, because the friction is only worth anything if somebody reads it. everything is inside sharp's tree: sharp 0.33.5 -> 0.35.4 at top level, @img/colour added, and the per-platform libvips binaries moved 1.2.4 -> 1.3.3. two SHARED packages moved with it — semver 7.7.4 -> 7.8.5 and @emnapi/runtime 1.8.1 -> 1.11.3 — both forced by sharp's own ranges, and both previous consumers (next, editorconfig) kept pinned copies of what they had. next keeps its own sharp at 0.33.5 entirely. nothing outside that moved. ^0.35.3 to match what av-club declares. 0.x caret pins the minor, so ^0.33 and ^0.35 do not overlap — the plugin intersection would have resolved two native libvips trees onto one machine for no reason. THE IMAGE LIST GREW: libvips reads HEIC and SVG and ffmpeg does not, and neither is exotic — HEIC is what every recent iPhone writes, and a folder of them drawing generic file icons was the most visible gap this had. `failOn: 'none'` and `limitInputPixels: false` are both deliberate. libvips rejects a file with any warning in it, and a truncated JPEG — an interrupted copy, a phone that died mid-write — is exactly that; refusing it would make this browser stricter than the viewer somebody opens the file with. the pixel ceiling is a DoS guard for an upload endpoint, and these are the owner's own files already on the owner's own disk: a panorama is not an attack. SETUP, both halves: ffmpeg was never in the package list at all, on any of the four package managers, despite this router shelling out to it for subtitles, audio extraction and audio tracks long before thumbnails existed. it worked because every machine set up by hand already had it. now declared. sharp gets a LOAD CHECK beside node-pty's build check, and reported separately from it. same failure shape — an install that succeeds while the native module does not — but the opposite cause: node-pty compiles and can fail to, sharp downloads a libvips binary for this exact platform and arch and can fail to resolve one. a combined "native modules" verdict would send somebody to rebuild the wrong thing. loading it is the only test worth running; a directory proves nothing, and `versions.vips` proves libvips is bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0d310ab7b0 |
the file browser draws pictures, in both views
images and videos get a thumbnail in grid and list. one <Thumbnail>, two sizes — two implementations would have drifted the first time one learned something the other did not, and the fallback is the part that would have drifted. FFMPEG FOR BOTH, and no new dependency. a video needs a frame extracted, which only ffmpeg does; an image only needs resizing, which sharp does faster and with better format coverage. sharp is nevertheless not used: it sits in node_modules as somebody else's transitive dependency and is absent from package.json, so importing it is an undeclared dependency — the thing the frozen lockfile exists to prevent — and declaring it is a deliberate --no-frozen-lockfile that belongs to whoever owns the dependency list. ffmpeg is already a hard requirement of this router, so this adds nothing to what the machine must have. the cost is HEIC and a few exotics, which fall back to the icon. THE CACHE KEY CONTAINS MTIME AND SIZE. a path alone would be wrong: replace a photo under the same name and every screen keeps the old picture with nothing to clear it but somebody who knows the cache exists. keyed on content, a changed file is a different key — the old entry is orphaned rather than stale, and the response can be `immutable` honestly rather than as a bet. FAILURES ARE REMEMBERED. the browser retries a broken <img> every time it re-enters the viewport, so without this one corrupt video runs ffmpeg on every scroll, forever. in memory, so a restart gives it another chance after somebody installs a codec. `ls` DECIDES what can be thumbnailed, not the client. the browser's own NATIVE_VIDEO_EXTS is deliberately only what a <video> tag can play, which is a different question from what ffmpeg can take a frame out of — every .mkv would have gone without a picture for that reason alone. the entry has just been stat'd, so the answer is a lookup on its name. FOUND WHILE TESTING, and the reason there is a test folder: ffmpeg EXITS 0 HAVING WRITTEN A ZERO-BYTE FILE when the seek lands past the end of the input. a 0.4s clip gave exit 0 and 0 bytes with `-ss 1`, and exit 0 and a real JPEG without it. the retry keyed on the exit code and so never fired, reporting every short video as having no thumbnail. it keys on the output file now — there is one question worth asking an image encoder and it is whether there is an image. four ffmpeg at a time. the grid is virtualised so only what is on screen is asked for, but scrolling fast through a folder of video queues far more than is visible, and unbounded decoding turned a 1.9GB box into a swap storm once already. size is snapped to 64px steps so a panel resizing by a pixel cannot mint a hundred entries of one image. verified: 240 and 256 share a cache entry, 200 gets its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
16fa869260 |
the dock rounds its tiles; now it clips them too
reported as a plugin whose dock tile had square corners while every other one was rounded, and the expectation was right: the rounding IS dock infrastructure and has been there all along. it was simply never reaching the artwork. the box is `rounded-xl` and the image inside it is not clipped. a `badge` tile insets its glyph to h-7, so the corners are empty and nothing looked wrong for as long as every plugin used one. `bare` fills the box edge to edge — so the first plugin to ship a logo on an OPAQUE background drew its own square corners over a rounded box, and became the one tile in the row that had not been told the shape. `overflow-hidden`, in the dock and in spotlight, which had the identical bug a few pixels away. it fixes every bare plugin rather than this one, and it is a no-op for badge tiles, which is why it belongs on the box and not on the image. the plugins list drew the same artwork with no rounding at all while the grid beside it used `rounded-xl`. rounded to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
3d936309a5 |
the list refreshes before the message, and the tab follows the result
reported as "the list does not update without a refresh". the counts were in fact correct, which is the more interesting half: fofos-admin was ALREADY INSTALLED on that host, so the fetch put it in Installed while the confirmation said "click it to install" and pointed at an empty Available tab. right numbers, wrong story. three fixes, and only one of them is the refresh. AWAIT THE REFETCH. `invalidateQueries` is fire-and-forget, so the confirmation appeared while the grid was still rendering the previous answer and the plugin turned up a moment later. that reads as "nothing happened" and sends somebody looking for a refresh button — which is exactly what happened. SAY WHICH THING HAPPENED. a directory and an install row are separate facts: deleting a plugin's source leaves the row, so a re-fetch can land on something the platform still considers installed. the route now reports it, and the message is either "added — click it to install" or "added — already installed". FOLLOW IT TO ITS TAB. if it came back installed, the overview switches to Installed rather than leaving somebody staring at the tab it is not in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
9a70089d6b |
a Marketplace section, and the git field lives where its result appears
a new row above the tags, and where the store now opens. the tags say what a
plugin is FOR; this says what state the machine is in, which is the question
somebody opening a store asks first — narrowing by kind is what you do after.
THREE TABS, because there are genuinely three states and the old grid had two.
`Installed` is what runs. the other two are both "not installed" and are not the
same thing:
Official the catalogue — somewhere else, a download away.
Available already ON THIS MACHINE, not installed. where a plugin fetched from
a git URL lands, and where one deleted-but-not-purged sits.
collapsing those hid the entire point of fetching from a repository: you add
something and it is indistinguishable from twenty things you have not downloaded.
it opens on Installed rather than the catalogue. a store that opens on what it
can sell you is selling; this is mostly where somebody checks on something they
already have.
THE GIT FIELD MOVED INTO THE AVAILABLE TAB, which is the tab its result appears
in — and only shows there. floating above a filtered grid it was an action whose
outcome landed somewhere off screen, which is how the first version installed a
plugin with no feedback at all. same mistake, one layer up.
the tag grid is untouched and still what a filter shows. two views because they
answer different questions: "what have I got" wants installed-versus-not, "what
is there for my calendar" wants everything of that kind whatever its state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e2d03394e1 |
the marketplace nav, in the house section style
taken from fofos-admin/web/NavPanel.tsx, which is the pattern from here on. three changes, each doing a job rather than being a preference: SEPARATION. `border-b border-border` on every row, replacing gapped pills. a gapped list leaves the boundary between one item and the next to guesswork; a ruled edge says where a row ends. THE LABEL IS NO LONGER MUTED. it was `text-duck-dark/70` — every row whispering, with nothing for the eye to land on. the muting moves down to the second line, where a count genuinely is secondary. THE COUNT MOVES OUT OF THE RIGHT-HAND COLUMN. it was a number at the far edge at 60% opacity, which is where the eye goes last. as the row's second line — "3 plugins" — it is read WITH the label rather than hunted for, and it gives every row the same two-line shape the fofos panel has. active is `bg-muted`, a filled row, rather than a tinted label. colour on the text was carrying both "this is a link" and "this one is selected", which is why nothing looked selected. the footer moved to the same border and the same `muted-foreground` token rather than the duck palette, so it sits under the rows instead of looking like another kind of thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
cac58ea044 |
the git-repo button FETCHES, and the card does the installing
it installed, and using it showed why that was wrong: the button is on the grid, the progress log is in the detail panel — so a URL install ran to completion with nothing on screen and the plugin simply appeared, already installed. no feedback, and no moment to change your mind about running somebody's code. cloning alone is enough to make a plugin real. `/api/plugins` lists what is ON DISK, so a fetched plugin shows up with `installed: false` — Available, exactly like a catalogue entry — and the existing card, its Install button and its streamed progress do the rest. one install path, and it is the one people have already used. so `install-from-url` and its streaming twin are gone, replaced by `fetch-from-url`, which clones and stops. the streaming route existed only to carry progress into a view that could not render it. the outcome is REPORTED, since that was the complaint: "fofos-admin added — click it to install", naming what the URL resolved to. the name is derived rather than typed — appName IS the directory, deciding the route, the table prefix and the process name — so being told what it became is the only way to know which card is yours. it stays on screen until the next attempt rather than being a toast that is gone before you look up. discovery is re-run after the clone: fetchPlugin checks the manifest EXISTS, discovery checks it parses. a directory failing that still renders in the grid as broken, which is honest, but saying so at fetch time reaches the person while they are still looking at the field they pasted into. verified: fetch 200 with the derived name, the plugin listed installed=false, a second fetch refused with "already on disk" rather than clobbering it, and the icon present in the clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
5c6c5b31f8 |
"Install from Git repository", in the marketplace
a button beside the plugin count that becomes a URL field. everything after it is the machinery that was already there: same installPlugin, same step and log frames, same progress panel, same result. STREAMED, because that is what "works exactly like it already does" means here. an install writes an ecosystem entry, starts a process and rebuilds the router; answering once at the end is a spinner that stops, with no way to tell "started the sidecar" from "could not". the frame reader in usePlugins was split out rather than copied — it never cared which URL it read from, and two copies of an incomplete-frame parser is two places to get it subtly wrong. A TWIN ROUTE, not a case inside the verb map. every other verb names an INSTALLED plugin in its path; this one names something the machine has never seen, and its subject arrives in the body. folding it in would mean a route whose `:appName` is ignored for exactly one value of `:verb`. NO NAME FIELD, deliberately. appName IS the directory a plugin sits in — never declared in a manifest — so it decides the route segment, the table prefix and the pm2 process name. a box for it would invite a name that disagrees with the address. it is derived server-side and reported as the FIRST streamed step, which is the moment it can still be cancelled. collapsed until asked for: a permanent URL box at the top of a catalogue reads as the primary way in, and it is the exception — for somebody who already has an address. verified end to end against fofos/fofos-admin: 30 frames, every step from clone to mount, `assets: published to /plugins/fofos-admin/` now that it carries an icon, and the icon served at 256x256 with alpha intact. purged afterwards — officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d273dc6a14 |
install a plugin from a git URL, with no registry involved
the catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" had no route. the first real case is a plugin that must never be listed: fofos/fofos-admin is one website's admin panel, useful to exactly one person. MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the URL, refused to overwrite an existing directory, checked for a manifest and removed a failed clone. exactly one line tied it to the marketplace — where the string came from. so this is a source parameter threaded through, and a route. THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is never declared in a manifest — the id cannot disagree with where the code sits. that decision is inherited here rather than remade: the repo name becomes the directory, the route segment, the table prefix and the sidecar suffix. so it is checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone under an unusable name reads as a broken plugin rather than an absent one. the URL is validated identically whether it came from the catalogue or from a person, because the catalogue is not a trust boundary — it is just where a string came from, and the same string reaches the same argv. what differs is who may ask: this route is owner-only, and the owner is choosing what code runs rather than choosing from a list somebody curated. validation is not review, and nothing here reads what it cloned. caught by testing: the source reached installPlugin and stopped there — the fetchPlugin call still had the old two arguments, so a URL install fell back to the catalogue and failed with "no catalogue entry". the guard tests are what surfaced it, because the error named the wrong cause. verified end to end against fofos-admin — cloned, schema applied, mounted, dock pinned — then purged, since officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
4e0a065f0e |
a host can drop a core sidecar it does not use
`OFFICER_DISABLED_SIDECARS` in .env, comma separated, filtered out of the generated ecosystem file. core processes are a fixed array because the platform's own features depend on them. that is still true — what is not true is that every host runs every feature. fofos is a 1.9 GB machine serving a website, and officer-opencode sat on it costing 456 MB for a harness nobody there uses. it froze hard twice today, and the tell is what is MISSING from the journal: no OOM kill, no panic, no shutdown — the log simply stops mid-session and resumes at the manual reboot. that is memory exhaustion with no headroom left to kill anything cleanly, on a box with 1.9 GB and a 2 GB swapfile that was already 440 MB into swap one minute after boot. .env rather than a database setting, and that is the load-bearing part: this file is regenerated during a plugin install, so a host that dropped a process needs it to stay dropped across one. per-host is also exactly the scope of the decision. not validated. naming `officer` or `officer-pty` will break that host, and which processes are safe to drop depends on what the host is for — which this script cannot know and should not guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f4685eeafe |
one plugins directory, because the second one was symlinks
`officer-dev/plugins/` is gone. it was created to hold every repo in the org side by side, and by today it held 22 symlinks into platform/plugins, one duplicate clone of a plugin that is installed here, and a README describing a state that had already been fixed. it could not have been more than that. a plugin cannot live outside the platform: bun resolves a symlink to its real path, so from there `@@/sidecar/create-proxy` and every relative import fail. tested both, plus `--preserve-symlinks`, which bun 1.3.14 accepts and ignores. WHICH MATTERS BEYOND TIDYING, because marketplace-sync leaned on the distinction. its comment said the second directory was "where a release is actually made", so publishing from it would advertise released versions rather than installed ones. that was never true — symlinks meant both paths read the same working copy. the bug it describes (21 plugins tagged, the store still showing 1.0.0) was never addressed by the thing built to address it. the honest fix is in the comment now: each plugin directory IS a git repo with its own tags, so the released manifest can be read out of the tag — `git show <tag>:manifest.ts` — without a second checkout. not written yet, and said plainly rather than left implied. finances is deleted, at the owner's request: not installed, no pm2 entry, no ecosystem entry, no plugin_installs row, no tables. both clones were clean and pushed. the gitea repo is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
87d1740caf |
deploy/2026.08.28.01 — seven hosts, one process each, and a probe that had to wait
First release under the new watcher loop. 7 of 7 target hosts to 9c8af9d4; officer-dev skipped as the host running the rollout. No db:push and no plugins:update, per the plan — so officer was the only process that moved anywhere. Route probe returned 401 on all seven; three answered 000 first because pm2 returns before the server binds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9c8af9d424 |
deploy/2026.08.28.01 — sign in as a user
two commits, one feature, and a plan that leaves things out for stated reasons. NO SCHEMA, NO DEPENDENCY, NO PLUGIN CHANGE — checked with git rather than asserted, because asserting it is exactly what sank 2026.08.27.01. db:push is deliberately not a step. yesterday's report established that it prints "Changes applied" when it applies nothing, so its output is not evidence, and on this fleet it re-applies a default on command_hq_sessions.reference_paths every time plus churns pk_music_now_playing on two hosts. running it here would be noise against a database this release does not touch. so the rollout is a merge and one restart. the verification step is an unauthenticated POST to the new route: 401 means mounted, 404 means the bundle did not move — a check that works without a session, on an owner-only feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fb0cc880ac |
sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for me". reproducing that with a password reset means locking somebody out of their own account in order to look at it, which is worse than the bug. THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and reaches every handler, because user-middleware puts the whole payload on the context. without it an impersonated action is indistinguishable from that person's own — in a log, in a transcript, in anything keyed on a user id — and a support tool that can forge activity as somebody else is a different and much worse thing than a support tool. ONE HOUR, not thirty days. a session lasts a month because a person uses their own account daily; impersonation is a task you finish. an hour is long enough to reproduce a problem and short enough that a forgotten tab stops being a live credential for somebody else's account by lunchtime. IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any token carrying `impersonatedBy`, so impersonating another Super Admin does NOT hand user management back — which would have made this a way to launder owner actions through somebody else's id. stated twice, in the gate and in the handler, because one file away is too far for this one. THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an owner session — a far more dangerous thing to own than this whole feature. the cost is that clearing browser storage strands you as the other person until the hour expires, and signing in again fixes that. the banner is loud and sits ABOVE the header, outside everything a fullscreen panel can hide. the failure it prevents is not a hole, it is forgetting: reading a mailbox or deleting something while believing it is yours. it asks the server who it is rather than decoding the token, so it cannot keep showing a name after the hour has run out. verified end to end against a throwaway account, since this host has one user: impersonate 200 with the target's id and impersonatedBy naming the owner, 60 minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining 403, user management while impersonating 403; one [SECURITY] line per start. the probe account was deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
02ad4d3bdf |
deploy/2026.08.27.02 — seven hosts, and a push that lies about applying changes
Every target host is at the tag. The one destructive step ran on six of them and was guarded by a count each time; alpha kept its table and its 8 rows. The finding is that `bun db:push` prints "Changes applied" unconditionally — it said so on an unchanged database, twice in a row. `--verbose` shows the truth: what it re-applies everywhere is pre-existing composite-key drift, and nothing touches dav_app_passwords on any host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
91147b403b |
deploy/2026.08.27.02 — a plan with commands, and a schema claim that is checked
2026.08.27.01 was REFUSED before a single host was contacted, and the commander was right twice over. its plan described the release instead of instructing the rollout — no step list at all, where deploy/README.md defines a plan as "what to run, in order, on which hosts" and steps 4 and 5 of the procedure are plan-driven. and it asserted "nothing here changes a schema" on the day dav/schema.ts was deleted out of core. that second one was not a formatting mistake. dav_app_passwords now lives in plugins/caldav, and gen-plugin-schemas follows plugin DIRECTORIES — so on a host without that directory the table is in the database and not in the schema, and `db:push` proposes dropping it. on alpha that is eight device credentials. so this plan answers it from a survey rather than a guess. alpha has the plugin and 8 rows; the other six have no plugin and an empty leftover table; zero hosts run officer-marketplace, which retires the `tags` column question entirely. the orphan table is dropped EXPLICITLY, guarded by a count, and skipped on alpha — never as a db:push side effect. step 6 is new and would have been missed: officer-opencode has to restart or none of the skills, agent-identity or credential work in this release does anything. ALSO THE SIGN-OUT. a single 401 from any endpoint wiped the token from both storages and reloaded — which signs the owner out of every tab and keeps them there, since nothing is left to recover from. that is the right response to a revoked session and far too violent a response to one endpoint answering 401 for its own reasons. it now confirms against /auth/me first, and treats an unreachable server as emphatically not a revocation: rebooting the box must not end the session. reported mid-demo, with nothing in the logs and a freshly signed token working against every route the app loads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
ec6c793143 |
deploy/2026.08.27.01 — the plan, and the version to match it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
421e742590 |
a dock tile for /bots, which had a screen and no way in
the route, the screen and the list have existed since bots did — `/bots` and `/bots/:sessionId` are both in App.tsx. what was missing was any way to GET there: no entry in CORE_DOCK_ITEMS, so it could not even be added to a dock, and no `nav.bots` label in any of the six locales. placed beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing. left as "Bots" in all six locales — a borrowed technical noun, and inventing translations for it would be worse than not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e805c3607 |
the new wordmark in the header, following the theme
two marks, same 1062x220 crop: white "Off" for a dark background, dark "Off" for a light one, yellow "icer" untouched in both. the dark variant is the light one with only its DESATURATED pixels inverted, so the yellow is bit-identical and the alpha and dimensions never move. CLASSIFIED BY LUMINANCE, not by the mode comment. only 22 of the 227 theme files declare one, and the cost of guessing wrong is a white wordmark on a white bar — so each theme's own `--background` is read and thresholded. 203 dark, 24 light. the signed-out screen is deliberately untouched and still uses the inline SVG. AND THE BUG THIS UNCOVERED, which was not about logos at all. `useThemeAsset` read the computed style inside an effect, and react runs CHILD effects before parent ones — every consumer is a descendant of `ThemeProvider`, which sets `data-theme` in ITS effect. so the read always saw the PREVIOUS theme's value: switch themes and the logo was one theme behind, switch again and behind again. it reads as a race and is deterministic, which is why it reproduced every time somebody watched for it. the read moved into requestAnimationFrame — past every effect in that commit and past style recalculation — plus a MutationObserver on data-theme/class for the case a frame cannot cover: a theme installed at runtime arrives as a <link> that loads whenever it loads. the wallpaper uses the same hook for `--page-bg-src` and had the same lag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
bf8bfa6859 |
start a commander from a plugin chat, without knowing where anything lives
pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.
every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.
THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.
it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.
caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.
verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
9ec60d6d75 | caldav 2026.08.27.03 — filters on calendar and contacts | ||
|
|
15392ae6c2 |
preview a commander's files, the way /files does
the commander file panel could list a directory and not open anything in it. it already mounts the same `FileBrowserApp` as /files — what it did not mount is the VIEWER, which on /files is a separate ephemeral panel and not part of the browser at all. THE PARAM COULD NOT BE READ, which is why this is more than mounting a panel. on /files the folder is in the PATHNAME, so `?view=notes.md` is a name inside it and that is all it needs to be. a browser mounted anywhere else keeps its folder in panel-local state, so the same name is unresolvable by anything except the component that wrote it — the commander panel was writing `?view=notes.md` with no reader able to tell which of its folders that meant. so off the files route the param carries the whole path, and `viewRoot` with it: a panel that overrides the root is exactly the case where "home" is the wrong guess. `viewRoot` joins VIEWER_PARAMS so closing an overlay clears it — a stale root outliving its view would send the next file to the wrong tree. the split is VERTICAL where /files is horizontal. this panel is already the narrow half of a screen whose other half is a transcript; splitting it sideways again leaves two columns too thin to read anything in. closing the file clears the overlay params by NAME rather than resetting the query, because `files=1` — the thing keeping this whole panel open — lives in the same string, and the obvious version closes the browser along with the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
02f20d419d |
an opencode chat you can reopen: it has a name, its messages, and its model
three complaints, one root cause and two small ones. THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message` and this build answers that with `[]` — 200, well-formed, and wrong. the same session returns 4 messages on `/api/session/<id>/message`. an empty transcript is indistinguishable from a new conversation, so reopening a chat said "send a message to start" and every message the owner had sent looked deleted. that also explains the third complaint. the model was read from the assistant turns, so no messages meant no model, so the detail reported a bare `opencode`, which the composer cannot match — and it fell back to claude code and sonnet. it now also reads the model off the session record, which is populated before any assistant turn lands. the message shape moved with the endpoint: `type` where it was `info.role`, `content` where it was `parts`, and a user message's text sits on the message rather than in a part. both shapes are accepted rather than pinned to today's build — this reader is the only thing between a working transcript and a chat that looks erased, and a host on a different opencode should lose features, never its history. AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing downstream re-sorted, so the answer sat above the question that produced it. sorted here, because this is the only place that still has timestamps. NAMING: opencode is meant to title a session from its contents and does not, on this surface — sessions stay at `New session - <timestamp>` through a finished turn and past it, which is why the list was a column of "(untitled)". `isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI. so the sidecar names a NEW session from the first line of its first prompt, which is what the claude side shows. a resumed session keeps what it has, and if opencode ever does title one it writes after us and wins. verified live on the session that prompted this: 4 messages, in order, model opencode/kimi-k3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
3b1d4cea84 |
permissions off wholesale, by every documented mechanism, plus a backstop
read the documentation instead of the binary, which is what should have happened
hours ago. it says three things this code was getting wrong.
- "permission": "allow" — a BARE STRING, not the object form. that is the
documented way to say "no prompting at all".
- most permissions already default to allow. the exceptions are
`external_directory` and `doom_loop`, plus `read` on `.env`. those are what
was actually stopping turns.
- an agent's own rules take precedence over the global config, and an agent
that declares none inherits the built-in defaults — so a global allow does
not reach it.
so: `permission: "allow"` in the serve config, AND the agent moved to the
markdown FILE form with `permission: allow` in its frontmatter. the file form is
what the docs recommend "for anything non-trivial", and it is the only place an
agent's permissions are honoured — the inline definition ignores both `permission`
and `prompt`, while accepting `description` and `mode`, which is the worst kind of
partial.
WHAT I COULD NOT MAKE TRUE. with all of that applied, the serve still reports
`external_directory: ask`, `doom_loop: ask` and `read *.env: ask` on the resolved
agent. i cannot get that list to come back clean, and i am not going to guess at
it again.
which is why the event-stream approver stays and is the thing actually doing the
work: whatever opencode asks, the platform answers `always`. that is the analogue
of the claude harness's `bypassPermissions` and it does not depend on predicting
the shape of the next gate. the config above is the belt; that is the braces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
3082949fac |
answer opencode's permission prompts, because nothing else can
the spinner had a cause and it was none of the four things fixed before it. the
serve was holding a permission request:
{"action":"skill","resources":["calendar-and-contacts"],"source":{"type":"tool"}}
opencode asks before loading a skill. in its own terminal a person answers. here
it goes to a UI that has never heard of it, and the turn does not fail — it SITS
THERE. no error, no log line, nothing in the transcript. three models were blamed
for it, and a paid provider, and the skill itself.
replying `always` by hand released it instantly: the skill loaded and the agent
went straight to the curl the skill describes. so the whole chain was correct and
one unanswered question was holding all of it.
CONFIG IS NOT ENOUGH, and this is the part worth keeping. the serve's config
carries `permission: {skill: {"*": "allow"}}`, the agent's own ruleset reports
`skill * allow`, and it asked anyway. answering is the only approach that does not
depend on guessing the shape of the next gate correctly — and there will be a next
gate, because `external_directory` was the last one.
not a widening: the claude harness runs `bypassPermissions` deliberately, because
CLAUDE.md is explicit that an unsandboxed agent on the owner's own machine is the
point. this is the same stance for the other harness. `always` rather than `once`
so a repeated tool does not re-ask every turn.
when opencode reaches members this becomes a real decision. it is owner-only today
for larger and unrelated reasons, and it goes on the list with them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
c226e8a46d |
copy the skills, do not link them — a link is what made it hang
the agent called `skill calendar-and-contacts` and sat there. not an error, not a refusal: a spinner, on three different models. opencode grants `external_directory` for the paths it discovered — literally `~/.config/opencode/skills/<name>/*` — and a symlink RESOLVES to the plugin directory, which is not in that list. so reading the skill body fell through to `external_directory: "*": "ask"`, and a server session has nobody to ask. the request waits forever. the permission list the serve reports says it plainly: every skill path is allowed at its LINK location and nothing allows the target. copying makes the real path the allowed path. the cost is drift — a skill edited in its plugin is not live until this sidecar restarts — which is why the copy is rewritten from scratch on every boot, and it is a good trade against a hang with no message. copies are marked so a skill somebody wrote there by hand is never clobbered and ours are replaced rather than merged, so a file deleted upstream cannot survive in the copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f0801808c4 |
read the documentation: four things wrong, none of them the model
an opencode agent could not look at a calendar all afternoon. i treated it as a
framing problem, then a regression, then a free-vs-paid model problem, and it was
none of those. the documentation says all four, plainly.
1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match
^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a
human title there — "Calendar and contacts" — so every one was LISTED and none
could be LOADED. the agent found the skill, called
skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title`
is now a separate field and keeps the prose.
2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config
example and not in the docs, and the difference is exactly this: skills
configured that way are offered to the agent and cannot be opened. skills load
from fixed discovery paths only, so each directory is now SYMLINKED into
~/.config/opencode/skills. links, not copies — one source of truth, and a skill
edited in its plugin is live immediately.
3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed
without complaint and never applied. this is what made it look like a model
problem: the agent existed, the prompt was on its record, and the model had
never seen a word of it.
4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to
ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}.
also: the platform cached opencode's model list for the lifetime of `officer`,
so signing into a provider and restarting the SIDECAR left the old catalogue in
the OTHER process — six free models, no paid ones, nothing saying why. the cache
is dropped when the sidecar reports a new port, which is the one event that
always accompanies a new serve.
verified: the prompt reaches the model — asked who it is, it answered "Officer,
running on your home server at officer-dev under your user account". 10 skills
discovered through the linked path with loadable names. NOT verified end to end:
a turn that opens the calendar skill and answers from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
a873690e27 |
opencode gets an identity, not just a coding agent's
three turns in a row could not look at a calendar while holding the calendar skill, and each failure had a different cause. FIRST: opencode's built-in `build` agent opens with "You are an AI coding agent. Help the user accomplish software engineering tasks." asked about a calendar it reasoned, in its own words, "this isn't a coding task, and I don't have access to any calendar" and never called the skill tool. the index was in its context the whole time. the plumbing was right and the identity was wrong, which is the harder of the two to see. so officer defines its own primary agent — mode primary, `default_agent`, and named explicitly on session create because a default is a preference and this is a requirement. only NEW sessions: a resumed one keeps the agent it was made with. SECOND: setting a custom `prompt` traded one problem for the other. with the built-in agent the index was there and the framing was wrong; with ours the framing was right and the index was gone, so the turn went looking for .ics files in the home directory. `prompt` replaces the assembled system text rather than adding to it. the index is now compiled INTO the prompt by `skillsBlock()` — the same function a claude session uses — so both halves arrive together. ours also carries the PATH to each SKILL.md, which opencode's own block does not: a path can be opened with the file tools every harness already has. WHAT IS NOT FIXED, AND IS NOT THIS FILE'S FAULT. a third attempt still failed, and a diagnostic turn reported system instructions beginning "Tool infrastructure You can call" — not ours — and named a skill (`frontend-design`) that exists nowhere in this install. the only provider this machine can reach is opencode's own hosted gateway on free models; there is no auth.json here. so either the gateway supplies its own system prompt, or a free model is confabulating about its instructions. those are different problems and neither can be told apart without a model whose credential we own. verified: the agent is registered and its prompt is 4,717 characters containing "Calendar and contacts" and the SKILL.md paths; the serve's credential works (a tool process fetched the owner's collections); 10 skills discovered. NOT verified: that any of it reaches the model on this box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
e867fe72af |
notify one named account — and unshadow /broadcast, which never worked
"can you send a notification to George" had no answer. an agent key reaches its
own holder and nothing else, and the only route that spoke about somebody else
sent to EVERYONE. so the choice was the whole household or nobody.
POST /api/notify/user, ownerOnly, same authority and same reasoning as
/broadcast: everything else under this permission acts on the account asking, and
reaching another person's phone is administration however few phones it is. it
returns the target's email so the caller can see who was reached rather than
trusting an id they typed, and 404s an unknown account rather than letting the
sidecar's silence read as delivery.
AND THE PART THAT MATTERS MORE. /broadcast has answered 404 since the day it
landed. `createSidecarProxy` mounts `router.all('/*')` when it is CREATED, and
hono matches in registration order — so every route added to the proxy's own
router afterwards was forwarded to the sidecar, which has never heard of it.
three days, on a route whose entire purpose was to stop people POSTing to the
sidecar's ephemeral port by hand. a shadowed route does not fail like a missing
one; it fails like a sidecar that has not implemented it, so the 404 reads as
somebody else's problem.
caldav's router learned this on 2026-08-21 and wrote it down. this file now does
the same thing in the same words: the platform's own routes first, the catch-all
proxy mounted LAST.
verified: /broadcast now answers 400 to an empty body where it answered 404
before, /user delivers and reports the address, /_officer/devices still forwards.
found while trying to reach around the notify sidecar's producer secret to do
this by hand — which is exactly what that secret was added to stop, and the
comment above it says so. it stopped me. the route is the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e6da5d8c77 |
opencode gets the skills and a credential, which it had neither of
an opencode agent could not have used the calendar skill written this morning, and would not have failed usefully either — it had no idea the skill existed and no key to call the platform with. two gaps, one file each. SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking directories of <name>/SKILL.md — the same progressive disclosure claude does over the same layout the platform already stores. so this points it at the SAME FILES rather than compiling a second index in a second format. skills/load.ts says a skill is a path because every harness can read a file; this is the better version of that bet — the harness had the feature all along. verified: `/skill?directory=…` now returns all nine platform skills, "Calendar and contacts" among them. THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per session, so its key is per session and a member's resolves to their own grants. opencode cannot: ONE serve runs every session, so the only environment there is is the serve's own, minted once at sidecar start, belonging to the owner. that is sound today for exactly one reason — opencode is owner-only, enforced at every door in chat.ts because one serve runs as one user. when that lifts, this key becomes a privilege escalation. it is the same blocker as the two already there, not a new one, but it lives in a sidecar where nobody looking at chat.ts would find it, so chat.ts now names it. all three move together. verified end to end: a tool process inside an opencode session ran curl with $OFFICER_AGENT_KEY and got the owner's calendars back. OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc — it is inline json, it MERGES into the same chain as the config files (checked in the binary), and it dies with the process. the alternative is the platform editing a dotfile it does not own and leaving state behind that outlives the install. affordances ride the `instructions` key as one composed file, written beside the serve's state. loaded — `/config` shows it — but NOT model-verified: this box has no provider credential in auth.json, so a turn 401s before reaching a model. whoever adds one should check that first. the credential module moved out of sidecar/claude/. it was never claude-shaped; it was just there first, and it had one importer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0918dd7443 |
todo: what the calendar API deliberately cannot write
recurrence, alarms, attendees and EXDATE. all four read correctly and none can be authored — which is what the agent skill still drops to raw DAV for. filed with an order to do them in (EXDATE first: "cancel next Tuesday" is a thing the owner will say and the honest answer today is a refusal) and the reason the API stops where it does. new theme, because burying a calendar gap under plugin-repos is where it goes to be lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3fac1460cd | close the request-body todo: the proxy claims only what it forwards | ||
|
|
f00abe816e |
a sidecar proxy claims only the sub-path it forwards
registerProxiedPrefix tells bodyParser to leave a prefix's request bodies alone. that is correct for forwarded traffic and load-bearing for uploads: a parsed multipart body is re-serialised with a new boundary while the original content-type header is still forwarded, and every upload fails. but a plugin mounts its OWN routes under the same prefix, and those are not forwarded — they are served in-process and they need their bodies. claiming the whole prefix meant every field on them read as missing. caldav hit it the day it was extracted: POST /api/caldav/provision/ios answered "deviceLabel is required" for a request that carried one. its app passwords used to live at /api/dav, a second prefix that was not proxied, and one prefix per plugin ended that arrangement. so `proxiedSubPath` — caldav's sidecar answers `_officer/*` and nothing else, so it claims `/api/caldav/_officer` and the plugin's own routes are parsed normally. omitted means the whole prefix, which is right for every sidecar with no routes of its own, so nothing else changes. this affects any plugin mixing its own routes with a proxy, which is most of them. they have not hit it because their own routes are GETs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6762a32ea6 |
todo: turn a chat into a commander or a bot, carrying its context
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
68f3643df7 |
a pull leaves origin/master where it found it, so every checkout looked ahead
plugins:check reported "2 commit(s) NOT PUSHED" for all twenty-one installed plugins on a machine where nothing had been committed. the check was right about what it measured and the cause was here. `git fetch origin HEAD` writes FETCH_HEAD and nothing else. refs/remotes/origin/ <branch> keeps whatever it had, so after a successful pull the checkout sits at the newest commit while origin/master still points at an older one — and every `rev-list origin/master...HEAD` reports the checkout as ahead by however many commits it just pulled. so the remote-tracking ref is refreshed too, explicitly, after the fetch that cannot do it. best effort, because the local branch may not exist on the remote under that name — which is the same reason the fetch asks for HEAD rather than for a branch. fetch also asks for --tags now. tags are how releaseState decides whether there is anything to update to, so a fetch that skipped them left the Update button answering from refs older than the release it was offering. verified by staling a ref on purpose: one false positive before, none after a single pullPlugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0d5c2c0083 |
servers.md: the eighth machine, and the port that is not 9000
two facts that each cost time today and neither was written down. macbruno was missing entirely — 100.64.0.22, login brunoalcadapadezmonteirodaconceicao, ~/officerdev/platform, pm2 under Homebrew. nothing on the fleet knew it existed. and THE PORT IS NOT ALWAYS 9000. macbookpro and production-pertento-bridge run on 9010. a health check against 9000 on production hit one of Pertento's own apps, which answered 403, and that was read as officer failing after a deploy. while there: production runs eight processes that are NOT officer — Dashboard, Tracking, Uptime and five Crons. they are production-critical and belong to Pertento. anything sweeping pm2 list must filter to officer and officer-*. added a section on pm2 not being on the PATH of a non-interactive shell, because that read as "pm2 is not installed" three times in one day and twice sent someone diagnosing a healthy machine. zsh reads ~/.zshenv for non-interactive shells and neither .zshrc nor .zprofile, so an nvm or Homebrew install is invisible to an orchestrator. fixed at the source on the two hosts it bit; the section says how to check rather than assume. every row verified by reading the machine rather than recalling it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
b4cf1c6427 |
publish the catalogue from the release clones, and make the cards look like cards
three small things. marketplace-sync reads PLUGINS_DIR now instead of assuming the directory it sits next to. platform/plugins/ is what a machine has INSTALLED — updated by plugins:update, which is a git pull — while the release is made in the workspace clones. publishing from the install directory means the catalogue advertises the version this machine happens to be running: 21 plugins were tagged and the store went on showing 1.0.0 until this changed. the grid cards used bg-white/40. this app's surfaces are a DARKENING of the panel behind them — every other card and section here is bg-duck-dark/5 — so a translucent white read as a pale box floating on a dark theme. picking a colour instead of a token, and it only looked right against a background this app does not have. hover now moves the BORDER and nothing else: a background swap across twenty-one cards makes the whole grid twitch as the pointer crosses it. the tag pills were the same value as the card surface, so they rendered as text with no chip at all. and the marketplace todo said the UI was not started. it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8705dc3a7c |
todo: a server registry for terminal ssh and agent awareness
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c14b686782 |
pm2 save when the process list changes, and a check that does not lie about symlinks
the owner asked whether anything runs `pm2 save` when a sidecar is added or removed. nothing did. PM2 resurrects from ~/.pm2/dump.pm2, written only by `pm2 save`, so the dump was whatever a human last happened to save. today it was FIVE DAYS old: 13 processes saved against 26 running. a reboot would have silently lost every sidecar installed since — bitcoin-wallet, desktop, gitea, invoices, iris, jellyfin, minesweeper, music, photos, soulseek, transcribe, transmission, vault. thirteen features gone, machine healthy, nothing saying why. saved after start AND after delete. removal matters as much: a dump that keeps resurrecting a deleted sidecar brings it back after a reboot as a crash loop for code no longer on the machine. a RESTART does not save — the same processes are running and the dump is already right. best effort and never fatal: a failed save leaves the estate correct and only the dump stale, which is where things already were. failing an install over bookkeeping would be the worse trade. separately, plugins:check filtered directories on the DIRENT's isDirectory(), which answers false for a symlink to a directory. platform/plugins is symlinked now, so the check would have reported "0 plugin repositories, all well-formed" — a green check over nothing, which is worse than a red one. it stats the path instead, the way discover.ts already does. dump saved once by hand as well, since five days of drift was already there: 26 saved, 26 running, no difference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
68d6dd942e |
update means something now: it depends on tags
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.
versions are dated and tagged now, so the rule the owner asked for is possible:
· a checkout NOT at a tag is always updateable — it is between releases, no
claim is being made about it, pulling is always reasonable
· a checkout AT a tag is updateable only when a NEWER tag exists
the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.
it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.
the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.
"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.
verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58f58813c |
the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right. that is a fine control panel and a poor shop: twenty-one rows sorted by nothing anybody cares about, where "something for my calendar" means reading all of them. now: tags down the left with counts, cards in a grid on the right, and clicking a card takes over the panel with a back link. three things about the shape. BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a filter in component state is lost on Back, so filtering, opening a plugin and pressing Back would drop you in an unfiltered grid having lost your place. browser Back works for free for the same reason. selecting a tag also CLEARS the selection, because a detail for a plugin outside the new filter is a page the grid you are now looking at cannot return to. the detail REPLACES the grid rather than sitting beside it. a grid needs the width and a detail beside one gets half of it; neither reads well. the back link drops ?selected= and keeps ?tag=, which is the entire point of putting the filter there. the panel KEYS are unchanged though both components were replaced. a stored layout names them, and renaming would drop everyone back to the default arrangement for nothing — the key identifies a slot, not an implementation. two things found on the way. PluginItem and AvailablePlugin had no tags, so the api now carries them on both branches — from the manifest for a plugin that is here, from the catalogue for one that is not, same field either way. and the screen used useDashboardState, which persists the whole tree: the panels this ships would never have reached anyone who had opened it before. useAuthoredLayout takes the structure from source and persists only sizes, which is what an authored screen wants. 21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one rows, and the width is worth more to the grid. verified: bundle contains the new panels, api returns tags for all 21 with none missing the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
276b12607a |
every brief the platform writes, where both harnesses will find it
opencode discovers AGENTS.md and nothing else — its only target is
H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers
CLAUDE.md. no overlap.
every brief the platform wrote was CLAUDE.md, so under opencode a commander got
none of its standing brief, a bot none of its arming instructions, and a plugin
chat none of its plugin's guidance. nothing failed and nothing was logged: the
agent simply knew nothing, which is indistinguishable from the feature not
existing, and is why it lasted this long. commanders can already run under
opencode, so this was live.
writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a
pointer rather than two copies because these are rewritten every turn, two
copies of that will eventually disagree, and the stale one is the one somebody
reads.
three writers, and the third needed a flag the others must not have:
bots rewritten every turn ON PURPOSE, so a template change reaches them
plugin chats same
commanders onlyIfAbsent — a commander EXTENDS this file over its life, and
rewriting it when the template changed would delete the job it
had agreed with the owner
two things that would have quietly defeated it. the file listing handed to a
commander said "CLAUDE.md — this file", which would have had it editing the
pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a
new commander means burying a credential section inside six lines nobody reads —
it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old
commanders still work.
plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now
chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its
own repository on its own release cadence and dropping the old name would
un-brief any installed copy that had not updated — silently, again.
tested: both files written, pointer is not a second copy, idempotent, rewrites
on change, and onlyIfAbsent leaves an extended brief alone. verified on disk
after a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
1c28a48299 |
the check can look at either plugins directory, and knows what is not a plugin
there are two now. `platform/plugins/` is what this machine has INSTALLED; `officer-dev/plugins/` is every repo in the org cloned side by side, which is where a plugin is worked on from now on. they hold different sets, so the check has to be pointable rather than assuming the one it sits next to. and it skips a repo with no manifest.ts instead of reporting it broken. `av-club` is the case — a standalone media service in the plugins org, meant to become a plugin in time. checking it against a plugin's conventions would flag its branch name as a defect for ever, and a check that cries wolf is one people stop reading. presence is the declaration here exactly as in discover.ts: the manifest is what makes something a plugin, so its absence is what makes something not one yet. |
||
|
|
5d51212cca |
write down what today left unfinished
three, so none of it is re-derived: agents-md-remaining — the five doc pairs landed; the runtime writers did not, and that half is BROKEN rather than untidy. commanders, bots and plugin chats write CLAUDE.md, opencode reads only AGENTS.md, so a commander run under opencode is briefed with nothing and nothing reports it. names the three writers by file. the 21 plugin repos are marked as the owner's to lead — not to sweep unprompted. marketplace-browse-by-tag — data layer done, UI not started. carries the two questions that decide the shape: "All" as default, and whether the detail replaces the grid or sits beside it, which is master-detail vs list-then-page and contradicts how the screen is deliberately built today. calendar-skill-never-used-by-an-agent — every recipe was run BY HAND and works; no agent has ever read the skill and acted on a sentence. those are different claims and only one of them is tested. |
||
|
|
a865bcfdfc |
put back the orientation the sweep overwrote
|
||
|
|
d381140d54 |
plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful. TAGS. the marketplace is going to be browsed rather than scrolled: tags on the left, the plugins carrying one on the right. a plugin declares its own in the manifest and the catalogue caches it on sync, exactly as it already does for label and summary — the alternative is editing a table every time a plugin ships, and a plugin that cannot say what it is. the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab, home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms is worse than no filter. an unknown tag is refused at discovery BY NAME, so the author is told which tag rather than which array index. all 21 are tagged, each with a one-line note saying why, so a later disagreement is with a stated reason rather than a mystery. every tag has at least three plugins behind it — no filter opens onto an empty page. PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and none of the reasons were about the commit: six remotes were HTTPS and cannot authenticate unattended, three checkouts were behind their remote, one URL had no .git suffix so the catalogue published a clone URL unlike every other, and offscale's fetch refspec named a branch that does not exist. that last one is why this is a script and not a habit. every command reported on a phantom — `git push` said "Everything up-to-date" while the branch looked diverged — and the repository was fine the whole time. twenty minutes spent in a repo with nothing wrong with it. none of it is visible until you try to push, which is the worst moment to learn it: mid-task, across 21 repositories, and every failure looks like it is about your change. so it checks the facts with one right answer — remote URL, fetch refspec, branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a separate flag so running the check can never be what changes your tree, and divergence is never auto-repaired because it needs a decision about content. verified by breaking each case on purpose and watching it catch them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2360a3ef60 |
agents.md holds the instructions, claude.md points at it
opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the content living in CLAUDE.md, every doc here was invisible to opencode. Swaps all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the workspace-level officer-dev/CLAUDE.md check is untouched, out of scope. |
||
|
|
185aa3168e |
two todos from the caldav extraction
setting up a phone should be one link inside officer — open Calendar, the screen already knows this phone is not set up, click, done. the endpoint for it exists (POST /api/caldav/provision/ios) and nothing in the UI calls it, so the smooth path is built and hidden while the path a new person walks has four fields. it also does not currently work: the profile is refused by iOS 26.6 as "Invalid Profile". the todo records what was RULED OUT with evidence rather than reasoning, because that is the expensive part to redo — delivery works, the phone really received it (the claim is logged now), the server side is correct, and removing PayloadScope did not fix it. signing is untested and is the first experiment. second: a plugin with a sidecar proxy loses the request body on its own routes. createSidecarProxy registers the whole mount prefix and bodyParser skips it, which is right for forwarded traffic and load-bearing for uploads — but it covers the plugin's own routes too. caldav works around it locally; the real fix is the proxy claiming only the sub-path it serves, which is shared machinery and not a thing to change with a phone waiting. |
||
|
|
fdf50ce2a4 |
ship screenArtUrl, which the last commit left behind
|
||
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
e0df28ff5d |
a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a JWT: /dav, the ios profile under it, and the two .well-known discovery paths a client probes before it has any credential. the mechanism for that already existed — unauthenticatedRootPaths, exclusive claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and what that list was recording is worth being exact about: `dav` and `.well-known` are in it because the PLATFORM serves them today, not because they are dangerous in themselves. once caldav is a plugin, the thing the reservation protects IS the plugin, and keeping it would mean the platform permanently owning routes for a feature it no longer contains. so a reserved name is claimable by a first-party plugin and refused to everyone else. the owner drew that line today: fine for a plugin we wrote, and letting a stranger's plugin claim /dav is a question that needs thinking about properly rather than falling out of a flag. /api stays refused to us too — everything of officer's lives under it and protocolHeaders is the only sanctioned way in. .well-known needed two segments, which the single-segment rule refuses. allowed by NAME rather than by pattern, and the reason is in the same namespace: acme-challenge is how this machine gets its TLS certificates, so a plugin owning it could take the domain. caldav and carddav are on the list; nothing else is, for us as much as for anyone. the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a preflight and never calls the route beneath — which to a DAV client is not a preflight but the question "what can you do", answered by a header iOS refuses the account without. that was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route rather than naming it here. verified: server boots (totality passes), /.well-known/caldav still 301s and /dav/ still asks for basic auth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6b5c87da10 |
a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and a frontend — happened to be the same string. caldav is where that stops holding: one sidecar, one permission, one install, and two screens that have nothing to do with each other on screen. calendar and contacts are not sections of one page, and forcing them into one would be a worse product than the core screens they replace. nothing else moved. all eighteen plugins with a frontend use flat web/panels.ts, which is now "one screen, at the mount prefix, labelled by the manifest" — the same file, the same route, the same persisted layout key. the nested form is opt-in and caldav is its only user. the split of who declares what follows the rule this file already had. the DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST says what to call it and which glyph to draw, because a directory listing cannot say either and those are a human's choice. the two are cross-checked and a mismatch is refused rather than reconciled: a segment with no directory renders nothing, a directory with no entry has no tile and no label, and both are the quiet kind of broken. the part that needed the most care is that a segment is a URL the plugin CHOSE — /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a plugin to shadow a core route, and a chosen segment steps straight out of it. so this is first-party only, refused at discovery, and tested: without that check this field is a way for any installed plugin to take over /files. screenId, not appName, is now the layout key and the panel allow-list's owner. two screens sharing one would persist over each other's sizes and each would accept the other's panels. existing plugins get screenId === appName, so no stored layout is orphaned — verified by regenerating. |
||
|
|
131af603ff |
remove launcher_config.json, an unused stray from the initial commit
Nothing in this codebase reads or writes it — no code, no docs, no reference to port 3333 anywhere. Its own last_updated timestamp (Dec 2025) predates this repo, and its shape (host/port/enable_request_logging, Python-isoformat timestamp) doesn't match anything Officer generates; there is no Python in this repo at all. Swept into the first commit by accident, not something the platform depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a3047b06b |
fix_ssh_permissions never checked the home directory itself
Asked to look at user provisioning and .ssh dir creation — the recurring pain point was "the .ssh permissions are never right even when I do everything correctly", and tracing it: fix_ssh_permissions() only ever touched ~/.ssh and its contents. sshd's StrictModes (on by default; nothing here turns it off) also refuses authorized_keys if the HOME DIRECTORY is group- or world-writable — not just .ssh. An account created by this script's own adduser call gets a safe 0755 home by default, but one that already existed some other way (cloud-init, a prior useradd, LDAP, or simply an account that was already there) might not, and the script never checked or reported on it. From the user's side that looks exactly like the complaint: .ssh is 0700, the key is present and correctly formatted, and login still fails, with nothing in the script's own output pointing at why. Fixed by extending fix_ssh_permissions() — called on every path that adds or verifies a key — to check the home directory's mode first and clear only the group/other write bits if set (go-w), reported with a warn rather than done silently, since it's genuinely surprising when it fires. Deliberately not forced to a fixed mode: 0755 vs 0750 is a real choice some setups make on purpose, and this only removes the specific bits sshd refuses to see. Verified with two simulations: a 775 home directory gets warned about and corrected to 755 (.ssh and authorized_keys already-wrong modes fixed too, as before); a 750 home directory is left untouched with no warning. The stat call tries GNU's -c first (this step only runs on Linux) with a BSD -f fallback, same pattern officer-setup's layout.sh already uses for the same reason — not a live concern here since MACOS_SKIP keeps this step off macOS entirely, but consistent rather than a call that would silently do nothing if that ever changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2e7fb84454 |
fix three officer-setup bugs found on a general review, unrelated to macOS
env_get() never matched its own file. write_env() always writes OFFICERDEV_-prefixed keys, but env_get() compared against the bare name — verified against a real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000]. Every one of its 5 call sites uses the bare name, so this always came back empty. Two consequences: the "recover what earlier runs already decided" block never recovers anything — its own comment says it exists to fix "Build announced PUBLIC_URL not set on a machine whose .env had been written twenty minutes earlier", and that exact bug was still live — and re-running the Environment step showed the wrong default (9000, tailnet URL) instead of the machine's actual current config. Fixed to check the prefixed key first, falling back to the bare one for an old unprefixed file, matching the runtime's own `OFFICERDEV_X ?? X` priority. pg_password_from_env_file() returned success with an empty string when the compose .env existed but had no POSTGRES_PASSWORD= line. The caller (`pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"`) would then treat that as "reuse this password" and build POSTGRES_URL with a blank credential rather than falling through to provision fresh. Only reachable via a hand-edited or corrupted file — write_pg_compose always writes the line — but "silently reuse a blank password" was the wrong direction to fail in. Now returns 1 when the line is missing or empty. SETUP_DOCKER_NETWORK was read independently by both scripts, each defaulting to "services", but never actually persisted across the machine-setup -> officer-setup boundary — not one of the three keys load_machine_answers recognised. A customised bridge name from machine-setup would silently not carry over, and Proxy would land back on "services" regardless of what Docker was actually told to use. Now saved by machine-setup's save_answers(), recognised by officer-setup's load_machine_answers(), and PROXY_NET is re-derived from it right after that load — proxy.sh's own top-level assignment runs at source time, before the answers file could have populated the variable, same ordering issue ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL already had to solve. All three verified with isolated reproductions before and after: env_get against a real prefixed .env and a legacy bare one, pg_password_from_env_file against a normal and a corrupted compose .env, and the full save/load/re-derive chain for SETUP_DOCKER_NETWORK. bash -n clean on every touched file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5dc9f2d5f7 |
neovim had no macOS install path at all — it always tried the Linux tarball
Asked to check how the script installs Neovim on Mac, and the answer was: it doesn't. nvim_install() had zero `$OS` branching, so on macOS it unconditionally fetched nvim-linux-<arch>.tar.gz — a URL that 404s there. Not a hard crash: the caller's `if nvim_install; then … else warn …` catches it and reports "Neovim: install failed" rather than aborting the run, which is exactly the shape that's easy to miss in a long scrollback. brew has a current, real neovim formula (0.12.5), so that's the macOS branch — no keg-only linking needed, unlike the Postgres client fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d02aac1eb8 |
drop htop from the core package list, unused next to btop
Nothing references it — btop is the one the System Monitor app actually embeds (docs/navigation-audit.md, /system-monitor/btop). htop was just sitting beside it on all four package managers with no caller anywhere, and its own first-run message asks for sudo to be useful at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e37fe8330e |
skip radicale and officer-caldav on a macOS dev install, not the platform
Hit live: `brew install radicale` fails outright, "No available formula with the name radicale" — it is a pure-Python package (pip/pipx), not something Homebrew carries, unlike every other name in that list. apt/pacman/dnf all package it for real and are untouched. Narrower than removing the feature: officer-caldav (Calendar/Contacts) stays CORE on Linux. On macOS the sidecar has nowhere to find Radicale — resolveRadicale() failing is exactly the "took a whole sidecar down on a fresh install" case its own test already guards against — so CORE_PROCESSES leaves it out on Darwin rather than let it crash-loop. Calendar/Contacts is the one core feature not available on a Mac dev install; nothing else changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
681df1c8bc |
make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.
The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.
Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.
Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:
- grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
dependency elsewhere in this script, so that's a correctness fix, not just a port.
- ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
enough for NPM's container to reach) — both silently mis-reported on macOS, since the
"command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
- getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
its full 30-minute timeout regardless of whether the record actually resolved. macOS
branch uses dscacheutil.
- stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
option -- c"), and the failure was VISIBLE and made every layout directory report as
wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
- apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
installers (one literally named install_update_linux.sh, the other hardcoding
"_Linux_" into the release asset URL) — each gets a brew branch, formula names
verified against the live Homebrew catalog rather than assumed.
- sudo -u "$USERNAME" to become the SAME account the process already is — real on
Linux, where this whole script runs as root and dropping to the owner needs no
password; on macOS the process never escalates in the first place, so re-invoking
sudo for yourself asks for a real password on every call, or fails outright without
sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
setup's own as_owner() (already the shared entry point there) got the same branch,
and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
were rerouted through it too.
Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).
Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.
Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
|
||
|
|
7e6cfc85af |
one resolver for where an email chat lives
the picker above an email composer listed conversations from the owner's home — a chat started somewhere else entirely, offered as if it belonged to the mailbox. the address had two resolvers. the chat socket knew `context: 'email'` and ran the turn in the account's directory; `GET /chat/sessions` did not, and fell through to `who.home`. so a message went into the mailbox and could not be found in the list directly above where it was typed. two answers to "where does this chat live" is one too many, and the one that was missing failed quietly. `resolveEmailAccountDir` in data-path.ts is now the only answer, and both call it. it verifies a client-named mailbox against disk rather than sanitising it into shape, which matters because the socket's caller ends in mkdirSync — an unrecognised value would not fail, it would CREATE a directory named after whatever arrived and run turns in it. `all`, the list's own sentinel for the unified view, is exactly the value most likely to arrive by accident. pure: no mkdir, no writes. the caller that needs the directory says so itself. tested, because a missing branch is what this was: the named mailbox wins, `all` never becomes a directory, a mailbox with no emails.db is not a mailbox, and no account at all resolves to the accounts root rather than to something invented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
be210c3ed4 |
a plugin declares its chat directory; email stops being hardcoded here
ChatPanelWrapper had one plugin's screen id compiled into a shared platform
package: `workspace.id === 'email'` → `{ context: 'email' }`. its own comment
said the general fix was a way for a plugin to DECLARE its chat context, and
that a second hardcoded id would be the signal it was needed. that fix exists
(`declared`), the email plugin now passes it, so the branch is gone.
it also could not fire any more, which is the part worth keeping. a plugin's
workspace key is `screens/plugin/<name>`, so email's id became `plugin/email`
the day it was extracted and the test silently stopped matching. a hardcoded id
does not fail loudly when the thing it names moves — it just stops being true,
and nothing said so for eight days.
two things on the backend half:
resolveEmailCwd now installs the email plugin's chat/CLAUDE.md. an email chat
runs in the account's directory, not the plugin's chats directory, so the
`plugin` branch never reaches it — the brief follows the chat rather than the
chat moving to the brief. a second plugin wanting this is the signal that
"which directory does this chat run in" belongs in the manifest.
and it verifies the mailbox exists before joining it. `accountEmail` comes from
the client (the mailbox picker's `?account=`) and this function ends in mkdirSync
— so an unrecognised value did not fail, it CREATED a directory named after
whatever arrived and ran the turn in it. the list's unified view uses the
sentinel `all`, which is the value most likely to show up by accident. the panel
drops it; this is the half that does not depend on the panel remembering to.
docs/claude-md-touchpoints.md updated: the copier now has two callers.
unrelated and NOT from this change: ModelSelector's "an unstarted composer gets
a default" fails on master too — verified by stashing these files and rerunning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58652bf14 |
a deleted bot stays deleted
the bot came back three times after being deleted, untitled each time, and the
mechanism is worse than an orphan process.
its watch had no record, so deleting it stopped nothing. the watch kept polling,
fired, and the hand-back resumed the session by transcript id — at which point
`claude --resume` RECREATED the transcript that had just been deleted. so the
bot reappeared in the list and did it again on the next fire. same shape
|
||
|
|
f6bd7564bb |
a bot names itself after it arms
the automatic title is the owner's first sentence truncated — "can you notifiy me when https://newweb.fofos…", typo included. that is a prompt, not a name, and it is what they scan a list of bots for later. so after arming: two to four words, the subject and what is watched about it. "newweb uptime", "NYT front page". explicitly NOT "Website Monitor Bot" — every bot is a monitor and the word earns nothing in a list of them. this also closes the hand-wave in the record step. it said "your session id is the file name you were told to use — if you do not know it, look at which transcript you are in", which is not an instruction anybody can follow. now it lists /chat/sessions?context=bots and matches on the title, because claude titles a session from its first message and on the arming turn that entry is you. which puts the three steps in a REQUIRED order, stated: find your id, write the record, then rename. renaming first destroys the only thing that identifies you in that list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
84d9c9d2d8 |
deleting a bot stops it, and a dead bot wakes up
a bot was deleted tonight and its watcher was still polling a website eleven minutes later. nothing in the product could find it: a bot's watch is a detached shell process, killing the harness ends the agent, and the loop it started is not its child. so a bot writes <sessionId>.bot.json beside its script — what it watches, the script, the state file, the pids. that record is the ONLY thing connecting a process to the bot that owns it, and the brief now says so in those words rather than as bookkeeping. the id is in the FILENAME because bots share a directory: deleting knows only an id, so this is a lookup rather than a scan, and a record whose contents are damaged still gets found and cleaned up. DELETE /chat/sessions/:id tears it down after forgetting the tables — children first then the recorded pid, same ordering as stopCommanderProcesses, so the loop cannot spawn a replacement between the two. every transcript in the returned chain, not just the id asked for. unconditional rather than gated on "was this a bot", because the record only exists in the bots directory and a branch on cwd is a second place to get it wrong. script and state go too, and only if they are inside the bots directory — an agent wrote that path. and the reviver sweeps bots on the same tick. a record with pids and none of them alive is a stopped watch; there is no armed flag and there should not be, since the record is written when it arms, so its existence IS the intent. the three-wake bound is shared, keyed bot-<sessionId>. the wake prompt forbids reconstructing a watch from memory when the record is gone: a watch subtly different from the one agreed is worse than none, because it looks like it is working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
513a50f2e8 |
a bot is a card, not a conversation in a list
it looked like the generic chat panel because it WAS one, and that leaks the implementation at exactly the wrong moment: a bot is a chat underneath, and nobody using this should have to hold that. the session list says "conversation, last touched" because that is what a conversation is. so it gets Command HQ's object instead — a card with a name, an id pill, rename and delete, selected ring, controls on hover. same arrangement: the whole card is the link with the buttons layered over it, so a click anywhere opens it and the controls still work. the pill is the first eight characters of the session, which is also what an @mention carries. that is the point rather than a coincidence — the thing shown on the card is the thing typed to reference it. the panel's own header picker is gone. the cards are the list, and a second picker in the panel bar is the same choice offered twice. command hq does not have one either. deleting the selected bot navigates back to /bots, or the route points at something that no longer exists and renders an empty chat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8b3ad8418a |
the brief carries the mechanism, so a bot stops going to find it
arming one watch took eight discovery calls and a 307-line read of the commander skill before anything happened. the brief said "use the background job you already know" and it did not know it, so it went looking — and what it found is a document about spawning agents in a loop, almost none of which applies to a bot. so the mechanism is written down here instead: write the script in this directory, run it with the Bash tool and run_in_background, the script waits in the shell and exits when the condition is true, the exit is the signal, re-arm before ending the turn. plus the shape almost every bot is a variation of, with the twice-in-a-row rule already in it. and it says NOT to read the commander skill, by name. an instruction to look somewhere is an invitation to read everything on the way. one real fix rides along: the first bot wrote its script to /tmp, which does not survive a reboot. the template puts the script and its state file beside each other in the bots directory, and says to write the new state BEFORE exiting — otherwise the next arming fires on the same change again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
20d60cd3be |
/bots looks like Command HQ, and stops opening the wrong chat
TWO BUGS AND A LAYOUT. the first outing of /bots opened a months-old conversation about deleting proxy hosts. resume-newest called useClaudeSessions(absoluteCwd) with no context, and a screen that owns its own directory has cwd '~', so that join yields null and the hook listed the caller's HOME. the panel then resumed the newest thing there. same bug the header had, one function down; the declared context wins now. second: ChatPanelWrapper takes an optional sessionId, because a screen whose selection lives in the URL cannot also let usePanelSession decide — two sources would disagree and the URL would lose. null means "the screen chose a new chat", undefined means "no opinion", and collapsing them would make New snap back to the newest. the layout is Command HQ's: a narrow list left, the conversation right, and a route pair — /bots is the new-bot state, /bots/:sessionId is one of them. rows are real links, so cmd-click works and a reload keeps its place. no categories pane. a commander is a job somebody files and a filter earns its place; a bot is a sentence, and a filter over a handful of one-line watches would be furniture. no table either: every bot is a chat in bots/, so the list IS the session list for that directory. nothing to keep in step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f119bbdf93 |
a bot has defaults, so it stops asking
"notify me when x.com is down" is a complete instruction. the brief now says so, and lists every question it was likely to ask along with the answer it should use instead: one minute, twice-in-a-row before calling something down, follow redirects, ten second timeout, extract the smallest stable piece rather than the whole page. two of those are the difference between a bot somebody keeps and one they turn off. firing on a single failed request produces a watcher nobody trusts. and the FIRST run establishes the baseline without firing — otherwise arming a headline watch immediately announces the current headline, which nobody asked about. and it can be told who to tell in prose. "@Name [id]" carries an id, but "the todo guy" is how it will actually be said — so: look it up in the chat list, match on title, use it if exactly one matches and say which. several matches is worth the one question. no match is worth asking. the line between defaulting and asking is stated rather than left to taste: no address at all is a question, a missing interval is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
de538cfc1b |
/bots — one sentence arms a watch
a bot is a chat whose working directory is bots/, armed through the background job any chat can already arm. no scheduler was written and none was needed: handoff already means "poll cheaply, wake the agent only when it fires", which is the expensive half of a commander without any of its ceremony. ONE directory, not one per bot. a commander earns its own because it is a job with procedures and a ledger; a bot is a sentence and a watch, and a directory each would mean a creation step before anything can be said. sharing it makes the list free — every bot is a chat in that cwd, so the header's picker is the list of bots. it earns NO permission of its own. /bots rides `chat`, because a bot adds no surface: same routes, same socket, same confinement. the registry says so rather than leaving the next reader to wonder. the brief is the feature. it tells the agent to arm on the FIRST message and not interview — pick a sensible interval, arm, say what you armed in one line, and let the owner correct it, which is cheaper for them than three questions. it says the owner will never say "notify" or "nudge" and must not have to. it forbids the loop that wakes on a timer to look for itself, which is the expensive mistake available here. and it draws the line: a bot watches and tells, it does not fix — a request for work is a commander, say so and stop. bots joins commanders and plugins in confineUserTree, third time: a member's bot runs as the member and a 700 service-user directory kills the turn on its own cwd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2faf1fa4b2 |
a mention renders as a pill, and the id stops being visible
@Todo Guy [6305e570] read as literal text in the bubble, brackets and all, which is not what a reference should look like. it renders as a LINK node at #officer-mention-<id>, and the bubble's existing `a` override turns that into a pill. a span with a class was the obvious thing and does not survive — rehypeSanitize strips exactly that. a fragment href passes untouched. inlineCode also survives and was dropped because it cannot be told apart from somebody typing backticks around an @; only a real mention should look like one. it is a span rather than an anchor: there is nowhere to navigate to yet, and opening the referenced conversation is a real feature with its own decisions. it should not pretend to be clickable. the pill reads @Todo Guy. the [6305e570] stays in the text that reaches the agent, which is the entire reason it is there. discord does the same — a human reads a name, the machine reads an id. hand-rolled tree walk: installs are frozen and neither unist-util-visit nor @types/mdast is in the lockfile, so a nine-line walk does not get to change it. node shapes declared locally, only the fields this touches. tested against over-matching, which is the failure that would matter: an email address, a bracketed price, a non-hex id. eight hex characters is the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
b5385ff40f |
@ in the composer, to reference another chat
type @, a list appears, keep typing to narrow, arrows to move, enter or tab to pick, escape to dismiss. the shape everybody already knows. it inserts `@Todo Guy [6305e570]` rather than just a name. the composer is a textarea — discord renders a pill because it uses a contenteditable, and matching that means replacing the composer — so a mention here is text, and the text has to carry enough to ACT on. 28 sessions on this machine, several with near-identical opening lines: "the todo one" is not a resolvable instruction. COMMANDERS COME FROM COMMAND HQ, not the chat list, and that is the whole reason this is not three lines. a commander's real name lives in its row; its transcript title is the setup preamble it was created with. a picker reading only /chat/sessions would show "You have been named "Platform Deployer", and that is th…" for exactly the sessions most worth referencing. the empty-query list is commanders then recent chats. it would be better to offer only sessions somebody NAMED — the server knows, as hasExplicitTitle — but that field is not in the client payload, and a heuristic for "does this look like a name" would be wrong more often than recency is. said so in the file rather than pretending. two details that are bugs if missed: the token follows the CARET, not the end of the value, so editing mid-sentence still opens the menu; and the query survives a space, because the names people give things have spaces in them and a picker that closed on the first one would miss every one of them. both tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0a4d7f8ad9 |
deploy report 2026.08.26.02
five of five target hosts, no schema change, standing db:push diff only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
121a77ce7f |
release 2026.08.26.02
one fix, and it is a crash: /browser fails to render with react #185 on every host running .01 with the browser plugin installed, which is the fleet. useAuthoredLayout takes structure from source every render, which is right and is what lets a plugin's layout change reach people who already opened it. what it did not account for is WorkspaceView repairing an unrecognised appType and WRITING THE REPAIR BACK — self-limiting when structure was persisted, an infinite loop when it cannot stick. fofos is already at the fix by hand, mid-work. the plan says so rather than leaving the commander to read it as drift. no schema change, no dependency change, and officer-claude-code stays untouched again — nothing here needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a7c9dab22c |
a repair that cannot stick must not be written
useAuthoredLayout turned a harmless fallback into a hard crash, and /browser on fofos is where it showed: "This screen failed to render", react #185, maximum update depth exceeded. WorkspaceView's appTypes guard repairs an unrecognised appType and WRITES THE REPAIR BACK. that was self-limiting while the structure was persisted — the repair stuck and the next render agreed with it. with the structure coming from source, it can never stick: recomputed, rewritten, discarded, recomputed, every render, until react gives up. the browser plugin's layout names officerdev/chat, which is not one of its own panel keys, so every single render of that screen produced a repair. the same trap proxy-manager hit last night, except that one only rendered the wrong panel — this one loops. so a write whose result equals the current value is dropped. not an optimisation: it is the thing that terminates the loop. persisting an identical tree was pointless anyway. tested by grafting a repaired structure and asserting it comes back identical to the current value — which is the exact condition setValue now checks, so if it ever writes there, the loop is back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
5005726994 |
deploy report 2026.08.26.01
5 of 5 target hosts. alpha, fofos, production-pertento-bridge,
deadpool-rezio-bridge, macbookpro all at
|
||
|
|
e9f1760f27 |
release 2026.08.26.01
22 commits. no schema change, no dependency change.
the reason it is being cut tonight rather than tomorrow: officerdev gained an
export that a SHIPPED plugin already imports. proxy-manager is in the catalogue
now and its ChatPanel imports ChatHeader, which exists only from
|
||
|
|
a2102c5b46 |
"generating" is not "has an outstanding background task"
the guard I added yesterday to stop the reviver interrupting a commander mid-work disabled the feature entirely, and it took a real case to see it. it asked collectRunningTasks, which counts BACKGROUND TASKS. a commander's watcher IS a background task, and it stays outstanding until it reports a terminal state — which a killed process never does. so every commander with an armed watcher had one outstanding by definition, the guard read "busy" forever, and the reviver could never wake the one thing it exists for. found on the deploy commander tonight: loop dead, pid file saying so, and the task "Platform Deployer watcher — polls origin for deploy/* tags" still marked running from its own revival six hours earlier. permanently true. this is the shape I criticised in the nudger yesterday — a guard that could only ever be right by accident — written by me a few hours later. the right question was always whether the AGENT is mid-turn, which the sidecar answers directly and which the nudger was already asking before I removed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a7b95a7bd1 |
todo: fold the existing claude-md-touchpoints audit into the AGENTS.md sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9f2b703b4b |
todo: sweep AGENTS.md/CLAUDE.md so AGENTS.md is primary everywhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9a3b5dd649 |
a screen can hear when a chat turn finished
ChatPanelWrapper already refreshed the file tree and the preview when a turn ran tools. it kept that to itself, so a screen whose own panels show something a turn can change had no way to know — which is every plugin with a chat beside a list. so onTurnComplete is a prop now, composed with the platform's own handling rather than replacing it. called for EVERY turn, outside the hadToolCalls guard, because "did anything I care about change" is a question about the caller's screen and not about this one — it gets the flag and decides. read through a ref: a caller passing an inline arrow would otherwise rebuild the callback every render, and it goes into EmbeddableChat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f9f4753425 | audit: every place the platform writes or touches CLAUDE.md |