2f8a2b60d5f66e62273ecea89d4ba83ab04e499b
55
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
66234cd3e0 |
an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.
So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.
The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.
`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.
Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.
Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6d8709c05a |
plan: roles as data, teams as scopes, and the owner as an identity
Design for tomorrow, written down so the work starts from a spec. Phase 0 is already built; everything after it is plan. The load-bearing decision is the smallest one. Ownership is answered today by users.role === 'Super Admin', which was fine while roles were a hardcoded union and nothing could write a new one — and becomes a privilege-escalation path the moment roles are editable data, because then adding a role and becoming the owner are the same feature. So phase 1 is just that: the owner is OWNER_USER_ID, every existing call site keeps meaning owner, and nothing downstream starts until it lands. Teams union rather than restrict, which is the only coherent resolution and has a consequence worth stating: a team can only ever grant. Deny rules are named under what must not be built, along with a role that confers ownership and team-scoped execution capabilities. And the thing a reader would otherwise discover at the end of phase 4: there is still no way to create a second account, so this is a permission model for accounts that cannot yet exist. Sequenced last because it is orthogonal, flagged because it may be worth doing first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b94a5e90b8 |
SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push. No staging area, no transfer table, no ack, nothing to expire. Every account already has a home, a file browser over it and a Files app on the phone, so a delivered file is an ordinary file from the moment it arrives. `Documents/SendOff/<machine>/` for something you sent yourself, and `Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually browses — the owner's real login home, a member's managed home — because the two are different directories and writing to the wrong one makes a delivery invisible rather than wrong. CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant answers "may this role send files", and the person who should decide whether somebody may write into your home is you. Absence denies, and a sender who was not allowed does not see the recipient in their picker at all — the endpoint answers 404 for "no such account" and for "did not allow you" alike, so it cannot be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving still needs their say-so. The sender's folder name comes from the token and never from the payload, so it cannot lie about who put a file there. Names are sanitised to one path segment and filenames to their basename — verified: `../../../../etc` becomes a literal folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the sender cannot see the directory and would have no way to know. The push carries the folder name and a file count, and the catalogue renders "3 file(s) from andre" on the device. That relaxes "content does not travel" by a folder name and an integer — the owner's call, on the grounds that the file never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. `kind: 'app'` rather than `confined` even though it writes into a home: the path is one fixed shape chosen by the server from the caller's token, so there is nothing to traverse and nothing to read back. Requiring a Linux user would mean an account without one could not be sent a file, which is most of the point. Backend and verified end to end. The web send form is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e6f1388aa6 |
plan: localizing the web UI, and the scanner that sizes it
not built. an execution plan written to be handed to an agent who did not have the conversation, so it carries the reasons and not just the steps. what it found: there is already a hand-rolled i18n package with typed keys, two catalogs and 46 keys — and exactly three components using it. against 884 distinct hardcoded strings across 436 files. so the system is not missing, it is 5% deployed, and that gap is the job. the decisions worth arguing with are D1 and D6. D1: the server should never emit user-facing prose, it returns a stable code and the UI owns every word — with the trap that a code must not leak more than the prose did, or a deliberately vague 403 becomes an enumeration oracle. D6: catalogs per workspace rather than one file, because one 900-key en.json is a merge conflict on every parallel edit. the scanner reproduces the numbers in the doc and doubles as the guard once an area reaches zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3837af657d |
live sessions default to agents only, and you ask by name for a commander
`?kind=` was opt-in, so a client that said nothing got everything — which is the wrong default for the one caller that cannot cope. The phone swipes between live chats one at a time, has no room for tabs and no way to skip a row, so a commander in that list is a commander you are stuck on. Defaulting to `agent` means forgetting the parameter can only ever under-report. The two callers that want both now say `kind=all` explicitly, and that is a fair price: they are few, they are on screens built to show both kinds, and they are the ones positioned to notice when the answer is wrong. `counts` still describes every live session whatever the filter, so a filtered caller renders both tab numbers from one request rather than two. Also corrects useLiveSessions' comment, which said `isCommander` was derived from the cwd. It stopped being true when a commander gained the right to choose its own working directory — it comes from the `command_hq_sessions` row, and the directory answers nothing. Unrelated but caught by docs:check on the way past: the registry gained a nineteenth entry (minesweeper, execution) and CLAUDE.md still said eighteen, which also left the generated platform skill two lines stale. Both corrected; the check is green again. The minesweeper feature itself is half-landed and not mine — `rfb.ts` fails typecheck and its registry test fails on a websocket that is not served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a356656a60 |
hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves whatever is newest, so machine-setup provisioning a box today would put a two-day-old release under the platform and every pm2 app except officer-pty. The owner asked for a temporary hold on 1.3. The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest 1.3.x at install time so bug fixes inside the series still land, and no 1.3.14 to rot into "the version we happened to pick" the way setup_22.x did for Node. The version reaches the installer as its documented first positional argument (a release tag), and install_bun refuses to run with an empty one: taking whatever bun.sh serves is the single outcome the pin exists to prevent, so no bun at all is the better failure. Loud, and officer-setup's pre-flight already stops on a missing bun. Resolved from the npm registry rather than GitHub's releases API, which is the obvious source and the wrong one — 60 unauthenticated requests an hour per IP, and it returned 504 twice while this was written. An empty answer installs nothing, so where it comes from is load-bearing. The section now reads four states instead of two. "Installed" was one answer covering a machine inside the pin, one behind it, and one past it; past it is the case worth catching, since that is what bun upgrade leaves and nothing else reports it. Above and below get different prose and both offer to move; nothing is corrected silently. What a setup script cannot cover: bun upgrade takes no version argument — latest stable or --canary, verified against its own --help — so it always jumps past the pin without saying so. Hence the doctor check, which reads engines.bun rather than repeating the number, because a second hardcoded copy is a second thing to drift. bun ignores engines (verified: an install declaring 1.9.x ran clean under 1.3.14), so the field is the declaration and doctor is the enforcement. Confirmed bun install --dry-run still resolves frozen with the field present and bun.lock unchanged. This host is already on 1.3.14 and inside the pin, so nothing moved here. Also fixes three more stale process counts in the same block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4a206086af |
docs:check — the documentation's checkable claims, against the code
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is invisible. Every piece of drift found today was mechanical — a count, a route name, a process list — so it is checkable, and a check that exits non-zero cannot be quietly not done. It found two things on its first run, both wrong for over a week: platform/CLAUDE.md "27 entries as of 2026-08-13" the registry holds 18 officer-dev/CLAUDE.md "Officer is twenty PM2 entries" there are 13 The second was worse than a wrong number. It listed eleven processes that do not run here — vault, slskd, headscale, transmission, invoiceshelf, wallet, photos, jellyfin, gitea, music, vnc — while omitting six that do, and named two mybiblepal entries as sharing the daemon when nothing does. Accurate on 2026-08-07, and nobody re-derives a list of processes while editing the sentence around it. Both are corrected, with what they used to claim recorded, because the interesting part is how a maintained list decays rather than that it did. Three checks: the permission registry count, the PM2 entry count, and every settings route Spotlight offers resolving to a section some screen declares. That last one is the class the counts cannot catch — a name, not a number — and it is the drift that shipped a dead link to /settings/users. THE RULE FOR ADDING ONE, written into the file: a check compares a documented claim against a value DERIVED AT RUN TIME, never against a second hard-coded copy. A check with the answer written into it is one more place to drift, and it drifts in the same edit that misses the doc. What it deliberately cannot do is read prose and judge it. It looks only at claims with a machine-checkable counterpart — a small fraction of the documentation, and the fraction that rots fastest. The settings check parses source rather than importing it, and says why: those sections are object literals inside components, so importing them drags React and a DOM into a CLI. `skipped:` lines are printed rather than swallowed — a machine without pm2 cannot check that count, and saying so beats reporting a pass it did not perform. Recorded as required before tagging in CLAUDE.md, and in prompts/deploy-commander.md as explicitly NOT the rollout agent's job: it executes a plan, and a prose edit between two hosts is not that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bcf8df9d32 |
design: notifications from every server one app is signed into
The good news survives checking. An APNs auth key belongs to the developer account, not a machine, and a device token belongs to the app, not a server — so N installs holding the same .p8 all push to one phone with no coordination, and the app posting its token per server is something the multi-server manager does anyway. One field is missing: the payload carries no origin. That breaks the deep link (a session id is meaningful only on the server that issued it), and threading, and — least obviously and most expensively — `mv`. That is a content hash of THAT server's catalogue, and the client's rule is `mv !== version → resync`. One stored catalogue and five servers means every push from B invalidates what was synced from A, forever. It would present as a battery and data problem rather than a bug. Where the identifier comes from is deliberately left to the sign-on design: the multi-server manager already needs a key per server, and notifications should adopt it rather than invent a second identity that drifts the first time a host is renamed. Records what stays per server on purpose — devices, per-chat mute preferences, the catalogue itself — because the app is what joins them, which is the same shape as the sign-on work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3fb439fabf |
plan: nightly visual regression on a second server
Capture every route of a JWT-authenticated dashboard each night, diff against a promoted baseline, and have an agent look only at what changed. The blocker is auth, and it fails silently. iris seeds a capture with Cookies, Cookies-wal, Login Data and Local State — a JWT lives in localStorage, which Chrome keeps in Default/Local Storage/leveldb and iris does not copy. So an authenticated capture renders the login screen: a real screenshot of a real page that is the wrong page. Three ways out, with a recommendation. Copying leveldb works and drags every site's localStorage into a temp profile. A token in the URL needs no profile at all and removes the desktop plugin from the plan entirely — that is the one to try first. Seeding through the relay works and needs a browser paired at 3am, which is the constraint iris exists to avoid. The rest is deliberate about where the model is spent: pixel diff first, mask what is known to move rather than raising the threshold, and only changed pairs reach an agent — with "if you cannot tell, say so" in the prompt, because a confident wrong verdict on night four is how this stops being trusted. Baselines are promoted by a person, never by a run succeeding, or a regression becomes the baseline on night two. Also records what Officer lacks: the queue has retries and no schedules, so this is cron. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4efc2552d0 |
os-deps: a dependency declares who has to be able to run it
The probe asked the owner's login shell where a binary was, and the install reported that as a fact about the machine. For a confined plugin the work happens as the member's own Linux user, in their own home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable. On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an absolute path resolved. At 750, the more usual default, every member would have lost every binary with nothing reporting it: the install would still have said satisfied, having only ever asked the owner. Nobody chose 751, and that is the part worth fixing. `scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a manifest error rather than a silent fall back to the permissive one. Shared changes three things. It is searched for on system directories only — asking the owner's shell would find the copy in the owner's home and call it satisfied, the exact false pass this exists to stop. Reachability is checked as world-executable AND every parent traversable to the root, because a binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so the download itself never runs as root. An unreachable shared dependency gets its own state and refuses the install ahead of every other check, because no package manager fixes it: the binary is installed and works, and what is wrong is where it lives — reinstalling would report success and change nothing. Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's data is gated; its executable is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1915a407a2 |
write down the plugin route authorization design, agreed and deferred
A plugin's settings routes should be owner-only while the plugin itself stays grantable — jellyfin's /_config holds a server's admin token and drives provisioning, and a Member granted the plugin can reach it today. Agreed: the platform says who is asking, the plugin decides what that means inside its own surface. That is not new — manifest.ts already refuses an ownerOnly permission on the grounds that finer visibility is the plugin's business. Two refinements recorded rather than assumed. Pass isOwner and the effective level, never the role name: roles are the platform's vocabulary and a plugin branching on "Super Admin" gives every plugin repository an opinion about it, so renaming a role would change authorization in code the platform does not own. And nothing may be trusted from the caller — the proxy's three-item header allowlist is what makes X-Officer-User safe, and a new header inherits that only while it stays an allowlist. One disagreement, recorded as a proposal rather than settled: pure plugin-side enforcement is the shape assertPermissionTotality exists to prevent. Jellyfin will check because we are about to write the check; the next plugin's author forgets a route and nothing notices. So: declare owner-only path prefixes in the manifest, enforce them in the proxy, and pass the facts to the sidecar as well. The real prerequisite is not the guard. Connections are per-user, so locking /_config leaves a Member with a plugin that can only show them a connect form they are forbidden to submit. The fix that fits what exists is the owner's server URL with the member's own token from jellyfin_accounts — which is arguably why provisioning mints those tokens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa5bacec46 |
email recall: the design, and which answers can be trusted
the use case is someone mid-phone-call who needs a fact from eight years of correspondence now. everything in here is in service of that moment. the split that came out of the discussion, and it is the whole document: counting needs EXHAUSTION, retrieval needs SPEED, and they are different machinery. an agent asked "how many times did she ask X" will search, find some, and answer with a number bounded by the phrasings it happened to think of — "i found four" is indistinguishable from "there were nine", and nothing in the output says which. vector search does not fix that; top-k misses too, just differently. the only honest count is to judge every message once and store the verdict. which makes the compile half the one that answers the questions that prompted this, and it needs no vectors at all. embeddings still earn their place for the other row: keyword search finds "school run" and never finds it from "the pickup arrangement", and on a live call thirty seconds of searching is a lost moment. the stronger argument is whatsapp and transcripts sharing no vocabulary with email — but we do not have those, so they shape exactly one decision (the store is not email-shaped) and nothing else. two constraints stated as constraints rather than preferences: the embedding model runs LOCALLY, because an api means uploading eight years of private correspondence to a third party, and there is no ollama or embedding code on any machine here — that is the real new dependency, not the database. and it is sqlite-vec in the plugin's own file, not a vector database: 19,097 rows is small, and a separate service would be operational surface bought for nothing. the hard part is neither. it is quoted reply chains putting the same paragraph in forty messages of one thread, which naive embedding turns into forty identical results. step 1 of the build order is chunking with no embeddings at all, because if that is wrong nothing after it can be right. a stored verdict is a judgement a model made about someone's divorce, so it records what it was asked, what it read and when. a number with no provenance is worse than none, because it will be quoted. |
||
|
|
5a26cdbbcb |
find my device: the design, and what it can never do
sketched the same day push landed, because push is the piece that makes it possible at all — there is no other way to reach a sleeping phone. more of it exists than expected: the wake channel, a working maplibre map with clustering in the photos plugin, and headscale already reporting online and lastSeen per node, which covers laptops without an app at all. what is missing is a table, an endpoint, some app code and a panel. what it can never do is stated up front rather than discovered later. apple's offline finding works because a billion strangers' devices relay a lost phone's bluetooth beacon. there is no self-hosted version of a crowd. off-grid gets you last-known-position; powered-off gets you nothing. two things i expect someone to get wrong, so they are called out above the schema: - do NOT key location on the push token. apns tokens rotate, the registration path is already an upsert because of it, and a history keyed to one silently forks into two devices. this needs a stable on-device uuid and a `devices` table that is not push_devices — which is worth building anyway, since the push registry currently has no stable device identity at all. - store accuracy and draw the circle. ios returns 3km fixes when it is being cheap, and rendering one as a confident pin sends someone to the wrong street. the sharpest open question is not technical: the owner bypasses every permission check by design, so on a family server the owner can see a member's location structurally, unless location is given an explicit exception. that falls out of the architecture rather than out of anyone's intent, so it should be decided deliberately. retention likewise. a location table is a complete movement record, apple deliberately cannot build one, and self-hosting means choosing to. the rule belongs in the design, not in a settings screen added afterwards. |
||
|
|
c68c734431 |
docs: what an affordance is, and the test that catches the bad ones
the substance was in plugins/CLAUDE.md marked "DESIGN, NOT BUILT", which stopped being true the same day it was written — load.ts reads them, publish.ts copies a plugin's in on install, and claude-manager compiles them into every session. moved to docs/affordances.md and corrected; plugins/CLAUDE.md keeps the plugin-specific part and points here. the new part is a third test, earned today by nearly failing it twice: CAN IT BE FIXED INSTEAD? an affordance is for something irreducible about the surface, and a defect is not irreducible. both candidates drafted today failed it. "links open in the same tab" was one missing line in MD_COMPONENTS. "AskUserQuestion never returns an answer" was true when the code comment was written and already fixed by the time i read it — that one would have shipped a permanent instruction to work around a bug that no longer existed, in a file whose whole purpose is to be believed without checking. names the failure mode: an affordance describing a defect calcifies it. once there is a document explaining the workaround, the defect has a rationale, and the rationale outlives anyone's memory that it was a bug. also records two things that are not built and read as if they were: nothing is scoped, so every affordance is in every conversation, and affordanceBlock() has exactly one caller — an opencode turn gets none at all. |
||
|
|
ab636eaa42 |
page title: derive from the route path instead of a hand-maintained table
RULES was a hardcoded map from every route prefix to a title, which meant a new route silently fell through to 'Officer' until somebody remembered to add an entry — the gap that hit installed-plugin routes, which had no entry at all. titleForPath now takes the URL's first path segment and title-cases it, so every route gets a sane tab title for free, including ones a plugin adds at runtime. Trade-off: a few routes lose their hand-tuned casing (QR Transfer -> Qr Transfer, Command HQ -> Command Hq) and settings sub-routes collapse to the one 'Settings' title instead of a distinct one per section — both accepted as the point of removing the table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b63badaf67 |
remove the email residue the extraction left in the platform
a sweep for remnants after 2026-08-18. nothing was broken - no dangling import, symbol, route or permission key anywhere in src/ - but the platform was still speaking the plugin's vocabulary in a dozen places. the one that mattered is live code rather than prose: ChatPanelWrapper hardcodes workspace.id === 'email' to send context: 'email', which is the frontend half of the pair whose backend half (resolveEmailCwd) carries a fifteen-line justification. the two ends now both say so, including that adding a SECOND id there is the signal the platform needs a real way for a plugin to declare a chat context. the rest: two dead imports left by removing the settings section, an empty 'Email' type-section header, a protocol.ts section header with no members naming the plugin's SSE design, and a queue comment pointing at sidecar/email/sync-runner.ts - a path in another repository now. CLAUDE.md, AGENTS.md and working-on-officer.md described email as a platform feature, an app permission and a pm2 process. those are the files a fresh agent reads first, so they were the highest-risk staleness in the tree. TODO.md's whole Email section moved to the plugin's own repository. it held OPEN items pointing at sidecar/email/store.ts, which is exactly how a leftover list sends someone to edit files that do not exist. the delete-account dialog promised it would remove email accounts and playlists. both tables belong to plugins now, so that cascade only happens where the plugin is installed - it now says 'anything an installed plugin keeps for them' instead of naming two it cannot guarantee. imapflow and mailparser STAY in package.json. the plugin has no package.json of its own and resolves from the platform's node_modules, so removing them would break the installed plugin's sidecar. the fix is giving plugins their own dependency manifest, not deleting these. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ecb6864ff9 |
delete the landing app
Built and shipped from somewhere else now, so this copy was dead weight that still carried its own third copy of the palette — `duck-teal` had three different definitions across three apps, and this was one of them. Removed: `src/apps/landing`, `scripts/build/landing.ts`, the `build:landing` script, and the tree entry in CLAUDE.md. `/landing-page-data` STAYS and is unrelated despite the name — it is the API the sign-in screen asks whether registration is open. Worth saying because a grep for "landing" hits both and only one of them was meant. Nothing imported from the app. tsgo clean, frontend builds, the live app serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c0e61a4cba |
the trap keeps its evidence, and says where the procedure is
This bug will not be reproduced on demand. It will arrive as somebody's report, hours later, from a browser whose console is long gone — so a `console.warn` is seen by nobody, and "what did you see?" gets an answer built from memory and a screenshot. So the trap now persists. Up to five occurrences in `localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment of the edit, the first 200 characters that arrived, how many, which file, and when. The question becomes "paste this key", and the answer is complete. Recording a bug must never become one: private mode, a full quota and a disabled storage API all throw at that write, and none of them is worth breaking an edit over. The console line survives either way. `docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four candidates already eliminated and why, the one command to ask a reporter for, how to read each field, what an EMPTY key would mean (either the editor was focused, making it a paste, or the insertion came in chunks under the threshold), and the instruction to delete every piece of this once the cause is known. Linked from TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it without grepping for it. Stated there rather than left implicit: a fix invented without knowing the cause would hide the symptom on the one screen where it is visible while the mechanism carried on doing whatever else it does. The corruption is never saved and reopening the tab clears it, so waiting is cheap and a wrong fix is not. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
53742a256f |
marketplace.officer.dev is live, and the plugin repos have READMEs
The site is up on HTTPS with a Let's Encrypt certificate, proxied to :9100, and serving the three plugins out of `officer_marketplace`. An Officer install with no configuration now reads its catalogue from it. ── What is running ── PM2 entry added to THIS machine's ecosystem only. Deliberately not in officer-setup's CORE_PROCESSES: the marketplace is one website officer.dev hosts, not something every install should run — a copy on every machine would serve a stale catalogue on a port nobody asked to open. nginx (NPM host id 7) → 172.20.0.1:9100, with a ufw rule for the docker bridge. That rule is not optional and its absence does not look like a firewall problem: without it nginx HANGS until proxy_connect_timeout rather than being refused, which is how the apptest hosts wasted an hour a few days ago. ── READMEs ── `music`, `offscale` and `example` now have proper READMEs, written against their own code and pushed to their own repos. They render on the plugin's page in the store and are what a self-hoster reads before installing. The example's is a tutorial as much as a shop window — it is the document a plugin author reads first, and every convention in it was verified in `discover.ts` rather than restated from memory. `music`'s remote was HTTPS with no credentials on this box, so it could not be pushed; switched to SSH like the other two. The catalogue still advertises HTTPS — `marketplace-sync` converts — which is the property that matters, since an `ssh://` clone URL only works on the machine that published it. ── Writing them was the cheapest audit available ── Reading each plugin against its own documentation turned up ~20 real defects, all recorded in TODO.md and none fixed here. The pattern worth naming: offscale is still branded "headscale" in user-visible panel names and in a persisted terminal session id, and its PLUGIN.md states outright that the ACL assistant is deliberately unwired — while `sidecar/assist.ts`, `claude-proxy.ts` and `PolicyAssistant.tsx` all ship. Documentation that confidently describes the opposite of the code is worse than none. Also fixed the docs on the frozen lockfile: both CLAUDE.md and bunfig.toml named `bun install --no-frozen-lockfile` as the escape hatch, and bun 1.3.14 has no such flag — it is ignored and the install fails anyway. tsgo clean, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
64f3de59fb |
CLAUDE.md caught up on how setup is run
It said `bun setup` runs officer-setup.sh and was "IN PROGRESS, sections 1-6 of 10". It runs scripts/install.sh, and both halves are finished — machine-setup has 28 sections, officer-setup 11. That line is probably why the orchestrator got doubted: the one document you would check to find out how to install says the wrong entry point. Added what install.sh actually is — an orchestrator that runs the two halves and nothing else, either half runnable alone, both re-runnable, run it as yourself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f1cfc0042f |
rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly beside officer-anthropic-proxy — a different process doing a different job — so "the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to spend a paragraph insisting the two are not the same thing. It spawns `claude`; the name says so now. Only two references were functional: the generator's CORE_PROCESSES and the CORE list in catalogue.test.ts. Everything else was prose or comments. Left alone deliberately: `x-officer-agent-token`. It looks like the same string and is not — it is the agent-handoff HTTP header, naming a per-panel bearer token, unrelated to any pm2 process. Renaming it would have changed a wire protocol to tidy a label. Historical docs keep the old name. claude-sidecar-isolation.md and open-threads-after-per-user-claude.md are dated investigations that record the PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would make that history unreadable. CLAUDE.md notes the change instead, where somebody reading those will be looking. Also worth recording, from the owner: merging this with officer-anthropic-proxy into one sidecar was investigated tonight and rejected. They stay separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
62cbf510cb |
no ecosystem files in git; officer-setup generates one
Deleted all four — ecosystem.config.cjs, .light., .mac.light. and the .profile. they derived from. The repository now contains no ecosystem file at all, and .gitignore keeps it that way. officer-setup writes one at the end, describing exactly the six processes a core install runs: officer, officer-anthropic-proxy, officer-agent, officer-opencode, officer-pty and officer-headscale. No profiles, no derivation, no plugins. The four existed because a profile has to subtract from something, so the full list had to name every plugin's process whether or not anybody installed it — and a test then had to assert the two files still agreed. Generating one file removes the subtraction, the second list and the test that policed them. It is .cjs, not the .js PM2's docs use, and that is not a preference: package.json declares "type": "module", so a .js file here is ESM and `module.exports` throws. PM2 require()s the config. Sections 10 (Services) and 11 (Verify) are built on top of it — write, startOrRestart, save, optional boot hook, then check every process is online with a sane restart count AND that the API actually answers on PORT. A process can be `online` and serving nothing, so the port is asked directly rather than inferred. That completes all eleven sections. catalogue.test.ts required both deleted files at import, so it could not even load. Its central assertion — "the store offers exactly what light leaves out" — has no meaning without a full list to subtract from, which is the point of the change. Replaced by two weaker but real checks: the store must not offer a core process, and every process it names must have a sidecar directory to run. The second catches the same typo the old one did without needing a manifest of everything; verified it holds for all 15 catalogue entries. app-store/pm2.ts starts a sidecar with `--only` against this file, which now holds core alone — so it can stop a plugin but cannot start one that was never written in. Marked `[open]` there rather than left to be discovered: appending a plugin's entry is the plugin system's job. Generated one in a scratch directory and required it with node: six apps, correct cwd on each, valid CommonJS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1ccb21e67f |
CLAUDE.md: five capability kinds, not four
The file described a stricter model than the code enforces. It said terminal, chat, files, tasks, desktop and browser are all `kind: 'execution'` and therefore never shareable — but terminal, chat and files moved to `confined` on 2026-08-11 with per-user Linux accounts, and are grantable. The distinction matters and is now written down: a confined grant means nothing without a Linux user. authorize.ts:97 drops it for an account whose `osUser` is null, so "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, since getOwnerHomeDir ignores the email it is passed. Verified in the code, not inferred from the comment. Also brings the Data section in line with today: DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are no longer environment variables, the install root is derived from cwd, and assertInstallLayout is why a wrong cwd fails instead of relocating the install. And `bun setup` runs officer-setup.sh, which is sections 1-6 of 10 — worth saying, since the entry read as though it were finished. Registry needs real work for where this is going. This is only the docs catching up to what is there now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9864fb1a49 |
switch off the browser relay, pending extraction into a plugin
BROWSER_RELAY_PORT is gone and the second listener no longer starts. The Chrome
extension, api/browser/ and the /browser screen all stay on disk — this is going
to be extracted, and deleting it means writing it again.
Three things had to move together, and the middle one would have failed the boot
on its own:
server.tsx the listener, commented out with the variable name recorded
hono.ts the /api/browser mount, closed
registry.ts the 'browser' capability's claim on /browser, dropped
assertCapabilityTotality checks both directions: check 2 refuses to start on a
capability claiming a prefix nothing serves. Unmounting the router alone would
have left the registry describing it, and the server would not have come up.
The capability itself survives because it also claims /scrape, which shares
nothing with the relay — it launches its own headless chromium through playwright
and never speaks to the extension.
The comment in server.tsx carries the two facts that are not recoverable by
reading the remaining code. First, the port is an INPUT TO A CREDENTIAL:
relay-auth.ts derives each extension's token as HMAC(JWT_SECRET,
'officer-browser-relay-v1:${port}:${userId}:${salt}'), so bringing the relay back
on a different number silently invalidates every paired browser — reported by the
extension as "Relay not reachable", which SETUP.md blames on a wrong address,
port or token. Second, it cannot come back as a kernel-assigned port:0 like the
other sidecars: the extension is configured by hand and stores the value, so a
port that moves each restart breaks the pairing each restart.
Left alone deliberately: the /browser route in App.tsx, its Dock entry, and the
Settings → Browser Relay panel. They will not work against a closed endpoint.
Removing them is frontend work for the extraction, not part of switching the
listener off.
.env is down to PORT and POSTGRES_URL.
Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes two variables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f063fc0c08 |
remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag defaulted to ON, so none of it ran on a real install — what comes out is documented defence in depth that was already switched off. The file said so itself: "Both flags and their call sites come out once the tailnet is the perimeter." Origin was never authentication here in any case. An app's `officer://<hex>` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt, originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which existed only to pin them. CORS now echoes whatever Origin it is given, which is what every install already did. What SURVIVES is the reason this needed care. origin-validation.ts held two unrelated things, and the second was the global authorization gate — a valid non-owner token reaches only what its role grants, deliberately NOT under the flag because it is account-based rather than origin-based. Its own comment called it "the airtight half". Deleting the file wholesale would have deleted authorization. So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with the name matching what it does: nothing in it reads an Origin header any more. hono.ts mounts it in the same position, ahead of every router. origin-middleware.ts stays and is untouched — it extracts the Origin for six auth handlers that log it, and for passkeys. Extraction, not validation. Also updates every claim that rested on the old model: CLAUDE.md's security section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five messages in machine-setup's Tailscale section which told the owner to set ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to follow, and the honest version is different: with no tailnet the token is the whole lock, so put a proxy in front and restrict who can reach it. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes four variables now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9eabc3ee4c |
design: the secret store
Written from the conversation of 2026-08-12. Nothing implemented; every claim about the current tree was checked on that date. The short version: secrets stay in Postgres, the keys move out of .env into a small SQLite store, and rotation becomes an operation instead of data loss. What is actually wrong today is narrower than "secrets in .env" and worth stating precisely, because the separation that exists is correct and must survive a refactor: secrets live in Postgres and the key that opens them does not. The problem is blast radius across processes — Bun auto-loads .env, so VAULT_STORE_KEY sits in the environment of all twenty pm2 processes, and officer-music holds the key that decrypts wallet seed envelopes for no reason. The document records the decisions and, more usefully, what was ruled out: Keys cannot go in Postgres. A dump would carry the ciphertext and the thing that opens it. Encrypting the key with a second key only moves the question — one secret has to be readable without any other, and the only decision is where it lives. The store is not encrypted at rest, and this was tested rather than assumed: stock SQLite silently ignores unknown pragmas, so `PRAGMA key` succeeds, encrypts nothing, and the value is readable with `strings`. bun:sqlite ships stock SQLite 3.53.0. Whole-file encryption needs SQLCipher, which is a second native dependency, and this project already knows what one of those costs. SQLite rather than a flat file for rotation, not secrecy: rotation needs key VERSIONS, since an interrupted rotation needs the old key and the new one to both exist. The file must not live in $OFFICER_ROOT/data/ — that is what people back up, and a key store in the same tarball as a database dump rebuilds the problem. It also records the core/plugin split the design assumes: light plus officer-headscale is the core, because CLAUDE.md rests the security model on the tailnet and a model that rests on the tailnet cannot treat administering it as optional. Vaultwarden and the wallet become plugins. Moving headscale into light removes it from the app store automatically, since catalogue.test.ts asserts the catalogue equals full minus light. Five open questions are left open rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
999362948f |
allow Node 22 or newer, and record why it was pinned to exactly 22
The preinstall check demanded exactly 22 — `v < 22 || v > 22` — which refuses Node 24, the current LTS. Relaxed to `>= 22`. Recording the reason it was exact, because it was deliberate and the details are gone: some months before now there was a real node-pty build failure that pinning to 22 solved. Nobody remembers what it was. That is exactly the kind of decision that gets undone twice, so it is written down here, in CLAUDE.md, and in the project memory rather than living in one person's recollection. What the evidence says now: node-pty 1.1.0 ships prebuilt binaries for darwin-arm64, darwin-x64, win32-arm64 and win32-x64 — and nothing for Linux. So its install script always falls through to `node-gyp rebuild` and compiles against whatever Node is installed. There is no prebuilt binary, so there is no ABI to mismatch, and node-pty declares no engines field. That reasoning is sound and completely untested: nothing here has built node-pty against 24, and node_modules has never existed on this machine. If `bun install` fails building it, or officer-pty cannot load its native module, restore the exact pin — CLAUDE.md says so, with the line to put back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c73ffed806 |
close the comms channel, keep what was still open
The sidecar-app-store channel ran one night, from per-user Linux accounts to a member's first agent turn, and is deleted now the work has landed. A spent channel left in place gets read as current, which is worse than none. Three things lived only in those docs and move to TODO.md rather than disappearing: deprovisionOsAccount (observed on production — a deleted member kept a shell, a running container and 454M of data, with their uid free to reissue), the terminal replaying query sequences as keystrokes, and agent sessions not being durable, which is one missing property behind three symptoms. The deprovision spec itself already lives in docs/. CLAUDE.md's section is rewritten from "here is the current channel" to how to run one, since the answer to "which channels exist" is now none. What is worth keeping is the protocol that emerged: numbered alternating files, parity as the author, a reply even when there is nothing to say, and termination on a checkable condition rather than on someone deciding it feels finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
701933d30d |
commit and push when it goes wrong too
The instruction was standing and nowhere in the repo, so every session started by waiting to be asked. Written down because the reason is not obvious: a branch held back because it is unfinished, untested or a dead end is exactly the branch whose history is worth having. A reverted commit and its message explain why an approach was abandoned; a quietly discarded attempt teaches the next person nothing, and they will try it again. The obligation that comes with it is saying what state the work is in — in the message, and in COMMS/ when another agent will pick it up — rather than letting a clean commit imply it is finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
48ed171b38 |
point CLAUDE.md at COMMS, so a new session finds it without being told
The channel is only useful if it is read, and relying on the owner to remember to say "check COMMS" in an opening prompt puts the mechanism back where it started. CLAUDE.md is loaded automatically, so the pointer belongs there: what the directory is for, that newest date wins, and which streams exist. Also states the split it is easy to get wrong — durable reasoning in docs/, coordination in COMMS — and that a spent handoff should be deleted rather than left to be mistaken for current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d56be0301d |
retire the single-user claim from the docs it outlived
CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b419af32de |
sweep the dead code in section 8
Each item re-verified before deleting; three of the ten entries were stale
and are corrected in place rather than silently fixed.
- WorkspaceLayout's isMobile/mobilePanelId/onMobileBack: none of its ten
callers set them, so the mobile collapse they fed was permanently off in
that renderer. WorkspaceView passes the same props to WorkspaceRenderer
itself, where they are live.
- fixedHeight on AppRegistryEntry, and getFixedHeight with it: no app has
ever declared one, so it only contributed undefined. The flex-column
branch it shared with fitContent stays, keyed on fitContent alone.
- getDefaults: getAllDashboardState already folds the defaults row into the
one payload the client fetches, which is why it never got a caller.
- upsertScreen's terminals/hostTerminals: never read is right, never
written was not — it inserted them, which is why all 15 rows hold {}.
The columns are left in place; dropping them needs a db:push, and this
tree holds another agent's uncommitted schema file.
- SELECTED_DASHBOARD_KEY: H2 (01365cb) replaced it with ?selected= four
months ago and it has had no reader since.
- ui/sidebar.tsx and the stray ui/hooks/ beside it. use-mobile was not
orphaned as claimed — the sidebar imported it — and the use-toast in
there was a near-identical copy of the live one.
- findChildById's unreachable duplicate condition, and the doc comment that
described the wrong behaviour rather than the code being wrong.
Left deliberately: DragOverlay/LayoutEditor (gated on 5.3, an owner
decision) and the two chat-owned channels, whose docs are fixed here even
though the publishers are not mine to delete.
|
||
|
|
30fcab2bd3 |
mark 5.6 resolved, and stop three docs claiming a channel that has no publisher
The todo entry said the file-viewer registration was dead; tracing it confirmed that and turned up the reason it looked alive — the ephemeral file viewer is a different mounting path entirely. Recorded, with what was checked in the database before deleting anything. CLAUDE.md, navigation-audit.md and workspace-panels.md all listed FILE_VIEWER_CHANNEL among the legitimate refresh/signal channels. It never had a publisher, and no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7c99429872 |
write down the north star: agents coordinating with each other
docs/agent-coordination.md is the objective the workspace/panel work serves — several agents on one dashboard handing work to each other instead of routing every step through the human, with the human authoring the workflow at the top. Written from the owner's own words during the 2026-08-07 conversation; where a section records a decision, that decision is his. It settles the questions that were blocking: sessions may be reaped and resumed from the durable sessionKey→claudeSessionId map (no heartbeat), the address is the human-assigned panel name rather than the panel id, roles are prompts rather than features, and the protocol is turn-boundary-only by construction — which routes around the mid-output restart failure instead of fixing it. Cross-referenced from CLAUDE.md, workspace-panels.md and workspace-panel-todo.md so it is findable from any of them. The todo is still ordered by defect severity and now says so; the re-rank against the objective is deferred, not forgotten. Also corrects two items dated 2026-08-08 to the day they were actually found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ce7968ac90 | document how the workspace/panel framework works | ||
|
|
10ff23c5dd |
gitea: the app — repos, code, issues, pulls, notifications
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through. Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits, branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications, explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item, all real Links with the URL as the source of truth — no selection channel. Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost is Gitea's #123 and @mention cross-references; relative links and images are resolved instead. The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands. retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY. This instance answers with two: /user and /repos build from its configured ROOT_URL (http://localhost:9004), /contents from the public host. An unconditional rewrite onto the connection URL therefore broke the second set to match the first, turning working https links into dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is itself public; when the connection URL is a dial address nothing is touched and the connection screen says why avatars will not load. Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather than connection.url, which is null for a member. Not verified: no runtime pass since the last four changes, the issues and pull views have never rendered a row (the instance has none), and the member path has never executed (one account). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6fde7afd7f |
docs: refresh the pm2 process list
it said fourteen; there are nineteen. photos, notify, caldav, memos and jellyfin have landed since it was last written. also names the two mybiblepal entries that share this pm2 daemon and belong to a different project, and points at `pm2 jlist` as the source of truth so the next reader does not trust a list that goes stale every time a sidecar ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2052fbdaa |
freeze installs: bun resolves from the lockfile or fails
bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from bun.lock and nothing else — it fails rather than quietly picking up a newer version, including a transitive one nobody chose. Config rather than a documented habit, because a supply-chain compromise does not wait for the one time somebody forgets a flag. On 2026-08-04 eleven cache packages — keyv, flat-cache, file-entry-cache, cacheable-request, cache-manager, the @cacheable/* scope and ecto — were published with a preinstall dropper that harvested npm and GitHub tokens, AWS and Kubernetes credentials, SSH and PEM keys, .env files and .claude/settings.json, then republished itself through any npm token it found, reaching 434 further packages across 1381 versions. This machine was unaffected only because nothing had installed since 2026-08-02. Verified on bun 1.3.10 rather than assumed, and the first result was wrong: with NO lockfile present, neither the flag nor the config refuses — bun simply creates one, so an initial test made the setting look ignored. Against a lockfile that no longer satisfies package.json, both exit 1 with "lockfile had changes, but lockfile is frozen". The config is honoured; the earlier reading was a bad test. `bun install` in this repo still reports 1058 installs, no changes. Applied to monorepo-mobile too, where flat-cache, file-entry-cache and keyv are present as eslint/got transitive deps — at versions old enough to be unaffected, which was luck rather than design. Documented in platform/CLAUDE.md and the workspace root, including the part that matters most: do NOT add --no-frozen-lockfile to a script, a Dockerfile or CI to make the error go away. The error means the lockfile and package.json disagree, and an unexplained lockfile change in a diff is precisely the signal this exists to produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a9b6494f56 |
db: stop push re-creating every composite key on every run
`bun db:push` planned 32 statements against a database that already matched the
schema, and stopped on a "do you want to truncate screens?" prompt that answering
could not resolve — the same question came back next run. Root cause found and
fixed rather than worked around.
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads one back,
compares it against a schema declaring the identical name, columns and order,
decides they differ, and emits DROP + ADD. Fifteen of those, forever. Reproduced
on a database drizzle had itself created seconds earlier, so it is not drift.
Single-column .unique() is diffed correctly; only unique('name').on(a, b) is
affected. Unique indexes go through a different code path and are stable, so all
fifteen are now uniqueIndex.
A unique index enforces exactly what the constraint did — verified, a duplicate
insert still fails on uq_screens_user_name — and onConflictDoUpdate accepts it as
an arbiter. It cannot be a foreign-key target, but nothing here targets a
composite key; checked before converting.
Separately, user_integrations_server_integration_id_server_integrations_id_fk is
65 characters and Postgres truncates identifiers at 63, so drizzle compared its
generated name against the stored, truncated one and re-created the FK every run.
Declared explicitly as fk_user_integrations_server_integration.
Measured on a scratch database, pushing twice each time:
before 32 statements, interactive prompt
after uniqueIndex 4
after FK fix 2
The two that remain are a composite primaryKey with the same bug and no index
form to escape to — music_now_playing re-creates pk_music_now_playing every push.
Silent, no prompt even with rows, data unaffected, and naming it explicitly does
not help. Documented as expected.
The conversion itself was tested against populated tables, since that is what the
real database will do: no prompt, and all rows survived.
Docs rewritten in src/databases/CLAUDE.md — the rules committed an hour ago
described the broken behaviour and would have been wrong the moment this landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6af80a20b0 |
db: add the user role column, and write down how push actually behaves
Adds `role` to users as a text column with a TS enum, matching how `status` is
done — no pgEnum, so adding a role stays a one-line schema change rather than an
ALTER TYPE while the set is still settling. USER_ROLES and UserRole are exported
from officerdb so the API and UI enumerate them from the column definition rather
than a second hand-written list. Defaults to 'Member', the least privileged, so a
row created by a path that does not think about authorisation cannot mint an
admin. NOT YET PUSHED — the column is in the schema, not in the database.
The larger half of this commit is a rule for anyone running `bun db:push`,
because the first time you run it, it looks like your change broke something.
It plans 16 statements against a database that already matches the schema, and
plans them again on the next run. Two separate causes, both diagnosed here:
- it drops and re-adds every NAMED COMPOSITE unique constraint — all 14, from
uq_screens_user_name to uq_wallet_labels_wallet_kind_ref. Single-column
.unique() diffs correctly and is untouched; only unique('name').on(a, b) is
affected. Names, columns and order in the database are identical to what the
schema declares. drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
- it drops and re-adds one foreign key because
user_integrations_server_integration_id_server_integrations_id_fk is 65
characters and Postgres truncates identifiers at 63, so drizzle compares its
generated name against the stored, truncated one and always differs.
The rules, ranked by how much damage getting them wrong does: never answer "Yes,
truncate the table" — it destroys rows and does not help, since the constraint is
re-added next push either way; never delete a constraint from the schema to
silence the prompt, because upserts depend on it existing; do not reach for
--force until someone has established on a scratch database which branch it takes.
Run with --verbose first and read what it is actually planning.
Pointers added to platform/CLAUDE.md and the workspace root CLAUDE.md, since
those are what a session reads before it ever opens the database directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3f22a808d6 |
notify: the sidecar shell, with Discord as the first channel
Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple
or Google credential exists, so the pipe is proven before the hard part.
officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and
proxied at /api/notify. It is a sidecar rather than platform code because the producers are
spread across sidecars — the queue, email, the agent — and a platform-owned notifier would
force every one of them to call back into the platform. That is the inversion just removed
from email; this avoids recreating it.
Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather
than replacing anything. Each is awaited with its own error boundary and the dispatcher
always resolves: a job that finished has finished whether or not a banner appeared, so a
channel must never be able to break its producer.
text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to
render a banner, so "send nothing" was never available — what we control is that the string
is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and
the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to
put one.
Device registration lives behind X-Officer-User, trusted because the listener binds loopback.
Platform and environment are validated rather than defaulted: an iOS token from a debug build
fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that
never receives anything and never says why. GET /_officer/devices returns only the last 8
characters of a token — enough to identify a row, not enough to push to it.
Verified end to end against a fake webhook: /_health reports configured channels, a test
notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as
{"content":"3 new emails"}, and every validation path returns its own error.
Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
aac20bf112 |
drop eight package.json scripts that point at nothing
Same origin as the phantom docs in the last commit — this architecture was based on another project and some of its scripts came along without their targets. - `build:editor` and its three variants run `scripts/build/editor.ts`, which has never existed here. CLAUDE.md carried a note saying exactly that, which the note now outlives. - `start:sidecar` / `stop:sidecar` / `restart:sidecar` / `logs:sidecar` drive `systemctl … officer-pty-sidecar`, a systemd unit replaced by PM2. That unit still exists on this host, enabled, pointing at a `monorepo/` directory that is gone, and has been failing to start ever since — these scripts are how it stayed reachable. CLAUDE.md's "Sidecar control" line pointed at the four systemd wrappers; it now says PM2 and points at working-on-officer.md for which process a change needs restarted. Verified afterwards that every remaining script's target exists. The rest of package.json is untouched — the edit is nine lines out, no reformatting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
04c0d89057 |
docs: bring the live docs back in line with the code
First pass of the documentation audit. Every doc was read against what the code actually does now; this commit fixes the ones worth keeping and deletes the ones that were only describing a past. Corrected: - CLAUDE.md — said seven WebSocket providers (there are eight, and terminal/vault are byte relays now, not translating bridges), listed channels/ as "Telegram / WhatsApp / Discord bridges" (they are gone; what remains is how /chat drives an agent turn), missed officer-wallet in the PM2 list and notify/ in the layout, and described the per-account email SQLite stores without saying they are the sidecar's and that nothing in the platform opens them. Further Reading pointed at four files that no longer exist and missed the four newest. - docs/working-on-officer.md — PM2 list was four sidecars short, and it still explained the officer-claude rename as news. Replaced with the thing a reader actually needs: which process to restart for which change, and why restarting officer no longer costs you a terminal or an agent session. - TODO.md — the "dead username plumbing" item was mostly resolved by deleting the channels, and two email items pointed at api/email/email-db.ts, which is sidecar/email/store.ts now. - AGENTS.md — trailing paragraph listed the design notes being deleted here. - MUSIC_API.md — playlists were entirely undocumented: seven endpoints the phone app has no reference for. Added from the sidecar's own contract. - docs/jobs-unification.md — phases 1-3 shipped, so it now says so at the top. Phase 4 (push notifications) is the only reason the file still exists, and email sync is explicitly no longer part of it. Deleted, all superseded rather than merely old: - PHONE_APP.md — a February plan for apps that now exist, with their own repo and README. - MARKETING_WEBSITE.md — a plan for a site this repo does not contain. - SECURITY_AUDIT.md + SECURITY_FIXES.md — a February audit of a codebase since restructured; it still cites queue/handlers, which is now empty. - docs/DOCKERIZATION_PLAN.md — cites pty-sidecar, whatsapp and projects, all deleted. - SETUP_GUIDE.md — documents systemd units and setup scripts replaced by PM2 and `bun setup`. Not harmless: /etc/systemd/system/officer-pty-sidecar.service is still enabled on this host, pointing at a `monorepo/` directory that no longer exists, and has been failing to start ever since. That guide is how it got there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
188b45c113 |
remove projects and apps end to end
deletes the last of the projects/apps cluster: the published-app store (/api/apps + /api/app-serve), the project dev-server and its websocket proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite they were the only consumers of, and their frontend — the Preview panel, the UserApp panel/header, useUserApps and the /settings/apps screen. also drops getUserProjectsDir and getUserAppsDir, the ProjectType and ProjectDefinition types, and the 'dev-server' websocket provider from server.tsx. nothing on disk is touched. 1440 deletions, 31 insertions. tsgo clean. |
||
|
|
b6dc73915d |
add officer-invoiceshelf sidecar
owns the invoiceshelf contract: instance url, sanctum token and the company
header that scopes every request. the platform side is the usual thin
auth+forward proxy at /api/invoiceshelf and holds no credentials.
built against the live 2.4.2 instance rather than the 3.0.0-alpha.1 checkout
in _references — the route allow-list came from artisan route:list on the
running container. they differ: 2.4.2 has estimates/{id}/convert-to-invoice
but no invoices/{id}/convert-to-estimate.
three upstream quirks absorbed here:
- accept: application/json is mandatory, or an unauthenticated request 302s
to an html login instead of returning 401
- origin/referer must never be sent, or statefulapi() switches to session+csrf
and every request 419s. the proxy forwards neither.
- a wrong company header does not error, it silently returns another company's
data. the pinned company is explicit and logged.
document pdfs are repaired: 2.4.2 prefixes them with a literal serialised http
response (201 bytes) inside a body already typed application/pdf. we slice to
the %PDF- magic. the report routes don't have the bug.
resources are an allow-list. backups, disks, modules, update/*, installation/*,
mail config, settings writes and ownership transfer stay unreachable, and the
per-resource action list keeps `send` — which really emails the customer —
from being reachable by accident.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c39460ce2d |
document the navigation conventions in claude.md
the url-as-source-of-truth rules from docs/navigation-audit.md were only in the audit; anything new was as likely to reach for a selection channel as a link. also records the workspace-barrel requirement for shared route helpers, and adds officer-headscale to the pm2 list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
86930f5b17 |
rename officer-claude to officer-anthropic-proxy
the pm2 entry named officer-claude never ran an agent. it starts sidecar/claude/index.ts, which registers as capabilities: ['proxy'] and only holds the anthropic proxy secret and forwards api traffic. the process that actually spawns claude is sidecar/claude/user-instance.ts, which had no pm2 entry at all and was spawned on demand by the main server. that misnomer is how both CLAUDE.md files ended up claiming that restarting officer does not disturb a running agent session. it does: the agent was a grandchild of officer and died with it. correct the name so the next reader starts from a true model, and fix the claim in both files. no behaviour change — officer-agent arrives in the next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
32749eb665 |
document that bun format touches every dirty file
The script globs `git diff --name-only HEAD`, so running it after a small change also reformatted an unrelated file the owner had in progress — whitespace churn landing in someone else's diff with no explanation. Point at `bunx prettier --write <paths>` instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |