Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 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
2026-08-28 22:03:44 +01:00
pastilhasandClaude Opus 5 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
2026-08-28 21:51:20 +01:00
pastilhasandClaude Opus 5 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>
2026-08-28 20:38:15 +01:00
pastilhasandClaude Opus 5 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
2026-08-28 20:27:11 +01:00
pastilhasandClaude Opus 5 ec6c793143 deploy/2026.08.27.01 — the plan, and the version to match it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:06:10 +01:00
pastilhasandClaude Opus 5 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
2026-08-27 17:05:50 +01:00
pastilhasandClaude Opus 5 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
2026-08-27 17:05:50 +01:00
pastilhasandClaude Opus 5 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
2026-08-27 14:32:49 +00:00
pastilhas 9ec60d6d75 caldav 2026.08.27.03 — filters on calendar and contacts 2026-08-27 14:20:52 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 14:13:40 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 13:56:37 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 13:37:28 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 13:31:18 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 13:24:10 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 13:11:19 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 12:44:34 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 12:15:16 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 11:13:00 +00:00
pastilhasandClaude Opus 5 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>
2026-08-27 10:56:51 +00:00
pastilhas 3fac1460cd close the request-body todo: the proxy claims only what it forwards 2026-08-27 08:48:54 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 08:48:40 +00:00
pastilhasandClaude Fable 5 6762a32ea6 todo: turn a chat into a commander or a bot, carrying its context
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 08:46:36 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 08:46:18 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 08:45:14 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 08:43:46 +00:00
pastilhasandClaude Fable 5 8705dc3a7c todo: a server registry for terminal ssh and agent awareness
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 08:38:18 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 07:52:46 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 07:44:00 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 07:32:05 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 07:18:31 +00:00
pastilhas 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.
2026-08-27 07:09:06 +00:00
pastilhas 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.
2026-08-27 07:04:05 +00:00
pastilhasandClaude Opus 5 a865bcfdfc put back the orientation the sweep overwrote
2360a3ef moved CLAUDE.md's text into AGENTS.md verbatim — which is exactly what
its brief asked for, and the brief was wrong. it described root AGENTS.md as "41
lines, currently a pointer". it was not: it carried an Orientation section — the
owner/member split, why execution permissions can never be granted, that
mounting a router without a registry entry refuses the boot, and the reading
list. all of it was replaced rather than merged, and none of it survived
anywhere else in the tree.

my fault, not the agent's. i skimmed a file and then told something else to
overwrite it. the other four pairs are clean — none of those directories had a
prior AGENTS.md, so there was nothing to lose.

restored at the top of the file, with three corrections rather than a straight
revert:

  · the sibling repo is `capabilities/`, not `permissions/` — the old text named
    a directory that does not exist
  · "read the Permissions section of CLAUDE.md" now says "below", since this is
    that file
  · the reading list no longer lists CLAUDE.md as the architecture doc, which
    would now be a pointer pointing at the thing pointing at it

the file also still called itself "# CLAUDE.md". it says AGENTS.md now, and says
why both exist: opencode walks the tree for AGENTS.md and nothing else, Claude
Code for CLAUDE.md, and one set of instructions has to reach both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 06:59:48 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 06:55:03 +00:00
pastilhas 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.
2026-08-27 06:35:31 +00:00
pastilhas 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.
2026-08-27 05:46:57 +00:00
pastilhasandClaude Opus 5 fdf50ce2a4 ship screenArtUrl, which the last commit left behind
52f209c6 named its paths explicitly and this file was not among them, so the
export that mount.ts imports never left this machine. alpha pulled, could not
resolve it, and crash-looped nineteen times before the log said so:

  SyntaxError: Export named 'screenArtUrl' not found in module assets.ts

the path-scoped commit is the right habit — two agents share this tree and a
bare `git commit` sweeps up whatever the other one staged. the cost is this: a
list is a thing you can be incomplete about, and tsgo cannot catch it because
the file is right here on disk. only a machine that pulls finds out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 05:04:31 +00:00
pastilhasandClaude Opus 5 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:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:54:42 +00:00
pastilhasandClaude Opus 5 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
2026-08-27 04:22:27 +00:00
pastilhas 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.
2026-08-27 04:15:51 +00:00
pastilhasandClaude Opus 5 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>
2026-08-27 04:34:55 +01:00
pastilhasandClaude Opus 5 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>
2026-08-27 04:11:16 +01:00
pastilhasandClaude Opus 5 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>
2026-08-27 04:01:52 +01:00
pastilhasandClaude Opus 5 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>
2026-08-27 03:55:21 +01:00
pastilhasandClaude Opus 5 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>
2026-08-26 21:49:41 +01:00
pastilhasandClaude Opus 5 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>
2026-08-26 21:40:59 +01:00
pastilhas 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.
2026-08-26 21:10:08 +01:00
pastilhasandClaude Opus 5 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
2026-08-26 18:47:37 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 18:43:13 +00:00
pastilhasandClaude Opus 5 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
22a9d58d fixed for commanders, arriving from a different direction.

the record could never have covered this: it depends on the bot having written
one, and a bot that did not is precisely the one nobody can stop. a mechanism
that only works when the agent remembered is not a mechanism.

so deleting also sweeps: any process running a .sh inside the bots directory
whose pid appears in NO record is killed. claimed pids are never touched,
whatever else is true — a live bot's watch is the one thing this must not
reach. reads /proc rather than shelling out, since it runs inside the delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 08:14:46 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 08:09:54 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 08:04:49 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 07:50:55 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 07:46:56 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 07:21:28 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 07:17:08 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 07:13:57 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 06:52:20 +00:00
pastilhasandClaude Opus 5 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
2026-08-26 06:39:27 +00:00
pastilhasandClaude Opus 5 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>
2026-08-26 04:39:40 +00:00
403 changed files with 7436 additions and 4426 deletions
+578 -25
View File
@@ -1,41 +1,594 @@
# AGENTS.md
Guidance for any coding agent working in the Officer platform repo.
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
Guidance for any coding agent working in the Officer platform repo. **This is the file with the
instructions in it**; `CLAUDE.md` beside it only points here. Both exist because the two harnesses
discover different names — opencode walks the tree for `AGENTS.md` and nothing else, Claude Code for
`CLAUDE.md` — and one set of instructions must reach both.
## Orientation
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
per-role permission grants, resolved on every request.
Officer is built around **one owner** (user id 1, role `Super Admin`, who bypasses every permission
check), which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. What
each may reach is decided by per-role permission grants, resolved on every request.
If a design question turns on "which user", the answer depends on the surface: real for the **app**
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything that
executes code or touches the disk — terminal, chat, tasks, files, desktop and browser are
`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
section of `CLAUDE.md` before adding one.
**Mounting a router without a registry entry makes the server refuse to boot.** Read the Permissions
section below before adding one.
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
management". That was written to correct an _older_ drift in the opposite direction — a fictional
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
paragraph above; treat the permission registry as the source of truth over either.
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as plain
files, and is where many changes belong — adding or changing a task needs no code change here and no
restart.
This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
plain files, and is where most changes belong — adding or changing a task needs no code change here
and no restart.
- [`CLAUDE.md`](CLAUDE.md) — this repo's architecture, conventions and code style
- [`docs/working-on-officer.md`](docs/working-on-officer.md) — the deployment-wide guide: which
directory a change belongs in, how to run and verify it, the task system, and the failure modes
worth knowing about
directory a change belongs in, how to run and verify it, the task system, and the failure modes worth
knowing about
- `CONVENTIONS.md` — component organisation and React patterns, with rationale
- `TODO.md` — current direction; takes precedence where it disagrees with anything else
Treat the code as the source of truth where anything disagrees with it.
## Project Overview
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
behind a single web app.
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
written: `users` holds six rows. The accurate statement is narrower and more useful —
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
user.** `authorize.ts` 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. That rule lives there once and covers the
HTTP routes, the websocket doors and the dock together.
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
below before adding one.
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
the counts stay right and the sentences rot.
## Architecture
One Bun process (`src/server.tsx`) serves everything:
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
deliberate — it is the owner's own machine. Do not add a jail without being asked.
## Repository Layout
```
src/
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
├── apps/
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine
│ └── sidecar/ # sidecar implementations + the wire protocol
├── databases/officer_db/ # the only database (Postgres + Drizzle)
├── extensions/browser-relay/
└── workspaces/ # shared packages, each a bun workspace
```
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
`src/apps/officer-web` is only the shell — screens, routing and settings.
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
## Tech Stack
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
## Data
Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
catalogue served by a process with no login, no session and no user, so sharing a connection would put
an unauthenticated surface one query away from every private table. It runs only on the host serving
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
what is inside it.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
failing.
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
passed).
### Schema changes use `push`, not migrations
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
history was deleted because it had drifted from the real schema. Treat the schema code, not those
files, as the source of truth.
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
"Composite keys" before adding either.
## Security Model
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
rules key off it — they fail closed.
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
is a valid token on every protected route plus the permission gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts.
### Permissions — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
survive the next door; refusing to boot does.
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time.
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
into a refusal.
## Commands
```bash
bun dev # the whole app — SPA + API + WebSockets, watched
bun start # production
bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform
```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
needs to, and on macOS never does, because Homebrew refuses to run as root.
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
See `docs/working-on-officer.md` for which process a given change needs restarted.
### Installs are frozen. Never resolve a dependency you did not ask for.
`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. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
`error: lockfile had changes, but lockfile is frozen`.
It is config rather than a 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, `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. It reached 434 further packages across 1,381 versions. This machine was
unaffected only because nothing had installed since 2026-08-02.
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
in a diff is the signal this exists to produce.
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
## Code Style
- **Paradigm**: functional — pure functions, immutability, composition
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
- **Async**: always async/await
- **Exports**: named only, no defaults
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
### Imports
Order: types → external → workspace → relative.
```ts
import type { User } from 'types';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { formatDate } from 'helpers/formatters';
import { useWebsites } from '../useWebsites';
```
In app code import types from `'types'`, which re-exports the database types. Only server and
database code imports from `officerdb/types` directly.
### Functions
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
parameter lists — extract a params type instead.
```ts
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
export function processData({ body, query }: ProcessDataParams): Result { ... }
```
### Components and hooks
```tsx
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
```
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
always named `ev`, never `e`.
### API calls
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
type parameter and let inference flow from it.
## Working With Me
- **Ask first** — confirm the approach before a significant change
- **Explore thoroughly** — read the related files before editing
- **Keep it simple** — no over-engineering, no premature abstraction
- **Be explicit** — no magic, no implicit behaviour
- **Stay focused** — note unrelated problems, don't fix them uninvited
- **Report honestly** — say what you verified and what you didn't
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
withholding the commit until it is good.
Commit messages: simple lowercase, no prefixes.
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
the two-step form only looks like it does.
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
follow-up note instead. Cheap to avoid, awkward to undo.
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
wrong commit, and only because the tag was checked afterwards did that surface at all.
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
```
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
```
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
restart in a single night. The transcript id survived both.
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
whose "why" is still answerable in six months.
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
only exposure is that a background task's output path contains it, which means starting a job to learn who
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
add the trailer and one that does not should leave it out rather than guess.
## Frontend route conventions (apply to EVERY new dashboard route)
Worked examples: `/soulseek`, `/music`, `/chat`.
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
`<WorkspaceView workspace={ws} locked />` where
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
that pins `appType`s to an allow-list. Panels are windowed apps under
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
`usePublishPageTitle` in `officerdev`.
### The URL is the source of truth for selection
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
link-focusable). Half the app still does this; none of the new code should.
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
_mutates_ rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
canonicalises both the bare route and a bogus param.
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
Action buttons are **siblings** of the anchor, never nested inside it.
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
when you want `isActive`.
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
## COMMS — a channel between agents, when one is open
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
sessions.
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
convention rather than an oversight: a spent channel left in place gets read as current.
If you open one:
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
agent read identically.
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
participant. "I think we're done" can close a thread with work still in it.
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
lands, delete the channel and move anything still open to `TODO.md`.
Two things that made it work, and neither is about either agent being more careful. One writes, the other
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
explaining why it was safe.
## Further Reading
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
a number produced by searching is confidently wrong with no signal that it is
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
question to settle first, which is retention rather than anything technical
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
agent. Read before writing one
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
it is what `docs/workspace-panel-todo.md` is ranked against.
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
persistence key families. Read before building a panel app. Its defect list is
`docs/workspace-panel-todo.md`.
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
check turns into an endless resync between servers without it. The server identifier is an SSO decision
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
before any agent is asked to look
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
one home directory happened to be mode 751
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
anything it already rules out
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
conversation. The CLAUDE.md in its own working directory is the living document, for free
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
`proxy-manager` ships a chat brief today)
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
a namespace makes provenance readable, separate directories make it structural
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
get wrong — a team folder that is not `setgid` charges nothing to the team
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
- `src/apps/CLAUDE.md` — shared frontend patterns
- `src/databases/CLAUDE.md` — database patterns
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
Treat the code as the source of truth where anything here disagrees with it.
+3 -560
View File
@@ -1,563 +1,6 @@
# CLAUDE.md
## Project Overview
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
behind a single web app.
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
written: `users` holds six rows. The accurate statement is narrower and more useful —
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
user.** `authorize.ts` 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. That rule lives there once and covers the
HTTP routes, the websocket doors and the dock together.
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
below before adding one.
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
the counts stay right and the sentences rot.
## Architecture
One Bun process (`src/server.tsx`) serves everything:
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
deliberate — it is the owner's own machine. Do not add a jail without being asked.
## Repository Layout
```
src/
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
├── apps/
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine
│ └── sidecar/ # sidecar implementations + the wire protocol
├── databases/officer_db/ # the only database (Postgres + Drizzle)
├── extensions/browser-relay/
└── workspaces/ # shared packages, each a bun workspace
```
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
`src/apps/officer-web` is only the shell — screens, routing and settings.
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
## Tech Stack
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
watched. Two places carry that number and they move together: `BUN_SERIES` in
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
## Data
Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
catalogue served by a process with no login, no session and no user, so sharing a connection would put
an unauthenticated surface one query away from every private table. It runs only on the host serving
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
what is inside it.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
failing.
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
passed).
### Schema changes use `push`, not migrations
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
history was deleted because it had drifted from the real schema. Treat the schema code, not those
files, as the source of truth.
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
"Composite keys" before adding either.
## Security Model
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
rules key off it — they fail closed.
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
is a valid token on every protected route plus the permission gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts.
### Permissions — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
survive the next door; refusing to boot does.
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time.
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
into a refusal.
## Commands
```bash
bun dev # the whole app — SPA + API + WebSockets, watched
bun start # production
bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
bun setup # runs scripts/install.sh — blank machine to running platform
```
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
place to drift, and it drifts in the same edit that misses the doc.
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
written had been wrong for over a week.
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
needs to, and on macOS never does, because Homebrew refuses to run as root.
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
See `docs/working-on-officer.md` for which process a given change needs restarted.
### Installs are frozen. Never resolve a dependency you did not ask for.
`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. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
`error: lockfile had changes, but lockfile is frozen`.
It is config rather than a 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, `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. It reached 434 further packages across 1,381 versions. This machine was
unaffected only because nothing had installed since 2026-08-02.
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
in a diff is the signal this exists to produce.
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
## Code Style
- **Paradigm**: functional — pure functions, immutability, composition
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
- **Async**: always async/await
- **Exports**: named only, no defaults
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
### Imports
Order: types → external → workspace → relative.
```ts
import type { User } from 'types';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { formatDate } from 'helpers/formatters';
import { useWebsites } from '../useWebsites';
```
In app code import types from `'types'`, which re-exports the database types. Only server and
database code imports from `officerdb/types` directly.
### Functions
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
parameter lists — extract a params type instead.
```ts
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
export function processData({ body, query }: ProcessDataParams): Result { ... }
```
### Components and hooks
```tsx
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
```
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
always named `ev`, never `e`.
### API calls
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
type parameter and let inference flow from it.
## Working With Me
- **Ask first** — confirm the approach before a significant change
- **Explore thoroughly** — read the related files before editing
- **Keep it simple** — no over-engineering, no premature abstraction
- **Be explicit** — no magic, no implicit behaviour
- **Stay focused** — note unrelated problems, don't fix them uninvited
- **Report honestly** — say what you verified and what you didn't
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
withholding the commit until it is good.
Commit messages: simple lowercase, no prefixes.
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
the two-step form only looks like it does.
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
follow-up note instead. Cheap to avoid, awkward to undo.
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
wrong commit, and only because the tag was checked afterwards did that surface at all.
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
```
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
```
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
restart in a single night. The transcript id survived both.
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
whose "why" is still answerable in six months.
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
only exposure is that a background task's output path contains it, which means starting a job to learn who
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
add the trailer and one that does not should leave it out rather than guess.
## Frontend route conventions (apply to EVERY new dashboard route)
Worked examples: `/soulseek`, `/music`, `/chat`.
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
`<WorkspaceView workspace={ws} locked />` where
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
that pins `appType`s to an allow-list. Panels are windowed apps under
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
`usePublishPageTitle` in `officerdev`.
### The URL is the source of truth for selection
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
link-focusable). Half the app still does this; none of the new code should.
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
_mutates_ rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
canonicalises both the bare route and a bogus param.
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
Action buttons are **siblings** of the anchor, never nested inside it.
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
when you want `isActive`.
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
## COMMS — a channel between agents, when one is open
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
sessions.
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
convention rather than an oversight: a spent channel left in place gets read as current.
If you open one:
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
agent read identically.
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
participant. "I think we're done" can close a thread with work still in it.
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
lands, delete the channel and move anything still open to `TODO.md`.
Two things that made it work, and neither is about either agent being more careful. One writes, the other
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
explaining why it was safe.
## Further Reading
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
a number produced by searching is confidently wrong with no signal that it is
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
question to settle first, which is retention rather than anything technical
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
agent. Read before writing one
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
it is what `docs/workspace-panel-todo.md` is ranked against.
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
persistence key families. Read before building a panel app. Its defect list is
`docs/workspace-panel-todo.md`.
- `docs/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
check turns into an endless resync between servers without it. The server identifier is an SSO decision
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
before any agent is asked to look
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
one home directory happened to be mode 751
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
because otherwise "add a role" and "become the owner" are the same feature
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
anything it already rules out
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
conversation. The CLAUDE.md in its own working directory is the living document, for free
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
`proxy-manager` ships a chat brief today)
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
a namespace makes provenance readable, separate directories make it structural
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
get wrong — a team folder that is not `setgid` charges nothing to the team
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
- `src/apps/CLAUDE.md` — shared frontend patterns
- `src/databases/CLAUDE.md` — database patterns
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
Treat the code as the source of truth where anything here disagrees with it.
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
+43 -42
View File
@@ -22,43 +22,44 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**100 open, 31 closed**, across 33 themes.
**107 open, 32 closed**, across 34 themes.
| Theme | Open | Closed |
| ------------------------------------------------------------------------------------------ | ---- | ------ |
| [Affordances](todo/affordances/) | 2 | 0 |
| [Agents coordinating agents](todo/agent-coordination/) | 4 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
| [Backburner](todo/backburner/) | 3 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 1 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 1 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 5 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [SendOff](todo/sendoff/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 2 | 0 |
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
| [Backburner](todo/backburner/) | 3 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 1 |
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 2 | 0 |
| [File watchers](todo/file-watchers/) | 1 | 0 |
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 7 | 0 |
| [Known bugs](todo/known-bugs/) | 6 | 4 |
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 5 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 |
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
| [Prompt library](todo/prompt-library/) | 1 | 0 |
| [SendOff](todo/sendoff/) | 1 | 0 |
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
| [Transmission](todo/transmission/) | 6 | 0 |
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning
@@ -69,13 +70,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Newest ten
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
- `2026-08-25` **Sweep: AGENTS.md becomes the real instructions everywhere, CLAUDE.md a pointer beside it** — [docs-conventions/sweep-agents-md-primary-claude-md-pointer](todo/docs-conventions/sweep-agents-md-primary-claude-md-pointer.md)
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
+119
View File
@@ -0,0 +1,119 @@
# Deploy report — 2026.08.26.02
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`).
Tag `deploy/2026.08.26.02` → `121a77ce`. `package.json` at that commit reads `2026.08.26.02` — tag and
version agree. Every host ended at `121a77ce` exactly, merged by `--ff-only` against the tag rather than
the branch.
Second release under this watcher, and the second the loop has executed. The claim was taken by the
watcher before this agent was spawned; this agent recorded `done` after the rollout.
## The release changed no schema, and that makes the `db:push` reading solid
This release touches five files: two deploy documents, `package.json`, and
`src/workspaces/state/useAuthoredLayout.ts` with its test. **No `schema.ts` changed**, so drizzle's desired
state is byte-identical before and after the merge. Anything `push` wants is pre-existing drift, deductively
rather than by assumption.
Per doctrine, exit 0 was not taken as evidence. Every host was run with `--verbose` to read the statements
push actually wanted, and then queried directly.
**The standing diff from `2026.08.26.01` is unchanged and is present on all five:**
```sql
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
```
`alpha` additionally wants the documented drop-and-re-add of `pk_music_now_playing`. Nothing new appeared on
any host — the set of wanted statements is identical to last night's, host for host.
Verified by query, per host, not inferred:
| Host | `command_hq_sessions.reference_paths` | `pk_music_now_playing` | `roles` |
| ---------------------------- | ------------------------------------- | ---------------------- | ------- |
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
Each database is already in the state the schema code asks for. The rollout was not stopped for it: the
plan's stop condition is "if it wants to alter something", and this is drizzle re-emitting a statement
whose effect is already in place. **That stop signal is still spent**, exactly as last night's report said —
worth fixing at the schema code, and it is the one thing here the owner should act on.
No direct SQL was run anywhere. None was authorised and none was needed.
## Per host
Steps in plan order on every host: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
`pm2 restart officer`. No `bun install` anywhere — the plan forbids it and no dependency changed.
`officer-claude-code` was not restarted anywhere, per the plan.
### `alpha` — OK
- `e9f1760f` → `121a77ce`. Tree clean, no untracked files.
- `db:push` exit 0; standing diff plus `pk_music_now_playing`; database verified correct by query.
- `plugins:update` 14/14 in 55s. That step bounces roughly a dozen sidecars itself (`sidecar: started` per
plugin) — more moved on this host than the `officer` line below suggests.
- `officer` restarted: `online`, `restart_time` 4 → 5.
### `fofos` — OK
- `a7c9dab2` → `121a77ce`, the short fast-forward the plan predicted: it adds only the version bump and the
plan file, since the fix itself was hand-applied here at 05:22. Not drift.
- One untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward cannot touch it, and
nobody's work was discarded.
- `db:push` exit 0; standing diff only; verified by query. 43 tables in `public`.
- `plugins:update` 3/3 in 21s (`browser`, `gitea`, `proxy-manager`).
- `officer` restarted: `online`, `restart_time` 31 → 32.
- **This is a host the release actually matters on** — it has the `browser` plugin whose layout names
`officerdev/chat`.
### `production-pertento-bridge` — OK
- `e9f1760f` → `121a77ce`. Tree clean.
- `db:push` exit 0; standing diff only; verified by query.
- `plugins:update` 1/1 in 4s.
- `officer` restarted: `online`, `restart_time` 9 → 10.
### `deadpool-rezio-bridge` — OK
- Logged in as `br`, per `docs/servers.md`.
- `e9f1760f` → `121a77ce`. Tree clean.
- `db:push` exit 0; standing diff only; verified by query.
- `plugins:update` 2/2 in 7s.
- `officer` restarted: `online`, `restart_time` 15 → 16.
### `macbookpro` — OK, and it was awake
- `e9f1760f` → `121a77ce`. Tree clean.
- `PATH` appended with `/opt/homebrew/bin` for the `bun` steps; `pm2` run as a separate command on the
interactive shell, so nothing was shadowed.
- `db:push` exit 0; standing diff only; verified by query.
- `plugins:update`: `no plugins installed — nothing to update`. Not a failure.
- `officer` restarted: `online`, `restart_time` 17 → 18.
## Skipped, and why
- **`officer-dev`** — the host this agent runs on. Not pulled, nothing restarted. It already holds this
code at `121a77ce`. Its working tree carries another session's uncommitted changes
(`src/servers/hono.ts`, `src/workspaces/hooks/src/useClient.ts`, an untracked
`src/servers/hono-error-shape.test.ts`) — untouched. **The owner restarts `officer` here deliberately,
as the last step.**
- **`edge-pertento-bridge`** — skipped on the plan's instruction: 300+ commits behind with no `roles`
table, its catch-up is a by-hand job, do not contact it. It was not contacted.
Note the disagreement, because an orchestrator reading the wrong document gets this wrong: `docs/servers.md`
lists this host in the platforms table as a deploy target again, with an empty "Deliberately skipped"
section. The **plan** overrides it, and the plan is right — a `db:push` on a host with no `roles` table is
the exact shape of change this doctrine refuses. `docs/servers.md` is stale on this point.
## What was not verified
**The fix itself was not clicked.** The plan's check is "open `/browser`, hard refresh, confirm it renders
rather than showing 'This screen failed to render'". That is a browser action on a logged-in session, and
this agent has no session on any of these hosts. What is verified is that the code carrying the fix is at
`HEAD` on every target and that `officer` came back `online` on each. **The one-click confirmation is
still owed, on a host with the `browser` plugin — `fofos` or `alpha`.**
+37
View File
@@ -0,0 +1,37 @@
# deploy/2026.08.27.01
55 commits since `deploy/2026.08.26.02`. A full day's work, most of it in two areas.
## What is in it
**Calendar and contacts became usable rather than readable.** Create, edit and delete on both screens; a
filter on each; `?collection=<name>` instead of an escaped DAV path. The agent skill was rewritten off
hand-written iCalendar onto JSON routes — an editor now patches the stored text rather than rebuilding it,
so alarms, attendees and recurrence rules survive an edit made from a form that cannot show them.
**OpenCode can reach the platform.** It gets the skills index, an Officer identity instead of "you are a
coding agent", an API credential, and its permission prompts answered — a server session has nobody to
answer them and a turn simply hung. Its chats also keep their name, their transcript and their model when
reopened.
**Smaller, and each one somebody hit today:** `/api/notify/user` (and `/broadcast`, which had answered 404
since it landed — shadowed by the sidecar proxy's catch-all); a commander started from a plugin chat, so
pointing one at a mailbox no longer needs a path only the owner can type; file preview inside a
commander's own browser; a dock tile for `/bots`; and the new wordmark in the header, following the theme.
## Risk
Low-to-moderate. Nothing here changes a schema. The two worth watching after the rollout:
- **`useThemeAsset`** now reads in `requestAnimationFrame` rather than during the effect. It fixes the
logo and the wallpaper being one theme behind; if a themed asset ever fails to appear, this is the first
place to look.
- **The notify router's mount order.** The platform's own routes now sit before the sidecar proxy's
catch-all. Device registration still forwards — verified — but it is the change most likely to surprise.
## Hosts
Seven. `officer-dev` is skipped: it is the host running the rollout, and restarting it ends the rollout.
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
+78
View File
@@ -0,0 +1,78 @@
# 2026.08.27.02
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
claimed and undeployed; do not try to revive it.
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
## The schema question, answered per host
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
Surveyed 2026-08-28, every host:
| Host | `plugins/caldav/` | `dav_app_passwords` |
| --- | --- | --- |
| `alpha` | present | **8 rows** |
| `fofos` | absent | present, 0 rows |
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
| `macbruno` | absent | present, 0 rows |
| `production-pertento-bridge` | absent | present, 0 rows |
| `edge-pertento-bridge` | absent | present, 0 rows |
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
holds anything:
```
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
# anything else → STOP. Report the count and do not continue on this host.
```
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
4. `bun run plugins:update`
5. `pm2 restart officer`
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
nothing about OpenCode changes.
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
normally and must report no changes.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
it, and it is the owner's call.
## Risk
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
count and it is skipped on the one host where the table has contents.
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
device registration is worth one check (it forwards correctly on `officer-dev`).
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout.
+96
View File
@@ -0,0 +1,96 @@
# 2026.08.27.02 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
was dirty in tracked files, none diverged, none needed a skip.
## Per host
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
| --- | --- | --- | --- | --- | --- |
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
process was left `errored` or `stopped` anywhere.
## The schema, verified by query per host
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
not inferred from an exit code. Final sweep:
```
alpha caldav=yes dav_app_passwords=present rows=8
fofos caldav=no dav_app_passwords=GONE
macbookpro caldav=no dav_app_passwords=GONE
macbruno caldav=no dav_app_passwords=GONE
production-pertento-bridge caldav=no dav_app_passwords=GONE
edge-pertento-bridge caldav=no dav_app_passwords=GONE
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
```
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
are intact.
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
under the plan's own guard rather than under an assumption.
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
`officer-marketplace`, confirmed zero of seven.
## `bun db:push` prints `Changes applied` even when it changes nothing
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
unchanged database. It is unconditional output, not evidence.
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
real content was:
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
`pk_music_now_playing` as the same two-column key
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
said it would.
Not fixed here. A rollout is the wrong place for a schema-code change.
## Plugins
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
deadpool 2/2, macbookpro none installed.
## What was not done
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
owner restarts it deliberately.
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
knowing.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
touch it and it was left alone.
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
## Worth a look, per the plan's own risk note
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
router's mount order changed (device registration is worth one check).
+71
View File
@@ -0,0 +1,71 @@
# 2026.08.28.01
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
Two commits since `2026.08.27.02`, one of which is that release's own report.
## No schema change. No dependency change. No plugin change.
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
plan got wrong, so it is stated here as a check that was run rather than a belief.
## `bun db:push` is deliberately NOT a step
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
mis-diff in `src/databases/CLAUDE.md`.
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
2. `pm2 restart officer`
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
boot.
## Verify, per host
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
401 or 403 means mounted, 404 means the bundle did not move:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
## Risk
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
impersonated — that last one is what stops it chaining into owner powers.
Nothing to roll back but the merge: no data is written and no schema is touched.
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
+46 -36
View File
@@ -9,14 +9,14 @@ read-in-place rule this mechanism is the one deliberate exception to).
## The shape of it
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files
that merely *mention* `CLAUDE.md` in a comment suggests.
that merely _mention_ `CLAUDE.md` in a comment suggests.
| # | Site | Verb | Cardinality | Trigger |
|---|------|------|-------------|---------|
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start |
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
| # | Site | Verb | Cardinality | Trigger |
| --- | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- |
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory, plus one per email account | every plugin-panel chat start, and every email chat start |
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
Two things fell out of tracing this that are worth stating up front, because they explain why the
mechanism looks the way it does rather than something simpler:
@@ -39,7 +39,7 @@ mechanism looks the way it does rather than something simpler:
Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the
row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering
is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a
is load-bearing: a turn that spawned before the directory existed used to recreate an _empty_ one on a
race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first
and the directory has to exist before a turn can reach it.
@@ -68,7 +68,7 @@ to be reading the file yet.
**What happens after creation is the interesting part: nothing does, from the platform's side.** The
file is described everywhere as "a living document its commander has been extending" — the commander
itself edits it during its own life, the same file a human would open in the file browser. The platform
never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with
never rewrites it. That is _why_ item 3 below has to append rather than regenerate: a commander with
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
### 2. Plugin chat briefs — the copier
@@ -95,8 +95,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
}
```
Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
Called from `resolveChatCwd()` from **two** branches, and the second one is the interesting case:
- `msg.context === 'plugin'` — the plugin's own chats directory. The ordinary path.
- `msg.context === 'email'` — the **account's** directory, the one holding `emails.db`.
Never for a commander and never for an ordinary `/chat` session.
Email is the exception because an email chat does not run in the plugin's chats directory: it runs beside
the database it exists to answer from, which is the entire point of that context. So the brief follows the
chat rather than the chat moving to the brief, and `plugins/email/chat/CLAUDE.md` lands in
`email_accounts/<address>/`. A second plugin wanting this would be the signal that "which directory does
this plugin's chat run in" belongs in the manifest rather than in a branch here. That branch also does the `mkdirSync`
for the chat directory itself, so the two writers (this one and the commander generator) can never
target the same directory: a commander's cwd is `$DATA_PATH/<email>/commanders/<dirName>/`, a plugin
chat's is `$DATA_PATH/<email>/plugins/<appName>/chats/`.
@@ -105,7 +115,7 @@ The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships
read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs
no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on
an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called
once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
once per chat _start_, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the
per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A
@@ -178,11 +188,11 @@ specifically to stop a future change from "fixing" this back in.
what fixed it.
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs
un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land
*inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
_inside_ `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
reads. Writing one there would silently mix generated content into a document that is supposed to be
entirely the owner's.
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that
the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
the _old_ `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
`settings.json` describing a user's Docker container, as part of an architecture that no longer exists.
What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
generated inside either.
@@ -199,28 +209,28 @@ at the top of the plugin's own repository, picked up automatically the moment th
starts a conversation. There is no manifest field for this and nothing to register — the platform simply
checks whether the file exists at that fixed path within the plugin's directory on every chat start.
| Plugin | `chat/CLAUDE.md` | Notes |
|---|---|---|
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
| `atelier` | no | |
| `bitcoin-wallet` | no | |
| `browser` | no | |
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
| `email` | no | |
| `example` | no | |
| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
| `invoices` | no | |
| `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | |
| `music` | no | |
| `offscale` | no | |
| `photos` | no | |
| `soulseek` | no | |
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
| Plugin | `chat/CLAUDE.md` | Notes |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
| `atelier` | no | |
| `bitcoin-wallet` | no | |
| `browser` | no | |
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
| `email` | no | |
| `example` | no | |
| `gitea` | no | Ships an _affordance_ (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
| `invoices` | no | |
| `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | |
| `music` | no | |
| `offscale` | no | |
| `photos` | no | |
| `soulseek` | no | |
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that
mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code
+39 -10
View File
@@ -21,21 +21,50 @@ ssh br@deadpool-rezio-bridge # not implied
## Officer platforms
Seven, all running.
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
| Host | Tailnet IP | Login | Checkout | Domain |
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
| Host | Tailnet IP | Login | Port | Checkout | Domain |
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
| `officer-dev` | 100.64.0.13 | `pastilhas` | 9000 | `~/officer-dev/platform` | `platform.officer.dev` |
| `alpha` | 100.64.0.1 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
`.env`. This is not a footnote: on 2026-08-27 a health check against 9000 on `production-pertento-bridge`
hit one of Pertento's own apps, which answered `403`, and that was read as Officer failing after a
deploy. Take the port from the `.env`, never from habit.
**`production-pertento-bridge` also runs eight processes that are NOT Officer** — `Dashboard`, `Tracking`,
`Uptime` and five `Crons *`. They are production-critical and belong to Pertento. Anything sweeping over
`pm2 list` must filter to `officer` and `officer-*`, and a restart there must name its target.
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## `pm2` is not on the PATH of a non-interactive shell
`ssh host 'pm2 jlist'` runs a NON-interactive, NON-login shell. zsh reads `~/.zshenv` for those and reads
neither `~/.zshrc` nor `~/.zprofile` — so a pm2 installed by nvm or Homebrew is invisible to an
orchestrator even though the daemon is running and healthy.
**This read as "pm2 is not installed" three times on 2026-08-27**, and twice sent someone diagnosing a
machine with nothing wrong with it. `claude` on a fresh install has the same shape.
Fixed at the source on the two hosts where it bit — `production-pertento-bridge` (nvm) and `macbruno`
(Homebrew at `/opt/homebrew`, which is on no default PATH on Apple Silicon) — by appending a
marker-guarded block to `~/.zshenv`. Everything else already worked.
If a command comes back empty over SSH, look for the binary on disk before concluding it is absent:
```bash
ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin/pm2 2>/dev/null'
```
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
-6
View File
@@ -1,6 +0,0 @@
{
"host": "127.0.0.1",
"port": 3333,
"enable_request_logging": false,
"last_updated": "2025-12-24T04:27:17.443375"
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.26.02",
"version": "2026.08.28.01",
"private": true,
"type": "module",
"workspaces": [
@@ -39,7 +39,8 @@
"setup": "bash scripts/install.sh",
"check:theme": "bun scripts/check-theme-tokens.ts",
"docs:check": "bun run scripts/docs-check.ts",
"skills:platform": "bun run scripts/gen-platform-skill.ts"
"skills:platform": "bun run scripts/gen-platform-skill.ts",
"plugins:check": "bun run scripts/plugins-check.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+92
View File
@@ -0,0 +1,92 @@
# Plugins
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
plugin author needs that are not procedure.
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
appears by `git clone` when somebody installs one, and is gitignored here.
---
## Affordances
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
the file format and how they reach the agent. Read it before writing one. What follows is only the part
that is specific to shipping one from a plugin.
> An affordance is something the surface on the other end of the conversation offers the agent — declared
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
> client will honour.
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
inside the file.
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
that it was a bug. See the two worked examples in `docs/affordances.md`.
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
have nothing to do with your plugin.
### A plugin-contributed one, for reference## Notifications — the same shape, for push
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
```
capabilities/notifications/
officerdev/music/
catalogue.yaml
```
```yaml
nowPlaying:
title: 'Now playing'
route: 'officer://music/{id}'
```
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
and the device renders the title and follows the route. The point is that **adding a notification is a
server change and never an app build**, because the mapping is synced to the device rather than compiled
into it.
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
catalogue is skipped without affecting anyone else's.
See `docs/push-notifications.md`.
### A plugin-contributed one, for reference
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
```markdown
---
name: Gitea
description: Use when working with git repositories, or with Gitea issues and pull requests.
version: 1
---
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
to the server directly.
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
configure it, and do not ask the user for a token.
```
### On the word
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
`capabilities/skills/` here and by the wider AI ecosystem.
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
other end permits. Keeping that distinction in the name saves explaining it every time.
+3 -89
View File
@@ -1,92 +1,6 @@
# Plugins
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
plugin author needs that are not procedure.
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
appears by `git clone` when somebody installs one, and is gitignored here.
---
## Affordances
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
the file format and how they reach the agent. Read it before writing one. What follows is only the part
that is specific to shipping one from a plugin.
> An affordance is something the surface on the other end of the conversation offers the agent — declared
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
> client will honour.
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
inside the file.
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
that it was a bug. See the two worked examples in `docs/affordances.md`.
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
have nothing to do with your plugin.
### A plugin-contributed one, for reference## Notifications — the same shape, for push
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
```
capabilities/notifications/
officerdev/music/
catalogue.yaml
```
```yaml
nowPlaying:
title: 'Now playing'
route: 'officer://music/{id}'
```
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
and the device renders the title and follows the route. The point is that **adding a notification is a
server change and never an app build**, because the mapping is synced to the device rather than compiled
into it.
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
catalogue is skipped without affecting anyone else's.
See `docs/push-notifications.md`.
### A plugin-contributed one, for reference
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
```markdown
---
name: Gitea
description: Use when working with git repositories, or with Gitea issues and pull requests.
version: 1
---
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
to the server directly.
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
configure it, and do not ask the user for a token.
```
### On the word
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
`capabilities/skills/` here and by the wider AI ecosystem.
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
other end permits. Keeping that distinction in the name saves explaining it every time.
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+12 -4
View File
@@ -64,7 +64,15 @@ for (const row of rows) {
continue;
}
const claudeMd = join(dir, 'CLAUDE.md');
/*
AGENTS.md where it exists, CLAUDE.md where it does not.
Since 2026-08-27 a commander's brief is written as AGENTS.md with a CLAUDE.md pointing at it, because
opencode discovers only the first name. Commanders created BEFORE that have the content in CLAUDE.md
and no AGENTS.md at all. Appending to the wrong one is the quiet failure this avoids: on a new
commander it would bury a credential section inside a six-line pointer the agent never reads.
*/
const brief = existsSync(join(dir, 'AGENTS.md')) ? join(dir, 'AGENTS.md') : join(dir, 'CLAUDE.md');
/**
* Append a section only when it is not already described.
@@ -73,10 +81,10 @@ for (const row of rows) {
* describe the same thing does not end up with it twice.
*/
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
const existing = existsSync(brief) ? readFileSync(brief, 'utf8') : '';
if (existing.includes(marker)) return false;
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
await giveToAccount(row.osUser, claudeMd);
appendFileSync(brief, `\n${lines.join('\n')}`);
await giveToAccount(row.osUser, brief);
return true;
};
+1 -1
View File
@@ -89,7 +89,7 @@ async function main(): Promise<void> {
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
record(
checkCount(
join(ROOT, 'CLAUDE.md'),
join(ROOT, 'AGENTS.md'),
/([0-9]+|[a-z-]+) entries as of/gi,
PERMISSIONS.length,
'permission registry entries',
+18 -1
View File
@@ -66,7 +66,19 @@ const git = (dir: string, args: string[]): string | null => {
return new TextDecoder().decode(result.stdout).trim() || null;
};
const PLUGINS_DIR = resolve(import.meta.dir, '..', 'plugins');
/*
Which plugins directory to publish FROM.
There are two, and they are not the same thing. `platform/plugins/` holds what this machine has
INSTALLED — updated by `plugins:update`, which is a git pull, and therefore always at whatever version
was last installed. `officer-dev/plugins/` holds every repo in the org, cloned for working on, and is
where a release is actually made.
Publishing from the install directory means the catalogue advertises the version this machine happens
to be running rather than the version that was released. That is exactly what happened on 2026-08-27:
21 plugins were tagged and the store went on showing 1.0.0.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
async function syncOne(dir: string): Promise<Sync | null> {
const appName = basename(dir);
@@ -109,6 +121,11 @@ async function syncOne(dir: string): Promise<Sync | null> {
platform: String(manifest.platform ?? '>=1.0.0'),
label: String(manifest.label ?? appName),
summary: String(manifest.summary ?? ''),
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
// quietly disagreeing with the repository it claims to mirror.
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
color: (manifest.color as string) ?? null,
tile: (manifest.tile as string) ?? null,
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
+161
View File
@@ -0,0 +1,161 @@
/*
Is every plugin checkout still a well-formed clone of its own repository?
── Why a script and not care ──
On 2026-08-27 one commit had to go into all twenty-one plugin repos. Seven pushes failed, each for a
different reason, and none of the reasons had anything to do with the commit:
· six remotes were HTTPS, which cannot authenticate non-interactively — they simply hung and failed
· three checkouts were behind their published remote, so the push was rejected as non-fast-forward
· one (`offscale`) had a fetch refspec and an upstream naming a branch called `main` that does not
exist on the remote. That one is the reason this file exists: every command reported on a phantom.
`git push` said "Everything up-to-date" while `git status` said the branch had diverged, and the
repository was actually fine. Twenty minutes went into a repo with nothing wrong with it.
· one remote URL had no `.git` suffix, so the catalogue published a clone URL unlike every other
None of that is visible until you try to push, which is the worst possible moment to find out — you are
mid-task, across twenty-one repositories, and the failures look like they are about your change.
── What it checks, and what it deliberately does not ──
Only mechanical facts with one right answer: the remote's URL form, the fetch refspec, the branch, its
upstream, and whether the checkout has diverged. It does not judge content, does not fetch by default
(that would make a local check depend on the network), and never writes — `--fix` is a separate flag so
that running the check can never be the thing that changes your tree.
*/
import { readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
/*
Which plugins directory to check.
There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and
`officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different
sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to.
*/
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
const HOST = 'git@gitea.officer.dev';
const BRANCH = 'master';
const REFSPEC = '+refs/heads/*:refs/remotes/origin/*';
const FIX = process.argv.includes('--fix');
const FETCH = process.argv.includes('--fetch');
const git = (dir: string, args: string[]): string | null => {
const p = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'pipe' });
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
};
type Problem = { plugin: string; what: string; fix?: () => void };
const problems: Problem[] = [];
const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true })
// `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to
// `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every
// symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which
// would have made this report "0 plugin repositories, all well-formed" — a green check over nothing.
// `discover.ts` already does it this way, at line 239, for the same reason.
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
.filter((e) => {
try {
return statSync(join(PLUGINS_DIR, e.name)).isDirectory();
} catch {
return false; // a broken symlink, which the checks below have nothing useful to say about
}
})
.map((e) => e.name)
.filter((name) => existsSync(join(PLUGINS_DIR, name, '.git')))
.sort();
/*
A repo with no `manifest.ts` is not a plugin, and that is not drift.
`av-club` is the case: a standalone media service living in the plugins org, intended to become a
plugin in time. Checking it against a plugin's conventions would report its branch name as a defect
forever, and a check that cries wolf is one people stop reading.
Presence is the declaration here exactly as it is in `plugins/discover.ts` — the manifest is what makes
something a plugin, so its absence is what makes something not one yet.
*/
const notYet = dirs.filter((name) => !existsSync(join(PLUGINS_DIR, name, 'manifest.ts')));
const checkable = dirs.filter((name) => !notYet.includes(name));
for (const name of checkable) {
const dir = join(PLUGINS_DIR, name);
const expected = `${HOST}:plugins/${name}.git`;
// 1. The remote, exactly. SSH because that is what can push unattended, and `.git` because a URL unlike
// every other is the one an installer trips over.
const url = git(dir, ['remote', 'get-url', 'origin']);
if (url !== expected) {
problems.push({
plugin: name,
what: `remote is ${url ?? 'missing'}, expected ${expected}`,
fix: () => void git(dir, ['remote', 'set-url', 'origin', expected]),
});
}
// 2. The fetch refspec. A narrowed one (a --single-branch clone) hides every other branch; a WRONG one
// makes git report on a branch that does not exist, which is the failure that cost the most time.
const refspecs = (git(dir, ['config', '--get-all', 'remote.origin.fetch']) ?? '').split('\n').filter(Boolean);
if (refspecs.length !== 1 || refspecs[0] !== REFSPEC) {
problems.push({
plugin: name,
what: `fetch refspec is ${refspecs.join(' + ') || 'unset'}, expected ${REFSPEC}`,
fix: () => {
git(dir, ['config', '--unset-all', 'remote.origin.fetch']);
git(dir, ['config', '--add', 'remote.origin.fetch', REFSPEC]);
},
});
}
// 3. The branch, and what it tracks.
const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
if (branch !== BRANCH) {
problems.push({ plugin: name, what: `on branch ${branch ?? '?'}, expected ${BRANCH}` });
continue; // the checks below are all about this branch
}
const upstream = git(dir, ['config', '--get', `branch.${BRANCH}.merge`]);
if (upstream !== `refs/heads/${BRANCH}`) {
problems.push({
plugin: name,
what: `${BRANCH} tracks ${upstream ?? 'nothing'}, expected refs/heads/${BRANCH}`,
fix: () => void git(dir, ['branch', `--set-upstream-to=origin/${BRANCH}`, BRANCH]),
});
}
// 4. Uncommitted work, and divergence. Not fixable here — both need a human decision about the content.
const dirty = git(dir, ['status', '--porcelain']);
if (dirty) problems.push({ plugin: name, what: `${dirty.split('\n').length} uncommitted file(s)` });
if (FETCH) git(dir, ['fetch', '-q', 'origin']);
const counts = git(dir, ['rev-list', '--left-right', '--count', `origin/${BRANCH}...HEAD`]);
if (counts) {
const [behind, ahead] = counts.split(/\s+/).map(Number);
if (behind) problems.push({ plugin: name, what: `${behind} commit(s) behind origin/${BRANCH}` });
if (ahead) problems.push({ plugin: name, what: `${ahead} commit(s) NOT PUSHED` });
}
}
const skipped = notYet.length ? ` (${notYet.length} not a plugin yet: ${notYet.join(', ')})` : '';
if (!problems.length) {
console.log(`plugins:check — ${checkable.length} plugin repositories, all well-formed${skipped}`);
process.exit(0);
}
for (const p of problems) console.error(` ${p.plugin}: ${p.what}`);
if (FIX) {
const fixable = problems.filter((p) => p.fix);
for (const p of fixable) p.fix!();
console.error(`\nplugins:check --fix — repaired ${fixable.length} of ${problems.length}; re-run to confirm.`);
console.error('Divergence and uncommitted work are never repaired here: both need a decision about content.');
} else {
console.error(
`\nplugins:check — ${problems.length} problem(s). \`bun run plugins:check --fix\` repairs the mechanical ones.`,
);
}
process.exit(1);
+67 -26
View File
@@ -100,6 +100,11 @@ fail() {
ANSWERS_FILE="${ANSWERS_FILE:-}"
# DOCKER_NETWORK included so officer-setup's Proxy step lands on the SAME bridge Docker
# was told to use here, rather than each script independently defaulting to "services" and
# happening to agree only because nobody has customised either yet. lib/docker.sh sets
# DOCKER_NETWORK at source time (SETUP_DOCKER_NETWORK env var, else "services"), so it is
# always defined by the time this runs, regardless of whether the Docker step itself ran.
save_answers() {
[[ -n "$ANSWERS_FILE" ]] || return 0
cat >"$ANSWERS_FILE" <<EOF
@@ -107,6 +112,7 @@ save_answers() {
MACHINE_ROLE=${MACHINE_ROLE}
SETUP_USERNAME=${USERNAME}
OFFICER_ROOT=${OFFICER_ROOT}
SETUP_DOCKER_NETWORK=${DOCKER_NETWORK}
EOF
chmod 600 "$ANSWERS_FILE"
}
@@ -165,25 +171,39 @@ ONLY_STEP="${ONLY_STEP:-}"
# Keyed on the step title, so the sections themselves stay Linux code with no
# `if macos` branches threaded through them. The reason is printed, because a
# silent skip and a missing step look identical.
declare -A MACOS_SKIP=(
["User account"]="accounts are System Settings' business on a Mac, not a script's"
["Disk space"]="ballast and swap tuning are server concerns"
["Locale"]="macOS manages locale itself"
["Timezone"]="macOS manages the timezone itself"
["Swap"]="macOS sizes its own swap dynamically"
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
["Boot hang"]="a systemd boot ordering fix"
["SSH access"]="hardening a door a dev machine should not be opening"
["DNS"]="systemd-resolved"
["Network address"]="netplan, and a laptop moves between networks by design"
["fail2ban"]="brute-force protection for an exposed SSH port"
["Unattended upgrades"]="apt; macOS updates through Software Update"
["Firewall"]="ufw; macOS has its own application firewall"
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
)
#
# A `case` rather than an associative array — `declare -A` is bash 4+, and macOS's own
# stock `/bin/bash` is 3.2 (Apple hasn't shipped a newer one since bash went GPLv3), with
# nothing newer installed on a fresh machine. An array keyed on the step title would parse-
# fail the instant this file is sourced, before a single step could run — which is exactly
# what happened here until it was measured. Prints the reason on stdout, so the caller reads
# it with `$(...)`; exit status says whether the title matched anything at all.
macos_skip_reason() {
case "$1" in
"User account") echo "accounts are System Settings' business on a Mac, not a script's" ;;
"Disk space") echo "ballast and swap tuning are server concerns" ;;
"Locale") echo "macOS manages locale itself" ;;
"Timezone") echo "macOS manages the timezone itself" ;;
"Swap") echo "macOS sizes its own swap dynamically" ;;
"Emergency disk ballast") echo "a server trick for a machine nobody is sitting at" ;;
"earlyoom") echo "Linux OOM killer tuning; macOS has its own memory pressure handling" ;;
"inotify watch limit") echo "Linux inotify; macOS watches files through FSEvents" ;;
"Sleep and suspend") echo "a laptop SHOULD sleep — this stops a server from doing it" ;;
"Boot hang") echo "a systemd boot ordering fix" ;;
"SSH access") echo "hardening a door a dev machine should not be opening" ;;
"DNS") echo "systemd-resolved" ;;
"Network address") echo "netplan, and a laptop moves between networks by design" ;;
"fail2ban") echo "brute-force protection for an exposed SSH port" ;;
"Unattended upgrades") echo "apt; macOS updates through Software Update" ;;
"Firewall") echo "ufw; macOS has its own application firewall" ;;
"Shell") echo "zsh is already the default, and tmux is a choice you make yourself" ;;
*) return 1 ;;
esac
}
# `${var,,}` (case-insensitive compare below) is bash 4+ too — this is the portable
# pre-4 substitute, not a style choice.
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
step() {
CURRENT_STEP="$1"
@@ -191,16 +211,19 @@ step() {
# which one it is. Twenty-six sections, one place.
declare -F report_section >/dev/null && report_section "$1"
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
echo ""
echo -e "${BOLD}── $1 ──${NC}"
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
SKIP_STEP=true
return
if [[ "${OS:-}" == "macos" ]]; then
local _skip_reason
if _skip_reason="$(macos_skip_reason "$1")"; then
echo ""
echo -e "${BOLD}── $1 ──${NC}"
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${_skip_reason}"
SKIP_STEP=true
return
fi
fi
if [[ -n "$ONLY_STEP" ]]; then
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
@@ -508,6 +531,24 @@ ask_officer_root() {
# both of which abort the step.
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
# Run a command as the created account, e.g. `run_as_owner git clone ...`.
#
# On Linux this whole script runs as root — refused otherwise, see the pre-flight check
# above — so `sudo -u "$USERNAME"` from root needs no password; sudo lets root become
# anyone freely. On macOS the script deliberately never escalates (Homebrew refuses to run
# as root), so the process already IS "$USERNAME", and re-invoking sudo for the SAME
# account asks for a real password — potentially many times in one run, dev.sh calls this
# shape roughly ten times — or fails outright on an account with no sudo rights at all.
# `dev.sh`'s git/bun/deno/claude/opencode/editor helpers all route through this rather than
# hand-rolling `sudo -H -u "$USERNAME" ...` each time, so macOS behaviour lives in one place.
run_as_owner() {
if [[ "${OS:-}" == "macos" ]]; then
"$@"
else
sudo -H -u "$USERNAME" "$@"
fi
}
# Run a block as the created user (login shell, inherits HOME)
as_user() {
sudo -u "$USERNAME" -i bash -c "$1"
+35 -11
View File
@@ -47,8 +47,8 @@ MACHINE_SETUP_DEV_LOADED=1
# has never been configured — the fresh machine this script exists for — reading
# the current value aborted the run before the section had printed anything.
# Missing a value is an answer here, not a failure.
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
git_get() { (cd / && run_as_owner git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && run_as_owner git config --global "$1" "$2"); }
# Is there anything configured at all?
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
@@ -102,7 +102,7 @@ install_oh_my_zsh() {
# failed died here, silently, because the output is redirected: the run just
# stopped after apt finished installing zsh, with nothing said. Observed on a
# fresh Hetzner VPS, 2026-08-14.
sudo -H -u "$USERNAME" sh -c \
run_as_owner sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
true
# Belt and braces: `|| true` above already makes the last command succeed, and
@@ -155,7 +155,19 @@ nvim_latest_version() {
#
# The old install is removed only after the download has succeeded, so a failed
# fetch leaves the working copy alone.
#
# This had NO macOS branch at all until it was caught here: unconditionally fetched
# nvim-linux-<arch>.tar.gz regardless of $OS, which 404s on a Mac. Not a crash — the
# caller's `if nvim_install; then … else warn …` catches the failure and reports it as
# ERRORS+=("Neovim: install failed") rather than aborting the run — but Neovim never
# actually landed. brew has a real, current formula, so that's the macOS path instead of
# reaching for a macOS release asset the way lazygit's fix did.
nvim_install() {
if [[ "${OS:-}" == "macos" ]]; then
brew install neovim
return
fi
local asset tarball dest
asset="$(nvim_asset)"
tarball="/tmp/nvim-linux-${asset}.tar.gz"
@@ -187,11 +199,11 @@ nvim_clone_config() {
local repo="$1" dest="${USER_HOME}/.config/nvim"
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
(cd / && run_as_owner git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
# The starter is a template, not something to track. Left in place for a
# config of the user's own, which they will want to keep pulling.
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
[[ "$repo" == *LazyVim/starter* ]] && run_as_owner rm -rf "${dest}/.git"
return 0
}
@@ -230,6 +242,18 @@ node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
install_node() {
local major="$1"
# NodeSource's setup script adds an apt repository — it is Debian/Ubuntu-only by name
# and by mechanism (checks for dpkg internally), so there is nothing to point it at on
# macOS. Homebrew's `node`/`nodejs` formula (both names resolve, confirmed) tracks only
# the current release rather than an arbitrary major the way NodeSource's per-major
# scripts do — same tradeoff this codebase already accepts for Docker and Xcode CLT on
# macOS: brew's own version, not a pinned one.
if [[ "${OS:-}" == "macos" ]]; then
pkg_install_now nodejs
return
fi
# NodeSource publishes one setup script per major. Checked before it is piped
# into a shell, because a 404 page piped to bash is a confusing way to fail.
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
@@ -358,7 +382,7 @@ ensure_bun_symlink() {
install_bun() {
local tag="${1:-}"
[[ -n "$tag" ]] || return 1
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
(cd / && run_as_owner bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
}
@@ -367,7 +391,7 @@ install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
install_deno() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
(cd / && run_as_owner bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
}
@@ -434,16 +458,16 @@ agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" ==
agent_version() {
local bin
bin="$(agent_path "$1")"
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
[[ -n "$bin" ]] && (cd / && run_as_owner "$bin" --version 2>/dev/null | head -1)
}
install_claude_code() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin claude)" ]]
}
install_opencode() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin opencode)" ]]
}
@@ -471,7 +495,7 @@ editor_candidates() {
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
# assignment from a function that exits non-zero aborts the run under `set -e`.
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
current_editor() { (cd / && run_as_owner bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
set_system_editor() {
local editor="$1" path
+17 -9
View File
@@ -108,24 +108,25 @@ pkgs_core() {
# broken rather than a package being absent.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler radicale fail2ban
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils radicale fail2ban
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale
#
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler
;;
esac
}
@@ -253,13 +254,20 @@ pkg_install() {
# passed by value cannot be told apart from an empty one once it has been through
# word splitting.
#
# Copies by name via eval rather than `local -n` — namerefs are bash 4.3+, and macOS's
# stock bash is 3.2. This is the idiom namerefs replaced: build the local array by
# expanding the CALLER's array name inside a double-quoted eval, which bash 3.2 has always
# supported. `$2`/`$3` are trusted call-site literals (`present`, `missing`), never
# user input, so the eval is not taking anything untrusted.
#
# Returns non-zero when there is nothing to do, or when the answer was no — in
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
# refusal so the summary does not claim work that never happened.
announce_plan() {
local label="$1"
local -n _present="$2"
local -n _missing="$3"
local -a _present _missing
eval "_present=(\"\${$2[@]}\")"
eval "_missing=(\"\${$3[@]}\")"
echo ""
info "${label} — installs what is missing, keeps what you already have"
+27 -4
View File
@@ -59,12 +59,35 @@ authorized_key_count() {
has_authorized_key() { (($(authorized_key_count) > 0)); }
# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd
# ignores an authorized_keys file that is group- or world-writable, and does so
# silently from the client's point of view — the login just fails.
# Everything that has to be true for sshd to use ~/.ssh at all. sshd's StrictModes
# (on by default, and nothing here turns it off) checks the HOME DIRECTORY too, not
# just .ssh — this used to check only .ssh, which is the shape of bug that is hardest
# to notice: an account whose home came from somewhere other than THIS script's own
# adduser call (cloud-init, a prior useradd, LDAP, or simply an account that already
# existed) can have a group- or world-writable home directory, and sshd refuses
# authorized_keys under it silently. The .ssh directory and the key both look
# completely correct — because they are — and nothing here used to say why the
# login still failed.
fix_ssh_permissions() {
local dir
local dir home mode
dir="$(user_ssh_dir)"
home="$USER_HOME"
if [[ -d "$home" ]]; then
# GNU stat's -c, tried first since this step only ever runs on Linux (MACOS_SKIP)
# — with a BSD fallback anyway rather than a call that silently returns nothing on
# whatever this is next tested against, the same trap officer-setup's layout.sh
# file_owner() hit for the same reason.
mode="$(stat -c %a "$home" 2>/dev/null || stat -f %Lp "$home" 2>/dev/null)"
# 0022 is the group-write and other-write bits together. Cleared rather than
# forced to a fixed mode — 0755 vs 0750 is a real choice some setups make on
# purpose, and this only removes the bits sshd actually refuses to see.
if [[ -n "$mode" ]] && ((8#$mode & 0022)); then
chmod go-w "$home"
warn "${home} was group/world-writable (${mode}) — sshd's StrictModes refuses authorized_keys under a writable home regardless of .ssh's own permissions; cleared"
fi
fi
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
chmod 700 "$dir"
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
@@ -245,7 +245,18 @@ tailscale_needs_logout() {
# Routing has to be on before this machine can forward anyone else's packets,
# whether as an exit node or as a subnet router. Written as a drop-in so it is
# visible as this script's doing.
# Both this and install_exit_node_tuning below are Linux-only — sysctl.d, systemd's
# networkd-dispatcher, ethtool NIC offload flags, none of which macOS has an equivalent
# concept for. Tailscale itself DOES support acting as an exit node from macOS (that's the
# `--advertise-exit-node` flag on `tailscale up`, untouched by this guard) — what's
# unavailable is specifically this forwarding-throughput tuning, which is also a much
# narrower loss on a Mac: the file's own MACOS_SKIP already treats a laptop advertising as
# an exit node as an edge case, not the common path a homelab box is.
enable_ip_forwarding() {
if [[ "${OS:-}" == "macos" ]]; then
warn "IP-forwarding tuning is Linux-only (sysctl.d) — skipping; the exit node still works, just untuned"
return 0
fi
cat >"$TS_EXIT_SYSCTL" <<'EOF'
# Written by machine-setup: required to forward traffic for other tailnet nodes,
# as an exit node or as a subnet router.
@@ -265,6 +276,10 @@ EOF
# once, because the settings are per-interface and do not survive the link going
# down and back up.
install_exit_node_tuning() {
if [[ "${OS:-}" == "macos" ]]; then
warn "NIC offload tuning is Linux-only (networkd-dispatcher, ethtool) — skipping"
return 0
fi
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
mkdir -p "$(dirname "$TS_DISPATCHER")"
+24
View File
@@ -48,7 +48,15 @@ tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
# The installers
# -----------------------------------------------------------------------------
# The installer script this used is literally named install_update_linux.sh — there is no
# upstream equivalent for macOS to swap in, so this is brew there instead of a raw fetch.
# All three tools are real Homebrew formulas (confirmed against the live catalogue), which
# also means macOS gets `brew upgrade` maintenance the raw-fetch path never had.
tool_install_lazydocker() {
if [[ "${OS:-}" == "macos" ]]; then
brew install lazydocker
return
fi
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
DIR=/usr/local/bin bash
}
@@ -57,7 +65,16 @@ tool_install_lazydocker() {
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
# two spellings ARCH uses, hence the mapping.
#
# The URL was also hardcoded to `_Linux_` — brew handles macOS instead of this needing a
# second literal-string branch, see tool_install_lazydocker above for why brew and not a
# Darwin asset name here.
tool_install_lazygit() {
if [[ "${OS:-}" == "macos" ]]; then
brew install lazygit
return
fi
local version asset url
case "$ARCH" in
amd64) asset="x86_64" ;;
@@ -82,7 +99,14 @@ tool_install_lazygit() {
# Quiet on purpose. The installer ends by printing how to add starship to bash,
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
# already writes the zsh config itself. Errors still come through.
#
# starship's own installer IS cross-platform (unlike the two above), but brew on macOS
# keeps it consistent with the other two tools and gets it `brew upgrade` maintenance.
tool_install_starship() {
if [[ "${OS:-}" == "macos" ]]; then
brew install starship
return
fi
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
}
+18 -10
View File
@@ -46,7 +46,9 @@ while [[ $# -gt 0 ]]; do
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
# all and errors immediately ("invalid option -- P"), confirmed live.
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
exit 0
;;
-h | --help)
@@ -114,15 +116,17 @@ info "Machine: ${OS_NAME} (${ARCH})"
info "Packages: ${PM:-none detected}"
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
# Everything below this line is written against apt and systemd. Detection above
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
# nothing implements them yet — and running the apt path on Arch would half-build
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
# list one entry at a time as each package manager grows a real path.
# Everything below this line is written against apt and systemd, with real macOS/brew
# branches threaded through where a Mac needs different behaviour (MACOS_SKIP for whole
# steps that are server-only, `$OS == macos` for the rest). Detection above also recognises
# pacman and dnf so THEIR branches have somewhere to hang, but nothing implements those yet
# — running the apt path on Arch would half-build a machine and stop somewhere unhelpful.
# Refuse clearly instead, and relax this list one entry at a time as each package manager
# grows a real path.
case "$PM" in
apt) ;;
apt | brew) ;;
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt and brew only, so far." ;;
esac
ask_machine_role
@@ -404,7 +408,9 @@ fi
step "System update"
if ! skip; then
mapfile -t UPGRADABLE < <(pkg_upgradable)
# `mapfile` is bash 4+; macOS's stock bash is 3.2. This is the portable substitute.
UPGRADABLE=()
while IFS= read -r _line; do UPGRADABLE+=("$_line"); done < <(pkg_upgradable)
echo ""
info "System update — upgrades installed packages to their latest versions"
@@ -2479,7 +2485,9 @@ EOF
echo " which is why git's own core.editor is deliberately not set: git"
echo " falls back to \$EDITOR, so setting it here covers everything."
echo ""
mapfile -t EDITORS < <(editor_candidates)
# mapfile is bash 4+; macOS's stock bash is 3.2.
EDITORS=()
while IFS= read -r _line; do EDITORS+=("$_line"); done < <(editor_candidates)
if ((${#EDITORS[@]} == 0)); then
warn "no editor found to offer — skipping"
else
+18 -4
View File
@@ -50,7 +50,9 @@ while [[ $# -gt 0 ]]; do
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
# all and errors immediately ("invalid option -- P"), confirmed live.
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
exit 0
;;
-h | --help)
@@ -143,6 +145,13 @@ else
echo " would have answered are asked below instead."
fi
# proxy.sh set PROXY_NET from $SETUP_DOCKER_NETWORK when it was SOURCED, before the answers
# file above could have populated that variable — so it needs re-deriving now the same way
# ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL are recovered below. Without this a customised bridge
# name from machine-setup silently would not carry over, and Proxy would default back to
# "services" regardless of what Docker was actually told to use.
PROXY_NET="${SETUP_DOCKER_NETWORK:-$PROXY_NET}"
# ── the account ──
#
# A remembered answer can go stale: the account it names may have been renamed or
@@ -211,8 +220,11 @@ fi
echo ""
info "What Officer needs from this machine"
mapfile -t MISSING < <(missing_tools)
mapfile -t MISSING_OPT < <(missing_optional_tools)
# mapfile is bash 4+; macOS's stock bash is 3.2, so this reads line-by-line instead.
MISSING=()
while IFS= read -r _line; do MISSING+=("$_line"); done < <(missing_tools)
MISSING_OPT=()
while IFS= read -r _line; do MISSING_OPT+=("$_line"); done < <(missing_optional_tools)
for t in "${REQUIRED_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
@@ -274,7 +286,9 @@ if ! skip; then
echo " To put data/ on a bigger volume later, symlink it — that is a"
echo " decision about storage rather than about how Officer is laid out."
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
# mapfile is bash 4+; macOS's stock bash is 3.2.
WRONG_OWNER=()
while IFS= read -r _line; do WRONG_OWNER+=("$_line"); done < <(layout_wrong_owner)
if ((${#WRONG_OWNER[@]} > 0)); then
echo ""
warn "these exist but do not belong to ${USERNAME}:"
+40 -2
View File
@@ -47,10 +47,16 @@ fail() {
ONLY_STEP="${ONLY_STEP:-}"
# `${var,,}` (case-insensitive --only compare below) is bash 4+. macOS's stock /bin/bash is
# 3.2 — confirmed live: "bad substitution" — and under this script's `set -e` that means
# `--only <step>` killed the ENTIRE run on macOS, not just the comparison. This is the
# portable pre-4 substitute.
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
step() {
CURRENT_STEP="$1"
if [[ -n "$ONLY_STEP" ]]; then
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
@@ -134,7 +140,39 @@ ask_required() {
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
# The bound address(es) of whatever is LISTENing on a TCP port — one per line, formatted
# like `127.0.0.1:5432` or `*:5432`. Used both to ask "is anything on this port at all" and
# "is it bound wide enough for a container to reach" (Postgres and the Proxy section ask
# opposite versions of that question).
#
# `ss` (iproute2) is Linux-only — no equivalent ships with it on macOS. `lsof` is the
# portable answer: present on every macOS install by default, and common enough on Linux
# (already a REQUIRED_TOOL nowhere here, so this still prefers `ss` there since that is the
# surface this was written and tested against) that branching by OS rather than picking one
# tool for both keeps each path exactly as capable as it always was.
listening_addrs() {
local port="$1"
if [[ "$(uname -s)" == "Darwin" ]]; then
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk -v p=":${port}\$" '$9 ~ p {print $9}'
else
ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4}'
fi
}
# Run something as the account that owns the install. Officer's files, its
# node_modules and its pm2 process list all belong to that account, not to root —
# a repository cloned as root is one the owner cannot pull.
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
#
# On Linux this script runs as root (that's the whole point of sudo -H -u here: dropping
# from root down to the owning account). On macOS it never escalates — officer-setup.sh
# refuses to even start under sudo there, "the account running this IS the owner" — so the
# process already IS $USERNAME, and re-invoking sudo for the SAME account asks for a real
# password on every single call (Dependencies, Schema, Build, Secrets, Services, Repository
# all go through this) or fails outright without sudo rights at all.
as_owner() {
if [[ "$(uname -s)" == "Darwin" ]]; then
(cd "${2:-/}" && bash -c "$1")
else
(cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1")
fi
}
+8 -3
View File
@@ -24,15 +24,20 @@ OFFICER_SETUP_BUILD_LOADED=1
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
# want at 3am.
push_schema() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
as_owner "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
}
# What tables the schema will create, read from the aggregator rather than
# guessed. This is what makes the section able to say what it is about to do.
#
# Portable ERE + sed rather than GNU grep's -P/lookbehind: BSD grep on macOS has no -P at
# all (confirmed live — errors immediately), and its 2>/dev/null here would have hidden that
# and reported "0 tables" on every macOS run instead, cosmetic but silently wrong.
schema_table_count() {
local dir
dir="$(platform_dir)/src/databases/officer_db/src"
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
grep -E "^export \* from '\./" "$dir/schema.ts" 2>/dev/null |
sed -E "s/^export \* from '\.\/([A-Za-z0-9_-]+)\/schema'.*/\1/" | while read -r f; do
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
done | awk '{s+=$1} END {print s+0}'
}
@@ -43,7 +48,7 @@ schema_table_count() {
# Not optional and not cosmetic: without it the server has no page to serve. It
# is gitignored, so a fresh clone never has one.
gen_index() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
as_owner "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
}
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
+26 -3
View File
@@ -31,14 +31,32 @@ env_exists() { [[ -f "$(env_file)" ]]; }
# One value out of an existing .env, without sourcing it — the file holds
# secrets and arbitrary shell would run as root.
#
# Checks the OFFICERDEV_-prefixed key first, matching the priority `OFFICERDEV_X ?? X`
# gives it at runtime — write_env ALWAYS writes prefixed keys (see below), so plain `env_get
# PORT` used to compare against a key that could never appear in a file this script wrote,
# and always came back empty. That silently defeated the resume block below it was written
# to fix, and the Environment step's own re-run showed the wrong default. Verified against a
# real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000], before this.
# Falls back to the bare key so an old, hand-written unprefixed file still works, same as the
# runtime does.
env_get() {
[[ -r "$(env_file)" ]] || return 0
awk -F= -v k="$1" '
$1 == k {
awk -F= -v k="$1" -v pk="OFFICERDEV_$1" '
$1 == pk {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
print v
exit
}
$1 == k && !found {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
bare = v
found = 1
}
END {
if (bare != "") print bare
}' "$(env_file)"
}
@@ -111,7 +129,12 @@ ENVF
# is any better a guess.
tailnet_hostname() {
local dns ip
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
# jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD
# grep at all (confirmed live — errors visibly), which used to leak straight to the
# terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is
# already an unconditional dependency elsewhere in this script (proxy.sh), so this is
# strictly a correctness improvement even setting the portability problem aside.
dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')"
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
dns="${dns%%.*}" # and we want the short name
if [[ -n "$dns" ]]; then
+8 -1
View File
@@ -39,6 +39,13 @@ layout_dirs() {
echo "$(layout_items_dir)"
}
# A file's owning username, portably: GNU stat (Linux) takes `-c %U`, BSD stat (macOS) has
# no `-c` at all and wants `-f %Su` instead — confirmed live, GNU form exits
# "illegal option -- c" under macOS's stat. Try GNU first since that is the common case,
# fall back to BSD rather than branching on an OS variable this file does not otherwise
# track.
file_owner() { stat -c %U "$1" 2>/dev/null || stat -f %Su "$1"; }
# Created owned by the account, because everything that writes into them runs as
# the account: the platform under pm2, the app store's compose files, the item
# store the agent authors into.
@@ -58,7 +65,7 @@ layout_wrong_owner() {
local dir
while read -r dir; do
[[ -d "$dir" ]] || continue
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
[[ "$(file_owner "$dir")" == "$USERNAME" ]] || echo "$dir"
done < <(layout_dirs)
}
+61 -3
View File
@@ -62,7 +62,7 @@ pg_client_installed() {
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
# just a more annoying one to operate.
install_pg_client() {
local major codename
local major
major="$(pg_client_major)"
[[ -n "$major" ]] || {
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
@@ -74,6 +74,49 @@ install_pg_client() {
return 0
fi
if [[ "$(uname -s)" == "Darwin" ]]; then
install_pg_client_macos "$major"
return $?
fi
install_pg_client_linux "$major"
}
# Homebrew's `postgresql@<major>` matches the apt path's own logic: the version has to agree
# with the server's, not just be "a psql". It is keg-only — Homebrew never links more than one
# Postgres onto PATH by default, because a dev machine plausibly has several majors installed
# for different projects — so `brew install` alone does not make `psql` resolve. Symlinking
# only the four client binaries this platform actually uses, rather than `brew link --force`ing
# the whole keg, is the same caveat Homebrew's own postgresql formula prints; force-linking
# would silently steal `psql` from whatever other major was linked before this ran.
install_pg_client_macos() {
local major="$1" prefix
command -v brew &>/dev/null || {
warn "Homebrew is not installed — cannot install postgresql@${major}. Run machine-setup.sh first."
return 1
}
prefix="$(brew --prefix)"
brew install "postgresql@${major}" &>/dev/null || {
warn "brew install postgresql@${major} failed"
return 1
}
local bin
for bin in psql pg_dump pg_dumpall pg_restore; do
[[ -x "${prefix}/opt/postgresql@${major}/bin/${bin}" ]] || continue
ln -sf "${prefix}/opt/postgresql@${major}/bin/${bin}" "${prefix}/bin/${bin}"
done
pg_client_installed || {
warn "psql is not version ${major} after installing — check: brew list postgresql@${major}"
return 1
}
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed via Homebrew"
}
install_pg_client_linux() {
local major="$1" codename
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
[[ -n "$codename" ]] || {
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
@@ -121,7 +164,12 @@ pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{
# Is something already answering on the port? A Postgres the user runs their own
# way is a perfectly good answer, and finding out by failing to bind is not.
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
#
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) is the portable version, branching to
# `lsof` on macOS. Confirmed empirically to fail silently the old way: `ss` doesn't exist on
# macOS, its "command not found" was swallowed by `2>/dev/null`, and an empty grep result
# reported the port as free even when something WAS listening.
pg_port_in_use() { [[ -n "$(listening_addrs "$PG_PORT")" ]]; }
# Bound to loopback, deliberately, and the reason is worth keeping next to the
# line it explains.
@@ -189,9 +237,19 @@ ENVF
return 0
}
# Failure has to mean failure. The bare awk one-liner this replaced printed nothing and
# still exited 0 when the file existed but had no POSTGRES_PASSWORD= line — so the caller's
# `pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"` treated that as
# success with an EMPTY password, and built POSTGRES_URL with a blank credential instead of
# falling through to provision fresh. Only reachable via a hand-edited/corrupted .env, since
# write_pg_compose always writes the line — but "reuse a blank password" is the wrong
# direction to fail in even for an edge case that rare.
pg_password_from_env_file() {
[[ -r "$(pg_env_file)" ]] || return 1
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
local pw
pw="$(awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)")"
[[ -n "$pw" ]] || return 1
printf '%s' "$pw"
}
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
+20 -1
View File
@@ -32,6 +32,13 @@ load_machine_answers() {
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
# The bridge machine-setup told Docker to use — proxy.sh's PROXY_NET otherwise
# defaults to "services" independently and can only agree with this by coincidence.
# PROXY_NET is a top-level assignment evaluated when proxy.sh is SOURCED, which
# happens before this function is ever called — officer-setup.sh re-derives it from
# SETUP_DOCKER_NETWORK right after calling this, same pattern as ENV_PORT/
# ENV_PUBLIC_URL/POSTGRES_URL below it.
SETUP_DOCKER_NETWORK) if [[ -z "${SETUP_DOCKER_NETWORK:-}" ]]; then SETUP_DOCKER_NETWORK="$value"; fi ;;
esac
done <"$MACHINE_ANSWERS"
return 0
@@ -69,7 +76,19 @@ tool_why() {
# The account has to exist before anything is written to its home.
owner_exists() { id "$USERNAME" &>/dev/null; }
# `getent` is Linux/glibc — not present on stock macOS at all (confirmed: "command not
# found" on a real machine; it only appeared to work in earlier testing because this dev
# session's shell profile shims it, which a fresh install does not have). Reproduced live:
# this returned the WRONG, hardcoded `/home/<user>` fallback on a real macOS run just now,
# and `install -d` then failed on it outright ("Operation not supported") since /home isn't
# a real, writable path on macOS.
#
# `eval echo ~"$USERNAME"` is the portable replacement — tilde expansion resolves through
# whatever the OS's own user database is (NSS on Linux, Directory Services on macOS)
# without naming either. A nonexistent user leaves the tilde word unexpanded (`~name`
# rather than a path), which is how the fallback below still triggers for that case.
resolve_user_home() {
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
USER_HOME="$(eval echo ~"$USERNAME" 2>/dev/null)"
[[ "$USER_HOME" == "~"* ]] && USER_HOME=""
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
}
+34 -2
View File
@@ -58,7 +58,9 @@ proxy_ask() {
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
proxy_require_listening() {
local port="$1" listen
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) branches to `lsof` on macOS. Same
# silent-empty-result failure mode as postgres.sh's pg_port_in_use if this were left as-is.
listen="$(listening_addrs "$port" | head -1)"
[[ -n "$listen" ]] || {
warn "nothing is listening on port ${port} — start Officer first"
return 1
@@ -170,7 +172,15 @@ proxy_wait_for_dns() {
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
while ((elapsed < timeout)); do
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
# `getent` is glibc/Linux-only — absent on macOS (confirmed: "command not found" on a
# real machine), silently swallowed by 2>/dev/null, which would have spun this through
# its full 30-minute timeout regardless of whether the record actually resolved.
# `dscacheutil` is macOS's own equivalent, consulting the same system resolver.
if [[ "$(uname -s)" == "Darwin" ]]; then
got="$(dscacheutil -q host -a name "$domain" 2>/dev/null | awk '/^ip_address:/ {print $2; exit}')"
else
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
fi
if [[ "$got" == "$want" ]]; then
ok "${domain} resolves to ${want}"
return 0
@@ -194,8 +204,18 @@ proxy_ensure_network() {
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
# address does not exist yet and the WHOLE container fails to start, not just that port.
#
# Docker Desktop on macOS has no systemd unit to order — it is a GUI app the OS launches, not
# a service tailscaled could be declared a dependency of — and this account does not run as
# root there either, so `mkdir -p /etc/systemd/...` would fail on permissions rather than do
# something merely unnecessary. Same shape as machine-setup's MACOS_SKIP: skip with a reason
# rather than let a Linux-only action fail confusingly.
proxy_order_docker_after_tailscaled() {
[[ "$CHALLENGE" == "dns" ]] || return 0
if [[ "$(uname -s)" == "Darwin" ]]; then
ok "Docker Desktop has no systemd unit to order — skipping"
return 0
fi
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
[[ -f "$f" ]] && return 0
mkdir -p "$(dirname "$f")"
@@ -378,8 +398,20 @@ proxy_get_token() {
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
# upstream would need none of this, which is why container upstreams are preferable when
# there is a choice.
# `ufw` is Debian/Ubuntu — no macOS equivalent through this interface (pf/pfctl is a
# different rule syntax entirely, and the Application Firewall machine-setup's own
# MACOS_SKIP already documents for "Firewall" is per-APP, not per-subnet/per-port). Skipped
# rather than guessed at: Docker Desktop for Mac also runs containers inside its own VM, so
# bridge-to-host traffic does not cross the real host firewall the same way it does when
# Docker runs directly on a Linux kernel — an unverified pf rule here could be solving a
# problem macOS's Docker networking model does not actually have.
proxy_allow_bridge_to_host() {
local port="$1" subnet
if [[ "$(uname -s)" == "Darwin" ]]; then
ok "no ufw on macOS — Docker Desktop's own networking model handles bridge-to-host, skipping"
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
return 0
fi
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
ok "ufw already allows the bridge to reach port ${port}"
+1 -1
View File
@@ -35,7 +35,7 @@ bootstrap_secret_store() {
# From the repo, because the module derives the install root as the parent of
# the working directory — the same rule as src/servers/data-path.ts.
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
as_owner "cd '$(platform_dir)' && bun --eval \"
const { getKey } = await import('officerdb/secret-store');
getKey('jwt');
\"" >/dev/null 2>&1 || return 1
+21 -11
View File
@@ -44,13 +44,6 @@ CORE_PROCESSES=(
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
# Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry.
#
# It was a Docker service because the app store made everything one, not because Radicale needs a
# container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal
# server having a calendar is not an integration with something you already run; it is the baseline.
# Radicale needs `python3` and the `radicale` package, both installed by machine-setup.
"officer-caldav|bun|run src/servers/sidecar/caldav/index.ts"
# Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with
# memos and photos when it was slated to become a plugin — the only one of the three never extracted,
# so it sat mounted nowhere with its schema commented out.
@@ -62,6 +55,11 @@ CORE_PROCESSES=(
"officer-notify|bun|run src/servers/sidecar/notify/index.ts"
)
# Calendar and contacts are NOT here. They were core from 2026-08-19 until 2026-08-27, when caldav became
# a plugin: it declares its own sidecar, its own `radicale` dependency and its own two screens, and the
# generated ecosystem file gains an `officer-caldav` entry when it is installed. A fresh machine runs five
# processes instead of seven, and gets a calendar by installing one plugin.
write_ecosystem() {
local dest entry name script args
dest="$(ecosystem_file)"
@@ -101,24 +99,36 @@ FOOTER
}
pm2_start() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
}
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
pm2_save() { as_owner "pm2 save" 2>&1; }
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
# doing it — so this runs what it prints, which is the whole point of already
# being root here.
#
# `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument
# has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd
# unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo
# command"). That is real, separate work rather than a one-line swap, so this skips with a
# stated reason on macOS instead of pretending the systemd path could ever produce anything
# usable there — matches the same call this codebase already made for the Tailscale exit-node
# NIC tuning and the proxy's systemd docker-ordering fix.
pm2_enable_startup() {
if [[ "$(uname -s)" == "Darwin" ]]; then
warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet"
return 1
fi
local cmd
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
[[ -z "$cmd" ]] && return 1
eval "${cmd#sudo }"
}
# One line per process: name, status, restarts.
pm2_status_lines() {
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
as_owner "pm2 jlist" 2>/dev/null |
node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
let apps = []; try { apps = JSON.parse(s); } catch { }
+271
View File
@@ -0,0 +1,271 @@
# Frontend Apps
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
## UI Components
### shadcn/ui (Base)
Location: `src/workspaces/components/ui/`
Standard shadcn/ui components with Tailwind CSS. Import via:
```tsx
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
```
### Custom Components
Location: `src/workspaces/components/`
Built on top of shadcn primitives:
- `Avatar.tsx` - User avatars
- `Card.tsx` - Custom card wrapper
- `ColorPicker.tsx` - Color selection
- `DataTable/` - Table with sorting, filtering, pagination (see below)
- `Dialogs/` - Common dialog patterns
- `ErrorDialogs/` - Error display dialogs
- `MetricCard.tsx` - Stats display card
- `SearchInput.tsx` - Search with debounce
- `Select.tsx` - Enhanced select
- `Slider/` - Custom slider
- `Tabs/` - Enhanced tabs
### DataTable + useDataControl
A complete data table solution with sorting, filtering, and pagination.
**Pattern:**
```tsx
import { DataTable, useDataControl } from '@/components/DataTable';
function JobsLibrary() {
const { jobs } = useJobs();
const dataController = useDataControl<Job>(jobs || []);
return (
<>
{/* Wire search input to controller */}
<SearchInput
value={dataController.searchQuery}
handleSearch={dataController.setSearchQuery}
/>
<DataTable<Job>
dataController={dataController}
pageSize={10}
columns={[
{ field: 'id', label: 'ID', sortKey: 'id' },
{ field: 'type', label: 'Type', sortKey: 'type' },
{
field: 'status',
label: 'Status',
condition: view === 'All', // conditional column
format: ({ value, item }) => <Badge>{value}</Badge>
},
{
label: 'Actions',
format: ({ item }) => <ActionsCell job={item} />
},
]}
/>
</>
);
}
```
**useDataControl returns:**
- `data` - Current page of filtered/sorted data
- `rawData` - Original unfiltered data
- `searchQuery`, `setSearchQuery` - Search state
- `searchKeys`, `setSearchKeys` - Which fields to search
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
- `sortKey`, `sortDirection` - Parsed sort info
- `currentPage`, `changePage`, `pageCount` - Pagination
- `pageSize`, `setPageSize` - Items per page
- `setCustomSort` - Custom sort function
**Column options:**
- `field` - Key in data object
- `label` / `labelMobile` - Header text
- `sortKey` - Enable sorting on this column
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
- `condition` - Show/hide column: `boolean | () => boolean`
- `tooltip` - Header tooltip text
- `headerClassName` / `cellClassName` - Styling
**Features:**
- Auto-generates columns from data if not specified
- Resets to page 1 on search/sort change
- Search strips diacritics for accent-insensitive matching
- Built-in pagination bar (shows when data exceeds pageSize)
## Hooks
Location: `src/workspaces/hooks/src/`
### Data Fetching - `useClient`
HTTP client with auth token handling:
```tsx
const client = useClient();
const data = await client.get<User[]>("/users");
await client.post("/experiments", payload);
```
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
Auto-attaches Bearer token from localStorage/sessionStorage.
### Forms - `useForm`
Custom form hook (not react-hook-form):
```tsx
const { state, formRef, update, reset, isValid } = useForm<FormState>(
initialState,
validateFn
);
return <form ref={formRef}>...</form>;
```
Features:
- Auto-syncs form inputs with state via `name` attribute
- Handles checkboxes, radios, selects, number inputs
- Nested object support via data attributes
- Validation function support
### Other Hooks
- `useAuth/` - Authentication state and methods
- `useDebounce` - Debounced values
- `useDragAndDrop` - Drag and drop functionality
- `useFullscreen/` - Fullscreen API wrapper
- `useIsMobile` - Responsive breakpoint detection
- `useLocalStorageState` - Persisted state
- `useMounted` - Component mount status
- `usePopover` - Popover state management
- `useTimeout` - Timeout management
- `useTimer` - Interval-based timer
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
- `useJobs` - background jobs
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
## State Management
Uses React Query cache as both server and client state manager.
### Global State - `useGlobal`
Uses React Query cache as a global state store (no Context providers needed):
```tsx
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
// Any component using the same key shares state and reacts to changes
setValue("collapsed");
```
How it works:
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
- Automatic re-renders when state changes
- Visible in React Query DevTools
- Supports functional updates: `setValue(prev => ...)`
### URL Query State - `useQueryState`
Syncs state with URL query parameters:
```tsx
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
```
Features:
- Updates URL via `history.replaceState` (no page reload)
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
- `isGlobal = false`: local to component, uses `useState`
- Auto-rebuilds URL when pathname changes (preserves global query state)
- Type coercion based on `defaultValue` type
### Server State
Domain-specific hooks in `state/` directories wrap React Query:
```tsx
// src/workspaces/hooks/src/useJobs.ts
const { jobs, isLoading } = useJobs();
```
### When to Use What
| Scenario | Hook |
|----------|------|
| API data | Domain hooks (`useJobs`, `useModels`, …) |
| Shared UI state | `useGlobal` |
| URL-driven state (filters, pagination) | `useQueryState` |
| Component-only state | `useState` |
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
| Persisted to localStorage | `useLocalStorageState` |
## Event Handlers
**Always use `ev` for event parameters**, not `e`:
```tsx
// ✅ Good
onChange={(ev) => setName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSubmit();
}
}}
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
// ...
};
// ❌ Bad
onChange={(e) => setName(e.target.value)}
```
## React 19 Patterns
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
```tsx
// ✅ Good - allow null in ref type
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement | null>;
};
// ❌ Bad - will error when passing useRef result
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement>;
};
```
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
```tsx
import { useJobs } from 'hooks/useJobs';
type TopHeaderProps = {
manager: ReturnType<typeof useJobs>;
};
```
## Error Handling
**Toast notifications** via Sonner:
```tsx
import { toast } from "sonner";
toast.error("Something went wrong");
toast.success("Saved successfully");
```
API errors automatically trigger via `useClient.config.onError`.
## Further Reading
- `../../CLAUDE.md` — architecture and the frontend route conventions
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
- `../../CONVENTIONS.md` — component organisation and React patterns
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
+3 -268
View File
@@ -1,271 +1,6 @@
# Frontend Apps
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
## UI Components
### shadcn/ui (Base)
Location: `src/workspaces/components/ui/`
Standard shadcn/ui components with Tailwind CSS. Import via:
```tsx
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
```
### Custom Components
Location: `src/workspaces/components/`
Built on top of shadcn primitives:
- `Avatar.tsx` - User avatars
- `Card.tsx` - Custom card wrapper
- `ColorPicker.tsx` - Color selection
- `DataTable/` - Table with sorting, filtering, pagination (see below)
- `Dialogs/` - Common dialog patterns
- `ErrorDialogs/` - Error display dialogs
- `MetricCard.tsx` - Stats display card
- `SearchInput.tsx` - Search with debounce
- `Select.tsx` - Enhanced select
- `Slider/` - Custom slider
- `Tabs/` - Enhanced tabs
### DataTable + useDataControl
A complete data table solution with sorting, filtering, and pagination.
**Pattern:**
```tsx
import { DataTable, useDataControl } from '@/components/DataTable';
function JobsLibrary() {
const { jobs } = useJobs();
const dataController = useDataControl<Job>(jobs || []);
return (
<>
{/* Wire search input to controller */}
<SearchInput
value={dataController.searchQuery}
handleSearch={dataController.setSearchQuery}
/>
<DataTable<Job>
dataController={dataController}
pageSize={10}
columns={[
{ field: 'id', label: 'ID', sortKey: 'id' },
{ field: 'type', label: 'Type', sortKey: 'type' },
{
field: 'status',
label: 'Status',
condition: view === 'All', // conditional column
format: ({ value, item }) => <Badge>{value}</Badge>
},
{
label: 'Actions',
format: ({ item }) => <ActionsCell job={item} />
},
]}
/>
</>
);
}
```
**useDataControl returns:**
- `data` - Current page of filtered/sorted data
- `rawData` - Original unfiltered data
- `searchQuery`, `setSearchQuery` - Search state
- `searchKeys`, `setSearchKeys` - Which fields to search
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
- `sortKey`, `sortDirection` - Parsed sort info
- `currentPage`, `changePage`, `pageCount` - Pagination
- `pageSize`, `setPageSize` - Items per page
- `setCustomSort` - Custom sort function
**Column options:**
- `field` - Key in data object
- `label` / `labelMobile` - Header text
- `sortKey` - Enable sorting on this column
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
- `condition` - Show/hide column: `boolean | () => boolean`
- `tooltip` - Header tooltip text
- `headerClassName` / `cellClassName` - Styling
**Features:**
- Auto-generates columns from data if not specified
- Resets to page 1 on search/sort change
- Search strips diacritics for accent-insensitive matching
- Built-in pagination bar (shows when data exceeds pageSize)
## Hooks
Location: `src/workspaces/hooks/src/`
### Data Fetching - `useClient`
HTTP client with auth token handling:
```tsx
const client = useClient();
const data = await client.get<User[]>("/users");
await client.post("/experiments", payload);
```
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
Auto-attaches Bearer token from localStorage/sessionStorage.
### Forms - `useForm`
Custom form hook (not react-hook-form):
```tsx
const { state, formRef, update, reset, isValid } = useForm<FormState>(
initialState,
validateFn
);
return <form ref={formRef}>...</form>;
```
Features:
- Auto-syncs form inputs with state via `name` attribute
- Handles checkboxes, radios, selects, number inputs
- Nested object support via data attributes
- Validation function support
### Other Hooks
- `useAuth/` - Authentication state and methods
- `useDebounce` - Debounced values
- `useDragAndDrop` - Drag and drop functionality
- `useFullscreen/` - Fullscreen API wrapper
- `useIsMobile` - Responsive breakpoint detection
- `useLocalStorageState` - Persisted state
- `useMounted` - Component mount status
- `usePopover` - Popover state management
- `useTimeout` - Timeout management
- `useTimer` - Interval-based timer
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
- `useJobs` - background jobs
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
## State Management
Uses React Query cache as both server and client state manager.
### Global State - `useGlobal`
Uses React Query cache as a global state store (no Context providers needed):
```tsx
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
// Any component using the same key shares state and reacts to changes
setValue("collapsed");
```
How it works:
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
- Automatic re-renders when state changes
- Visible in React Query DevTools
- Supports functional updates: `setValue(prev => ...)`
### URL Query State - `useQueryState`
Syncs state with URL query parameters:
```tsx
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
```
Features:
- Updates URL via `history.replaceState` (no page reload)
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
- `isGlobal = false`: local to component, uses `useState`
- Auto-rebuilds URL when pathname changes (preserves global query state)
- Type coercion based on `defaultValue` type
### Server State
Domain-specific hooks in `state/` directories wrap React Query:
```tsx
// src/workspaces/hooks/src/useJobs.ts
const { jobs, isLoading } = useJobs();
```
### When to Use What
| Scenario | Hook |
|----------|------|
| API data | Domain hooks (`useJobs`, `useModels`, …) |
| Shared UI state | `useGlobal` |
| URL-driven state (filters, pagination) | `useQueryState` |
| Component-only state | `useState` |
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
| Persisted to localStorage | `useLocalStorageState` |
## Event Handlers
**Always use `ev` for event parameters**, not `e`:
```tsx
// ✅ Good
onChange={(ev) => setName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSubmit();
}
}}
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
// ...
};
// ❌ Bad
onChange={(e) => setName(e.target.value)}
```
## React 19 Patterns
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
```tsx
// ✅ Good - allow null in ref type
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement | null>;
};
// ❌ Bad - will error when passing useRef result
type PanelProps = {
triggerRef: React.RefObject<HTMLButtonElement>;
};
```
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
```tsx
import { useJobs } from 'hooks/useJobs';
type TopHeaderProps = {
manager: ReturnType<typeof useJobs>;
};
```
## Error Handling
**Toast notifications** via Sonner:
```tsx
import { toast } from "sonner";
toast.error("Something went wrong");
toast.success("Saved successfully");
```
API errors automatically trigger via `useClient.config.onError`.
## Further Reading
- `../../CLAUDE.md` — architecture and the frontend route conventions
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
- `../../CONVENTIONS.md` — component organisation and React patterns
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
+7 -4
View File
@@ -78,8 +78,6 @@ export function App() {
entry keep working. Only DIRECTORIES live here — an open file stays `?view=`, since it is
what is on top of you rather than where you are. */}
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
@@ -91,13 +89,15 @@ export function App() {
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
{installedPlugins.flatMap((plugin) => [
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
// panels read `useParams` themselves, so which section is open is the URL rather than state
// passed between them.
<Route
key={`${plugin.appName}-section`}
key={`${plugin.screenId}-section`}
path={`${plugin.route}/:section`}
element={<Dashboard.PluginScreen {...plugin} />}
/>,
@@ -108,6 +108,9 @@ export function App() {
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/todos" element={<Dashboard.TodosScreen />} />
<Route path="/bots" element={<Dashboard.BotsScreen />} />
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { Bot, Check, Pencil, Plus, Trash2, X } from 'lucide-react';
import { Link, useNavigate, useParams } from 'react-router';
import { useClaudeSessions, type ClaudeSessionSummary } from 'state/useClaudeSessions';
/**
* The bots, as cards — the same object Command HQ gives a commander.
*
* ── Why not the chat session list ──
*
* A bot IS a chat underneath, and that is an implementation detail nobody using this should have to hold.
* The generic session list says "conversation, last touched at" because that is what a conversation is. A
* bot is a thing you name, check on, and eventually delete — so it gets a card with its name, its id, and
* whether it is watching, exactly as a commander does.
*
* ── The id pill ──
*
* A commander shows `#54`, its directory. A bot has no directory of its own — they share one — so its id
* is the first eight characters of its session, which is also what an `@mention` carries. Seeing the same
* eight characters in both places is the point: the pill is what you type to reference it.
*/
const shortId = (id: string) => id.slice(0, 8);
type CardProps = {
bot: ClaudeSessionSummary;
selected: boolean;
onRename: (id: string, title: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
};
const BotCard = ({ bot, selected, onRename, onDelete }: CardProps) => {
const navigate = useNavigate();
const [confirming, setConfirming] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(bot.title);
const commit = async () => {
const next = draft.trim();
setRenaming(false);
if (next && next !== bot.title) await onRename(bot.id, next);
};
return (
<div
className={`group relative flex flex-col gap-1.5 rounded-xl border bg-background p-3 transition-colors hover:border-primary/40 hover:bg-muted/40 ${
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
}`}
>
{/* The whole card is the link, with the controls layered above it — same arrangement as a commander
card, so a click anywhere opens the bot and the buttons still work. */}
{!renaming && <Link to={`/bots/${bot.id}`} className="absolute inset-0" aria-label={bot.title} />}
<div className="flex items-center gap-2 pr-12">
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
{renaming ? (
<form
className="z-10 flex min-w-0 flex-1 items-center gap-1"
onSubmit={(ev) => {
ev.preventDefault();
void commit();
}}
>
<input
autoFocus
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => ev.key === 'Escape' && setRenaming(false)}
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0.5 text-sm"
/>
<button type="submit" className="rounded p-0.5 text-primary" aria-label="Save name">
<Check className="size-3.5" />
</button>
</form>
) : (
<>
<span className="truncate text-sm font-medium">{bot.title || '(unnamed)'}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground" title={bot.id}>
#{shortId(bot.id)}
</span>
</>
)}
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{bot.messageCount} message{bot.messageCount === 1 ? '' : 's'}
</span>
<span title={bot.updatedAt}>{new Date(bot.updatedAt).toLocaleDateString()}</span>
</div>
{confirming ? (
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded-lg bg-background/95 p-0.5 shadow">
<button
onClick={() => {
void onDelete(bot.id);
// Leaving the route pointed at something that no longer exists would render an empty chat.
if (selected) void navigate('/bots');
}}
className="rounded p-1 text-destructive hover:bg-destructive/10"
title="Delete this bot"
>
<Check className="size-3.5" />
</button>
<button onClick={() => setConfirming(false)} className="rounded p-1 opacity-50 hover:opacity-100">
<X className="size-3.5" />
</button>
</div>
) : (
!renaming && (
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button
onClick={() => {
setDraft(bot.title);
setRenaming(true);
}}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Rename"
>
<Pencil className="size-3.5" />
</button>
<button
onClick={() => setConfirming(true)}
className="rounded p-1 text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 className="size-3.5" />
</button>
</div>
)
)}
</div>
);
};
export const BotsList = () => {
const { sessionId } = useParams();
const { sessions, isLoading, renameSession, deleteSession } = useClaudeSessions(null, {
chatContext: { context: 'bots' },
});
return (
<nav className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
</span>
{/* `/bots` with no id IS the new-bot state: an empty composer, and the first sentence arms it. */}
<Link to="/bots" title="New bot" className="text-muted-foreground hover:text-foreground">
<Plus className="size-4" />
</Link>
</header>
{isLoading ? (
<p className="p-3 text-xs text-muted-foreground">Loading…</p>
) : sessions.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
<Bot className="size-7 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No bots yet.</p>
<p className="text-[11px] leading-relaxed text-muted-foreground">
Say what you want watched — “notify me when x.com is down” — and it arms itself.
</p>
</div>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-auto p-2">
{sessions.map((bot) => (
<li key={bot.id}>
<BotCard bot={bot} selected={bot.id === sessionId} onRename={renameSession} onDelete={deleteSession} />
</li>
))}
</ul>
)}
</nav>
);
};
@@ -0,0 +1,59 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
import { useParams } from 'react-router';
import { useAuthoredLayout } from 'state/useAuthoredLayout';
import { defaultLayout } from './defaultLayout';
import { BotsList } from './BotsList';
/**
* `/bots` — one sentence arms a watch, and the conversation stays open to widen it.
*
* ── The same shape as Command HQ ──
*
* A narrow list on the left, the conversation on the right, and the selection in the URL as a route pair:
* `/bots` is the new-bot state, `/bots/:sessionId` is one of them. That is the convention
* `docs/navigation-audit.md` sets and the one Command HQ already follows, so a bot is linkable and a
* reload keeps the one you were reading.
*
* ── Why the panels are supplied here rather than registered ──
*
* Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's
* `officerdev/chat` derives its context from the workspace and would land on none of it — it would list
* and resume the caller's HOME, which is exactly what this screen did on its first outing. `components` is
* the prop for a screen that supplies its own chat panel, and it carries the header, so the picker lists
* bots rather than every chat on the machine.
*
* The instructions a bot runs under are `$DATA_PATH/<email>/bots/CLAUDE.md`, written by the server on
* every turn — `api/bots/brief.ts`.
*/
const CHAT_CONTEXT = { context: 'bots' } as const;
const BotsChat = ({ panelId }: { panelId: string }) => {
const { sessionId } = useParams();
return (
<div className="flex h-full min-h-0 flex-col">
{/* `sessionId ?? null` — null is "the screen chose a new chat", which is not the same as having no
opinion. On `/bots` with no id that is exactly right: an empty composer waiting for the sentence. */}
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} sessionId={sessionId ?? null} />
</div>
);
};
export const BotsScreen = () => {
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
return (
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
components={{
'bots-list': BotsList,
// No header picker: the cards on the left are the list, and a second one in the panel bar would be
// the same choice offered twice. Command HQ does not have one either, for the same reason.
'bots-chat': BotsChat,
}}
/>
);
};
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
// The bots down the left, the conversation on the right — the same division Command HQ makes, and for the
// same reason: the list is names and the right-hand side is the only thing anybody reads.
//
// No category pane. Commanders have one because a commander is a job somebody files; a bot is a sentence,
// and a filter over a handful of one-line watches would be furniture.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'bots-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'bots-list', appType: 'bots-list' }, size: 24 },
{ node: { type: 'panel', id: 'bots-chat', appType: 'bots-chat' }, size: 76 },
],
};
@@ -0,0 +1 @@
export { BotsScreen } from './BotsScreen';
@@ -1,27 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /calendar uses the Workspace/Panel system (like /command-hq): the calendar list on the left, the
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
// /api/caldav auth proxy, which holds no DAV credentials of its own.
//
// There is no :param route pair here and so no <Navigate> guard: a collection id is a DAV path with
// slashes in it, so the selection lives in ?collection= on this same route. An absent or unknown value
// resolves to the first collection inside the panels rather than by redirecting, because until the
// collection list has loaded there is no canonical URL to redirect to.
export const CalendarScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['calendar-nav', 'calendar-view'], fallback: 'calendar-view' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'calendar-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 },
{ node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 },
],
};
@@ -1 +0,0 @@
export * from './CalendarScreen';
@@ -1,21 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
// collection is a query param rather than a route segment.
export const ContactsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['contacts-nav', 'contacts-view'], fallback: 'contacts-view' }}
/>
</div>
);
};
@@ -1,11 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'contacts-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 },
{ node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 },
],
};
@@ -1 +0,0 @@
export * from './ContactsScreen';
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
<ImpersonationBanner />
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
@@ -142,22 +142,23 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
};
import {
Home,
MessageCircle,
FolderOpen,
Code,
LayoutGrid,
Monitor,
Globe,
Workflow,
Music,
Activity,
Radio,
Bot,
Code,
FolderOpen,
GitBranch,
Store,
Globe,
Home,
LayoutGrid,
MessageCircle,
Monitor,
Music,
Puzzle,
Radar,
Radio,
Send,
Store,
Workflow,
} from 'lucide-react';
/**
@@ -176,6 +177,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
// Beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing.
// The route and the screen have existed since bots did; there was simply never a tile, so the only way
// to reach /bots was to type it.
{ labelKey: 'nav.bots', to: '/bots', icon: Bot, color: '#34d399' },
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
@@ -187,17 +192,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure
// this file's comment above warns about, arrived at from the other direction.
//
// Artwork rather than glyphs since 2026-08-19: both were redrawn to the icon brief, so they carry
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
// Calendar and Contacts are NOT here. They were core tiles from 2026-08-19 until caldav became a
// plugin on 2026-08-27, and they come back through `dockItemsFromPlugins` — one tile per screen, each
// with its own artwork, filtered by the same `calendar` permission. A plugin contributing two tiles is
// what `screens` in a manifest is for.
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
@@ -282,6 +280,8 @@ export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
// this list is what every install has and most people want visible; a calendar is now in that category.
// Contacts is deliberately NOT here — it is core and available in dock settings, but a second tile for
// the same sidecar in everyone's default dock is a crowded default rather than a useful one.
// `/calendar` stays in the default arrangement even though it is a plugin route now: it is installed by
// default and a fresh dock that silently dropped it would look like a downgrade.
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat', '/calendar'];
/**
@@ -178,13 +178,9 @@ const SETTINGS_ITEMS: SettingsItem[] = [
icon: KeyRound,
},
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:dav',
labelKey: 'settings.sections.davSync.label',
to: '/settings/integrations/dav-app-passwords',
hintKey: 'settings.sections.davSync.hint',
icon: CalendarDays,
},
// DAV sync setup left with the caldav plugin on 2026-08-27 — it is a modal on the Calendar screen now,
// and Spotlight has no way to open a plugin's modal. `bun run docs:check` caught this one: it compares
// every settings route Spotlight offers against the sections that actually exist.
// Users — UserManagement
{
id: 'set:users',
@@ -18,11 +18,18 @@ import { useAuthoredLayout } from 'state/useAuthoredLayout';
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
// something else falls back rather than rendering another plugin's panel inside this one.
export function PluginScreen({
appName,
screenId,
panels,
layout,
}: {
appName: string;
/**
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
*
* A plugin with two screens has two of these, and they must differ: the id is both the layout
* persistence key and the owner of the panel allow-list below, so sharing one would have Calendar and
* Contacts persisting over each other's sizes and each accepting the other's panels.
*/
screenId: string;
panels: AppRegistryMeta[];
layout: LayoutNode;
}) {
@@ -32,7 +39,7 @@ export function PluginScreen({
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
const workspace = useAuthoredLayout(`screens/plugin/${appName}`, layout);
const workspace = useAuthoredLayout(`screens/plugin/${screenId}`, layout);
const allowed = panels.map((panel) => panel.key);
return (
@@ -1,6 +1,6 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { useAuthoredLayout } from 'state/useAuthoredLayout';
import { defaultLayout } from './defaultLayout';
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
@@ -11,7 +11,11 @@ import { defaultLayout } from './defaultLayout';
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
export const PluginsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
// `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is ours and comes from source on
// every render, while only the sizes are the user's and persisted. Under the old hook the whole tree
// was stored, so the panels this screen ships today would never have reached anyone who had opened it
// before — they would still be looking at the list-and-preview this replaced.
const workspace = useAuthoredLayout('screens/plugins', defaultLayout);
return (
<div className="h-full w-full pt-2">
@@ -8,7 +8,9 @@ export const defaultLayout: LayoutNode = {
id: 'plugins-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
// 22/78, not 32/68. The left panel was a list of every plugin and needed the room; it is a list of
// seven tags now, and the grid on the right is what benefits from the width — one more card per row.
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 22 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 78 },
],
};
@@ -1,226 +0,0 @@
import type { FormEvent } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
//
// The generated password is returned by POST and never again — it is stored as an argon2 hash, so there
// is nothing to read back. That single fact drives the whole design of this screen: the new credential
// is shown in a panel that stays put until dismissed, with the server URL and username beside it,
// because the moment it disappears the only remedy is to revoke and mint another.
type DavPassword = {
id: number;
label: string;
hint: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
};
type Minted = { password: string; username: string; label: string };
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
export const DavAppPasswords = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string, what: string) => {
try {
await copyToClipboard(text);
toast.success(t('settings.dav.copied', { what }));
} catch {
toast.error(t('settings.dav.copyFailed'));
}
};
const client = useClient();
const [passwords, setPasswords] = useState<DavPassword[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [label, setLabel] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [minted, setMinted] = useState<Minted | null>(null);
// The base the phone should be pointed at. Taken from the browser rather than configured: it is by
// definition the address that reached this page, which is the one that will work.
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
const load = useCallback(() => {
client
.get<{ passwords: DavPassword[] }>('/dav/passwords')
.then((data) => setPasswords(data?.passwords ?? []))
.catch(() => {})
.finally(() => setIsLoading(false));
}, [client]);
useEffect(load, [load]);
const create = async (ev?: FormEvent) => {
ev?.preventDefault();
const trimmed = label.trim();
if (!trimmed || isCreating) return;
setIsCreating(true);
try {
const res = await client.post<{ password: string; username: string }>('/dav/passwords', { label: trimmed });
setMinted({ password: res.password, username: res.username, label: trimmed });
setLabel('');
load();
} catch {
toast.error(t('settings.dav.createFailed'));
} finally {
setIsCreating(false);
}
};
const revoke = async (id: number) => {
try {
await client.post(`/dav/passwords/${id}/revoke`, {});
toast.success(t('settings.dav.revoked'));
load();
} catch {
toast.error(t('settings.dav.revokeFailed'));
}
};
const remove = async (id: number) => {
try {
await client.delete(`/dav/passwords/${id}`);
load();
} catch {
toast.error(t('settings.dav.deleteFailed'));
}
};
if (isLoading) return null;
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
Calendar and contacts sync uses one password per device. A phone can't hold an Officer session, so it gets its
own credential — revocable on its own, without touching anything else.
</p>
{minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
Password for “{minted.label}” — shown once
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
revoke this entry and make another.
</p>
<div className="grid gap-2">
{[
{ field: 'Server', value: serverUrl },
{ field: 'Username', value: minted.username },
{ field: 'Password', value: minted.password },
].map(({ field, value }) => (
<div key={field} className="flex items-center gap-2">
<span className="w-20 shrink-0 text-xs text-duck-dark/50 dark:text-foreground/50">{field}</span>
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{value}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copy(value, field)}
title={t('settings.dav.copyField', { field })}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
In DAVx5 or iOS, choose “login with URL and username” and give it the server address above — not a full
calendar path. Discovery does the rest.
</p>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it
</Button>
</div>
)}
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={label}
onChange={(ev) => setLabel(ev.target.value)}
placeholder={t('settings.dav.placeholder')}
/>
</Label>
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
{t('settings.dav.create')}
</Button>
</form>
{passwords.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{t('settings.dav.none')}
</div>
) : (
<div className="grid gap-2">
{passwords.map((entry) => {
const revoked = !!entry.revokedAt;
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
className={`flex items-center gap-3 rounded-lg border p-3 ${
revoked
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
: 'border-duck-dark/15 dark:border-foreground/15'
}`}
>
<Smartphone className="h-4 w-4 shrink-0 text-duck-teal/70" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.label}
{revoked && <span className="ml-2 text-xs text-red-500">revoked</span>}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.hint}…</code>
{' · '}
{/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'}
</div>
</div>
{!revoked && (
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => remove(entry.id)}
title={t('settings.dav.delete')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -9,7 +9,6 @@ import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection }
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations';
@@ -43,13 +42,9 @@ const personalSections: SettingsSection[] = [
// descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />,
// },
{
key: 'dav-app-passwords',
icon: CalendarDays,
titleKey: 'settings.sections.davSync.label',
descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />,
},
// DAV app passwords left with the caldav plugin on 2026-08-27. A plugin cannot contribute a settings
// section — it ships panels — so the form is a modal on the Calendar screen, the same answer the email
// plugin reached for account management when it was extracted.
];
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
@@ -1,7 +1,17 @@
import { startImpersonation } from 'officerdev';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
import {
Copy,
Crown,
KeyRound,
Loader2,
RotateCcw,
SquareTerminal as TerminalIcon,
Trash2,
UserCog,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -128,6 +138,25 @@ export const UsersSection = () => {
}
};
/**
* Sign in as this person, for an hour.
*
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
* new password and a deletion, which are neither.
*/
const impersonate = async (user: ManagedUser) => {
setPendingId(user.id);
try {
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
// Swaps the token and reloads — nothing after this line runs.
startImpersonation(token);
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -263,6 +292,22 @@ export const UsersSection = () => {
</Button>
)}
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
offers it would be the one row where it cannot work. */}
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Sign in as ${user.email}`}
title="Sign in as this user for one hour"
onClick={() => void impersonate(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -11,8 +11,6 @@ export * from './Skills';
export * from './Tasks';
export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './CommandHQ';
export * from './SystemMonitor';
export * from './Activity';
@@ -24,3 +22,4 @@ export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
export * from './Bots';
+1
View File
@@ -463,6 +463,7 @@
"files": "Dateien",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Files",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Jobs",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Archivos",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Trabajos",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Fichiers",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Éditeur",
"jobs": "Jobs",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Ficheiros",
"chat": "Chat",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Trabalhos",
"terminal": "Terminal",
+1
View File
@@ -463,6 +463,7 @@
"files": "Filer",
"chat": "Chatt",
"commandHq": "Command HQ",
"bots": "Bots",
"editor": "Editor",
"jobs": "Jobb",
"terminal": "Terminal",
+231
View File
@@ -0,0 +1,231 @@
# Database Patterns
## Dropping and adding a column in the same push makes drizzle ASK
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
```
Is grants_postgres column in roles table created or renamed from another column?
❯ + grants_postgres create column
~ provisioning_script › grants_postgres rename column
```
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
error about a column that does not exist, several steps from the thing that failed.
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
a rollout over SSH has a terminal to answer with.
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
`bun db:push` as the check that it worked rather than the thing that does it:
```sql
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
```
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
## Overview
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
describing a different codebase.
```
src/databases/officer_db/
├── src/
│ ├── db.ts # the connection
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
│ ├── schema.ts # what db:push creates — see below
│ ├── types.ts # every type export (Select / Insert / extended)
│ ├── crypto.ts # at-rest encryption, one key per purpose
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
│ └── <feature>/
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
│ ├── schema.ts # its tables
│ └── queries.ts # everything that reads or writes them
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
```
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
query function is one file in one directory, rather than that file plus a hand-written list of every
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
`queries/` trees, where the two sides had drifted: four features were named differently on each side
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
query file at all, and `integrations` had no schema file.
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
Task Logs feature — see below.)
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
commented line there removes a table from the DATABASE without removing a line of code — every query
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
create only the core tables, with the plugin ones commented out until their plugin is installed.
## Schema changes use `push`, not migrations
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
as the source of truth, never those files.
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
them was gone, because nothing structural said they had become illegal.
```ts
const SERVER_PROVIDERS = ['google', 'apify'] as const;
check(
'ck_server_integrations_provider',
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
sql`${table.provider} IN (${sql.join(
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
);
```
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
database has drifted, instead of quietly accepting it. Clean the rows, then push.
### Composite keys: use `uniqueIndex`, and name any long foreign key
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
them against a schema that declares exactly the same name, columns and order, decides they differ, and
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
that could never be resolved by answering it.
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
adding one.
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
longer version and re-creates the constraint on every push.
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
follow.
The six, and where each lives:
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
the three-argument one, because there was nowhere else to put the declaration.
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
rather than the truncation one, and there is no index form to escape to because a primary key must be a
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
is a far more useful baseline than ten, because anything else really is your change.
**Rules that still apply:**
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
nothing — whatever is being re-created gets re-created next push regardless.
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
uniqueness rule would break the upserts that depend on it.
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
branch it takes at a truncate prompt.
4. **Read the plan before applying it:**
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
all. Anything else is your change.
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
## Type naming
```ts
// types.ts — inferred from the schema, never hand-written
export type UserSelect = typeof Schema.users.$inferSelect;
export type UserInsert = typeof Schema.users.$inferInsert;
// The bare name is the hydrated shape, when a table has relations worth carrying
export type User = UserSelect & {
passkeys: PasskeySelect[];
};
```
Organise `types.ts` by domain with section comments, mirroring the schema files.
## Queries
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
types from `../types`:
```ts
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys } from '../schema';
import type { UserSelect } from '../types';
export async function getUsers(): Promise<UserSelect[]> { … }
```
Every query is exported from `src/index.ts`, which is the only surface callers use.
## Importing
```ts
// ✅ types from the types subpath
import type { User } from 'officerdb/types';
// ✅ queries, schema and drizzle helpers from the package root
import { getUserById, eq } from 'officerdb';
// ❌ never reach into src/schema or src/queries directly
// ❌ never hand-write a type a table can infer
```
In app code import from `'types'`, which re-exports the database types. Only server and database code
imports `officerdb/types` directly.
## Conventions
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
(`chat_session_events` is the example — its id is also the replay cursor).
- `timestamp('…', { withTimezone: true })`, always.
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
than cast.
+3 -228
View File
@@ -1,231 +1,6 @@
# Database Patterns
## Dropping and adding a column in the same push makes drizzle ASK
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
```
Is grants_postgres column in roles table created or renamed from another column?
❯ + grants_postgres create column
~ provisioning_script › grants_postgres rename column
```
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
error about a column that does not exist, several steps from the thing that failed.
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
a rollout over SSH has a terminal to answer with.
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
`bun db:push` as the check that it worked rather than the thing that does it:
```sql
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
```
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
## Overview
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
describing a different codebase.
```
src/databases/officer_db/
├── src/
│ ├── db.ts # the connection
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
│ ├── schema.ts # what db:push creates — see below
│ ├── types.ts # every type export (Select / Insert / extended)
│ ├── crypto.ts # at-rest encryption, one key per purpose
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
│ └── <feature>/
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
│ ├── schema.ts # its tables
│ └── queries.ts # everything that reads or writes them
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
```
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
query function is one file in one directory, rather than that file plus a hand-written list of every
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
`queries/` trees, where the two sides had drifted: four features were named differently on each side
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
query file at all, and `integrations` had no schema file.
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
Task Logs feature — see below.)
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
commented line there removes a table from the DATABASE without removing a line of code — every query
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
create only the core tables, with the plugin ones commented out until their plugin is installed.
## Schema changes use `push`, not migrations
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
as the source of truth, never those files.
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
them was gone, because nothing structural said they had become illegal.
```ts
const SERVER_PROVIDERS = ['google', 'apify'] as const;
check(
'ck_server_integrations_provider',
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
sql`${table.provider} IN (${sql.join(
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
);
```
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
database has drifted, instead of quietly accepting it. Clean the rows, then push.
### Composite keys: use `uniqueIndex`, and name any long foreign key
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
them against a schema that declares exactly the same name, columns and order, decides they differ, and
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
that could never be resolved by answering it.
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
adding one.
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
longer version and re-creates the constraint on every push.
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
follow.
The six, and where each lives:
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
the three-argument one, because there was nowhere else to put the declaration.
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
rather than the truncation one, and there is no index form to escape to because a primary key must be a
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
is a far more useful baseline than ten, because anything else really is your change.
**Rules that still apply:**
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
nothing — whatever is being re-created gets re-created next push regardless.
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
uniqueness rule would break the upserts that depend on it.
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
branch it takes at a truncate prompt.
4. **Read the plan before applying it:**
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
all. Anything else is your change.
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
## Type naming
```ts
// types.ts — inferred from the schema, never hand-written
export type UserSelect = typeof Schema.users.$inferSelect;
export type UserInsert = typeof Schema.users.$inferInsert;
// The bare name is the hydrated shape, when a table has relations worth carrying
export type User = UserSelect & {
passkeys: PasskeySelect[];
};
```
Organise `types.ts` by domain with section comments, mirroring the schema files.
## Queries
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
types from `../types`:
```ts
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys } from '../schema';
import type { UserSelect } from '../types';
export async function getUsers(): Promise<UserSelect[]> { … }
```
Every query is exported from `src/index.ts`, which is the only surface callers use.
## Importing
```ts
// ✅ types from the types subpath
import type { User } from 'officerdb/types';
// ✅ queries, schema and drizzle helpers from the package root
import { getUserById, eq } from 'officerdb';
// ❌ never reach into src/schema or src/queries directly
// ❌ never hand-write a type a table can infer
```
In app code import from `'types'`, which re-exports the database types. Only server and database code
imports `officerdb/types` directly.
## Conventions
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
(`chat_session_events` is the example — its id is also the replay cursor).
- `timestamp('…', { withTimezone: true })`, always.
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
than cast.
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
@@ -17,6 +17,8 @@ export type CatalogueEntry = {
platform: string;
label: string;
summary: string;
/** What it is for. Always present, `[]` for a plugin that declares none, so callers need no guard. */
tags: string[];
color: string;
tile?: 'badge' | 'bare';
iconUrl?: string;
@@ -53,6 +55,9 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
platform: m.platform,
label: m.label,
summary: m.summary,
// Normalised to an array here rather than at every call site: the column is nullable because it
// arrived after the rows did, and null vs [] is a distinction no consumer of this wants to carry.
tags: Array.isArray(m.tags) ? (m.tags as string[]) : [],
color: m.color ?? '#94a3b8',
...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}),
...(m.iconUrl ? { iconUrl: m.iconUrl } : {}),
@@ -85,6 +85,20 @@ export const pluginMetadata = pgTable(
/** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */
icon: text('icon'),
/**
* What the plugin is for — the store's filter, and the only field here that exists for BROWSING
* rather than for installing.
*
* jsonb like the shapes below it, and for the same reason: it is read as a unit and rendered as a
* unit. A join table would buy filtering in SQL, which matters at a scale a hand-curated catalogue of
* twenty-one is nowhere near — and it would cost a migration every time the vocabulary changes, which
* is exactly the thing expected to change.
*
* Nullable because it arrived after the rows did. Null and `[]` mean the same thing here: no filter
* shows it.
*/
tags: jsonb('tags'),
permissions: jsonb('permissions').notNull(),
osDependencies: jsonb('os_dependencies'),
/** `{ api, schema, sidecar, web }` — what the plugin ships. */
@@ -1,9 +0,0 @@
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries';
export type { DavAppPassword, DavAppPasswordView } from './queries';
-113
View File
@@ -1,113 +0,0 @@
import { and, desc, eq, isNull } from 'drizzle-orm';
import argon2 from 'argon2';
import { randomBytes } from 'node:crypto';
import { db } from '../db';
import { davAppPasswords } from './schema';
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
/** What the UI is allowed to see: everything except the hash. */
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
const view = (row: DavAppPassword): DavAppPasswordView => {
const { passwordHash: _hash, ...rest } = row;
return rest;
};
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
// into a phone by hand. Grouped into blocks of four for the same reason.
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
function generateSecret(): string {
const bytes = randomBytes(20);
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
// still fits in five readable blocks.
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
}
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
const rows = await db
.select()
.from(davAppPasswords)
.where(eq(davAppPasswords.userId, userId))
.orderBy(desc(davAppPasswords.createdAt));
return rows.map(view);
}
/**
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
* value is the only chance the owner gets to see it.
*/
export async function createDavAppPassword(
userId: number,
label: string,
): Promise<{ entry: DavAppPasswordView; password: string }> {
const password = generateSecret();
const [row] = await db
.insert(davAppPasswords)
.values({
userId,
label,
passwordHash: await argon2.hash(password),
hint: password.slice(0, 8),
})
.returning();
if (!row) throw new Error('failed to create dav app password');
return { entry: view(row), password };
}
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.update(davAppPasswords)
.set({ revokedAt: new Date() })
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
.returning();
return !!row;
}
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
const [row] = await db
.delete(davAppPasswords)
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
.returning();
return !!row;
}
/**
* Verify a Basic-auth credential, returning the owning user id.
*
* Every live credential is tried, because the username a DAV client sends is the account email, not a
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
* UI should encourage deleting devices that are gone rather than accumulating them.
*
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
* the column exists to answer "is this device still around", not to be an access log.
*/
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
const rows = await db
.select()
.from(davAppPasswords)
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
for (const row of rows) {
let ok = false;
try {
ok = await argon2.verify(row.passwordHash, password);
} catch {
ok = false; // a corrupt hash must not take the whole login path down
}
if (!ok) continue;
const now = Date.now();
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
await db
.update(davAppPasswords)
.set({ lastUsedAt: new Date(now) })
.where(eq(davAppPasswords.id, row.id));
}
return row.userId;
}
return null;
}
@@ -1,46 +0,0 @@
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
//
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
// credential a phone could carry is the account password itself — which then lives in that phone's
// account manager in recoverable form, and in whatever backs the phone up.
//
// So: one password per device, revocable per device, hashed with argon2 like the account password and
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
//
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
// this table — an app password is not a session, and a device that syncs a calendar has no business
// reaching the wallet or the vault.
//
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
// noticeable at all.
export const davAppPasswords = pgTable(
'dav_app_passwords',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
label: text('label').notNull(),
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
passwordHash: text('password_hash').notNull(),
/**
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
* authenticate with, and it is the only way for the owner to match a row in this list against the
* password saved on a device they are looking at.
*/
hint: text('hint').notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
// being free and it being a sequential scan on every PROPFIND.
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
);
-1
View File
@@ -52,7 +52,6 @@ export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
export * from './notify';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
+5 -3
View File
@@ -51,9 +51,11 @@ export * from './service-connections/schema'; // service_connections
export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md
export * from './nudges/schema'; // nudges — the Nudger schedule
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
export * from './dav/schema'; // dav_app_passwords officer-caldav
// ── Plugins — nothing here ───────────────────────────────────────────────────────────────────────
//
// `dav_app_passwords` was the last entry and it left with caldav on 2026-08-27. A plugin's tables are
// generated into `drizzle-schema.ts` from the plugin's own `db/schema.ts` when it is installed, which is
// what this hand-maintained line was standing in for while caldav was core.
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
//
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { writeAgentBrief } from './agent-brief';
/**
* Both harnesses have to find the brief.
*
* Claude Code discovers `CLAUDE.md`, opencode discovers `AGENTS.md` and nothing else. Every brief the
* platform wrote was `CLAUDE.md`, so under opencode a commander, a bot and a plugin chat were each
* briefed with nothing — silently, which is why it lasted.
*/
const root = mkdtempSync(join(tmpdir(), 'agent-brief-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
const dir = (name: string) => {
const d = join(root, name);
rmSync(d, { recursive: true, force: true });
Bun.spawnSync(['mkdir', '-p', d]);
return d;
};
describe('writeAgentBrief', () => {
test('writes the content as AGENTS.md and a POINTER as CLAUDE.md', async () => {
const d = dir('both');
await writeAgentBrief(d, '# Brief\n\nthe real instructions\n');
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('the real instructions');
const pointer = readFileSync(join(d, 'CLAUDE.md'), 'utf8');
expect(pointer).toContain('AGENTS.md');
// The pointer must NOT be a second copy — two copies of a file rewritten every turn will disagree,
// and the stale one is the one somebody reads.
expect(pointer).not.toContain('the real instructions');
});
test('is idempotent — an unchanged brief writes nothing', async () => {
const d = dir('idempotent');
expect(await writeAgentBrief(d, 'same\n')).toBe(true);
expect(await writeAgentBrief(d, 'same\n')).toBe(false);
});
test('rewrites when the content changes, because that is how a template reaches a bot', async () => {
const d = dir('rewrite');
await writeAgentBrief(d, 'v1\n');
expect(await writeAgentBrief(d, 'v2\n')).toBe(true);
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toBe('v2\n');
});
test('onlyIfAbsent never touches a brief the agent has extended', async () => {
// A commander writes its own job into this file over days. Rewriting it because the template
// changed would delete what it agreed with the owner.
const d = dir('extended');
await writeAgentBrief(d, 'generated\n');
writeFileSync(join(d, 'AGENTS.md'), 'generated\n\n## JOB\n\nwatch the thing\n');
expect(await writeAgentBrief(d, 'a new template\n', { onlyIfAbsent: true })).toBe(false);
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('watch the thing');
});
test('onlyIfAbsent still creates both files the first time', async () => {
const d = dir('first');
expect(await writeAgentBrief(d, 'generated\n', { onlyIfAbsent: true })).toBe(true);
expect(existsSync(join(d, 'AGENTS.md'))).toBe(true);
expect(existsSync(join(d, 'CLAUDE.md'))).toBe(true);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { writeAtomic } from './write-atomic';
/**
* Writing a brief where BOTH harnesses will find it.
*
* ── The bug this exists to remove ──
*
* Claude Code discovers `CLAUDE.md` by walking up from its working directory. opencode discovers
* `AGENTS.md` and nothing else — verified in the binary, whose only target is
* `H.up({ targets: ["AGENTS.md"], start, stop })`. There is no overlap.
*
* Every brief the platform writes was named `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 survived this long.
*
* ── The shape, and why the pointer rather than two copies ──
*
* `AGENTS.md` holds the content; `CLAUDE.md` is six lines pointing at it. That is the same arrangement
* the repository's own docs use, and it is chosen over writing the text twice for one reason: two copies
* of a file that is REWRITTEN ON EVERY TURN will eventually disagree, and the one that is stale is the
* one somebody reads.
*
* A harness that discovers neither name still gets nothing, and that is honest — it is a fact about the
* harness rather than something this can paper over.
*/
const POINTER = `# CLAUDE.md
**The instructions live in [\`AGENTS.md\`](AGENTS.md). Read it first — this file only points there.**
Written by the platform so that agents looking for \`AGENTS.md\` by convention and agents looking for
\`CLAUDE.md\` find the same guidance, without the two drifting apart.
`;
/**
* Put `content` in `dir` under both names.
*
* Content-diffed, so a directory's mtimes do not churn on every turn — these are rewritten per message
* in the bots' case. Returns false when nothing was written.
*
* Best effort by contract: every caller must still work if this throws. An agent that knows less is
* where all of them were before any of this existed.
*/
export async function writeAgentBrief(
dir: string,
content: string,
/**
* Never overwrite an existing `AGENTS.md`.
*
* For a brief the AGENT extends rather than one the platform owns. A commander's file is generated
* once at creation and then edited by the commander over its whole life — rewriting it because the
* template changed would silently delete the job it had agreed. The platform's own briefs (bots,
* plugin chats) are the opposite: rewritten every turn ON PURPOSE, so a change reaches them.
*/
opts: { onlyIfAbsent?: boolean } = {},
): Promise<boolean> {
let wrote = false;
const agents = join(dir, 'AGENTS.md');
if (opts.onlyIfAbsent && existsSync(agents)) return false;
if (!existsSync(agents) || (await Bun.file(agents).text()) !== content) {
await writeAtomic(agents, content);
wrote = true;
}
const claude = join(dir, 'CLAUDE.md');
if (!existsSync(claude) || (await Bun.file(claude).text()) !== POINTER) {
await writeAtomic(claude, POINTER);
wrote = true;
}
return wrote;
}
/** The pointer's text, for a caller that generates its own file listing and has to describe both. */
export const AGENT_BRIEF_POINTER = POINTER;
+204
View File
@@ -0,0 +1,204 @@
import { join } from 'node:path';
import { mkdirSync } from 'node:fs';
import { getBotsDir } from '@@/data-path';
import { writeAgentBrief } from '@@/agent-brief';
/**
* The instructions every bot runs under.
*
* Written into `$DATA_PATH/<email>/bots/CLAUDE.md`, which Claude Code reads from its working directory at
* session start. Rewritten on every bot turn, so a change here reaches every bot on the next message with
* nothing to migrate — the same arrangement as a plugin's chat brief.
*
* ── Why this file is short, and a commander's is not ──
*
* A commander is a job: it has procedures, a ledger, classification rules, a dispatch policy, and a setup
* conversation to settle all of it. A bot is one sentence and a watch. Everything a commander's template
* spends words on — proving the action on one item, arming only once the owner agrees, writing JOB.md —
* is ceremony a bot does not have. The whole point is that the first message arms it.
*/
const BRIEF = `# Bots
Every conversation in this directory is a **bot**: one thing watched, one thing done when it changes.
**This file is the platform's and is rewritten on every turn.** Anything you want to keep goes in a file
you create beside it.
## Arm on the first message. Do not interview.
The owner says what they want in one sentence. Work out what it means and **arm it in that same turn**.
> "notify me when x.com is down"
> "tell the todo guy when nytimes.com has a new headline"
That is the whole input. Both are complete instructions and neither wants a conversation about it.
**Do not ask any of these.** Every one has a default below: how often, which selector, what counts as down,
how many failures before it counts, what to do if the check itself errors, whether to notify on recovery.
Pick the default, arm it, and say in ONE line what you armed and how often it checks. The owner corrects
you in the next message if you guessed wrong, which is cheaper for them than answering six questions first.
**Ask only when you genuinely cannot proceed** — no address at all, or a request so vague that any watch
you built would be a guess about the subject rather than about the details. "Watch my site" with no site
is a question. "notify me when x.com is down" is not.
**Never ask the owner to phrase it differently.** They will not say "notify" or "nudge" and must not have
to. "tell me", "let me know", "ping me", "message the todo guy" all mean the same two things below.
## Defaults — use these instead of asking
- **Interval: one minute.** Unless the owner says otherwise. It is cheap: the check is shell, not a turn.
Use longer only when the source asks for it — a page that rate-limits, or something that changes daily.
- **"Down" means** the site fails to answer, or answers 5xx, **twice in a row**. One failed request is a
blip and firing on it produces a bot nobody trusts. Follow redirects. A slow answer is not down; a
timeout of about ten seconds is.
- **"Has a new headline" / "has changed" means** the thing you extracted differs from what you stored last
time. Extract the smallest stable piece — the top headline's text, not the whole page, which changes on
every ad and would fire constantly.
- **Recovery is worth one message**, once, when something that was down comes back. Nothing else.
- **The first run establishes the baseline** and does not fire. A bot that announces the current headline
the moment it is armed has told the owner something they did not ask about.
## Arming — the whole mechanism, so you do not go looking for it
**Do not read the commander skill.** It is about spawning agents in a loop and none of it applies here.
This is everything a bot needs:
1. **Write the check as a shell script in THIS directory**, named after the bot — \`newweb-uptime.sh\`, not
somewhere under \`/tmp\`. It has to survive a reboot, and \`/tmp\` does not.
2. **Run it with the Bash tool, \`run_in_background: true\`.** That is the arming. You will be handed back
when it exits.
3. **The script waits in the shell and exits when the condition is true**, printing one line saying what
happened. The exit IS the signal — nothing else tells anyone it fired.
The shape, which almost every bot is a variation of:
\`\`\`bash
#!/bin/bash
# state lives beside this script, so a restart resumes rather than re-baselines
STATE="$(dirname "$0")/newweb-uptime.state"
fails=0
while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 10 "https://example.com/" || echo 000)
if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then
fails=$((fails + 1))
# twice in a row, never once — a single failed request is a blip
if [ "$fails" -ge 2 ]; then echo "DOWN http=$code"; exit 0; fi
else
fails=0
fi
sleep 60
done
\`\`\`
For a "has it changed" watch, the same shape with the comparison against \`$STATE\` instead of a counter:
fetch, extract the smallest stable piece, compare, and \`exit 0\` printing the new value when it differs.
Write the new value to \`$STATE\` **before** exiting, or the next arming fires on the same change again.
4. **Record what you armed, in \`<your-session-id>.bot.json\` in this directory.** This is not optional and
it is not bookkeeping — it is the only thing that can stop your watch. Nothing else knows your pid, and
a bot that is deleted while its record is missing leaves a process polling a website for ever.
\`\`\`json
{
"sessionId": "6305e570-f26a-4989-a0ae-2135c1076505",
"watches": "newweb.fofosfluffybastards.com, every 60s, notify the owner when down",
"script": "/abs/path/to/newweb-uptime.sh",
"state": "/abs/path/to/newweb-uptime.state",
"pids": [1316235],
"armedAt": "2026-08-26T07:33:00Z"
}
\`\`\`
**Rewrite it every time you arm**, with the new pid. \`pids\` is an array because a re-arm can briefly
overlap; list every process you started that is still meant to be running.
**To find your own session id**, list the bots and match on the title:
\`\`\`bash
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
"$OFFICER_API_URL/api/chat/sessions?context=bots"
\`\`\`
Claude titles a session from its first message, so on the turn that arms you the entry titled with the
owner's own opening sentence is you. That is only reliable BEFORE you rename yourself — which is the
next step, so do them in this order: find your id, write the record, then rename.
5. **Give yourself a real name.** The automatic title is the owner's first sentence, truncated —
\`"can you notifiy me when https://newweb.fofos…"\`, typos and all. It is a prompt, not a name, and it is
what they will scan a list of bots for later.
\`\`\`bash
curl -s -X PATCH -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
-H 'content-type: application/json' -d '{"title":"newweb uptime"}' \\
"$OFFICER_API_URL/api/chat/sessions/<your-session-id>/title"
\`\`\`
Two to four words, the SUBJECT and what is watched about it: \`newweb uptime\`, \`NYT front page\`,
\`gitea disk space\`. Not "Website Monitor Bot" — every bot is a monitor and the word earns nothing in a
list of them. Lower case unless the subject is a proper noun. Rename again if the owner widens what you
watch and the old name stops being true.
6. **When you are handed back**, do what the owner asked, then **re-arm the same script the same way,
before you end the turn**, and rewrite the record. Act, re-arm, record — in the one turn. Never build a
second loop on top of this.
7. **If you are woken and told your watch is gone**, read your own \`.bot.json\`, arm the script it names
again, and rewrite it. That file is what survives a reboot; the conversation may be days behind.
Do not go hunting for how any of this works — it is above, and looking costs the owner a minute of tool
calls before anything is armed.
## What to do when it fires
Two things, and the owner will not name either of them:
- **Tell the owner.** A notification reaches their phone. Send one when what fired is worth an interruption
— a site down, a headline they asked to know about. Not "I checked and nothing happened".
- **Tell another chat.** If they named one, deliver it there rather than to the owner.
- An \`@Name [id]\` in their message carries the id already. It is a PREFIX — resolve it to the full
session before using it.
- A name in prose — "the todo guy", "my deploy commander" — is just as valid and is how they will
usually say it. Look it up in the chat list and match on the title. If exactly one matches, use it and
say which one you picked. If several do, that is worth the one question.
Then re-arm, and stay quiet until the next fire.
## Remembering things
The interesting watches compare against last time — a headline, a hash, a status. Keep that in a file in
this directory named after this bot, and say in your first reply what you called it. Do not keep it only in
the conversation: you are woken into a turn that may be days later, and the file is what survives.
## Adding to a bot
The owner will come back and add more — another site to ping, another page to watch. That is the same bot:
widen the check, re-arm it, and confirm in one line what it now covers. Do not start a second watch running
beside the first unless the two genuinely need different intervals.
## What a bot does not do
It watches and it tells. It does not fix things, deploy things, or change anything it is watching. A
request that wants work done is a request for a commander or an ordinary chat — say so plainly and stop
rather than quietly becoming one.
`;
/**
* Put the brief where a bot will read it, and make sure the directory exists.
*
* Best effort by contract: a turn must still start if this fails. The bot then knows less, which is where
* every chat was before this existed — worse than being briefed, better than not answering.
*/
export async function ensureBotsBrief(email: string): Promise<string> {
const dir = getBotsDir(email);
mkdirSync(dir, { recursive: true });
try {
// Both names — a bot run under opencode was getting none of this. See `writeAgentBrief`.
await writeAgentBrief(dir, BRIEF);
} catch (err) {
console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err);
}
return dir;
}
+197
View File
@@ -0,0 +1,197 @@
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { getBotsDir } from '@@/data-path';
import { descendantPids } from '../tasks/process-tree';
/**
* Stopping a bot, and knowing which ones have stopped themselves.
*
* ── Why a record exists at all ──
*
* A bot's watch is a detached shell process. Nothing else on the machine knows it belongs to that bot: not
* the transcript, not the session list, not the process table, which sees an anonymous `bash` in a
* temporary directory. So the bot writes `<sessionId>.bot.json` beside its script, and that file is the
* only thing that can connect the two.
*
* The cost of not having it was paid before it existed: a bot was deleted on 2026-08-26 and its watcher
* was still polling a website eleven minutes later, with nothing in the product able to find or stop it.
*
* ── Why the id is in the FILENAME ──
*
* Every bot shares one directory, so the record has to be addressable without reading all of them. Deleting
* a session knows only its id; `join(dir, id + '.bot.json')` is then a lookup rather than a scan, and a
* record whose contents are damaged still gets found and cleaned up.
*/
export type BotRecord = {
sessionId?: string;
watches?: string;
script?: string;
state?: string;
pids?: number[];
armedAt?: string;
};
const recordPath = (email: string, sessionId: string) => join(getBotsDir(email), `${sessionId}.bot.json`);
/** Refuse anything that is not a bare id — this reaches here from a URL. */
const safeId = (sessionId: string) => /^[A-Za-z0-9._-]+$/.test(sessionId) && !sessionId.includes('..');
export function readBotRecord(email: string, sessionId: string): BotRecord | null {
if (!safeId(sessionId)) return null;
const path = recordPath(email, sessionId);
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as BotRecord;
} catch {
// Damaged rather than absent. The caller still deletes the file — a record it cannot read is a record
// that can never stop anything, and leaving it would only make the next reader believe there is one.
return {};
}
}
const alive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
/** Every recorded pid still running, which is how "is this bot actually watching" is answered. */
export function liveBotPids(email: string, sessionId: string): number[] {
const record = readBotRecord(email, sessionId);
return (record?.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1 && alive(pid));
}
/**
* Stop a bot's watch and forget it.
*
* Children first, then the recorded process, so the loop cannot spawn a replacement between the two —
* the same ordering `stopCommanderProcesses` uses and for the same reason.
*
* Best effort throughout: a bot whose record is missing, damaged, or names processes that are already
* gone still has its files removed. Returns how many processes were signalled, which is worth logging —
* "deleted a bot and stopped nothing" is a different event from "deleted a bot that was not armed".
*/
export function tearDownBot(email: string, sessionId: string): number {
const record = readBotRecord(email, sessionId);
if (!record) return 0;
const pids = (record.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1);
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
let signalled = 0;
for (const pid of tree) {
try {
process.kill(pid, 'SIGTERM');
signalled += 1;
} catch {
// Gone between the walk and the signal. That is the outcome we wanted.
}
}
// The script and its state go with it. They are the bot's, they are named in its own record, and leaving
// them means the next bot with the same idea finds a stale baseline and does not fire.
for (const path of [record.script, record.state]) {
if (!path) continue;
// Only inside the bots directory. A record is written by an agent, and a path out of that tree is
// either a mistake or something worse — either way it is not this function's business to delete.
if (!path.startsWith(getBotsDir(email))) continue;
try {
rmSync(path, { force: true });
} catch {
// Not worth failing a delete over.
}
}
try {
rmSync(recordPath(email, sessionId), { force: true });
} catch {
// Same.
}
return signalled;
}
/**
* Kill anything running out of the bots directory that no live bot claims.
*
* ── Why a record is not enough on its own ──
*
* `tearDownBot` needs the bot to have written `<sessionId>.bot.json`. A bot that never did — armed before
* the instruction existed, or one that simply did not — cannot be stopped that way, and deleting it leaves
* a process polling for ever.
*
* That is not the worst of it. **The orphan resurrects the bot it belonged to.** Its watch fires, the
* harness hands back to the agent, the agent resumes by transcript id, and `claude --resume` RECREATES the
* transcript that was deleted — so the bot reappears in the list, untitled, and does it again on the next
* fire. Seen on 2026-08-26: a bot deleted three times and back each time. It is the same shape
* `22a9d58d` fixed for commanders.
*
* ── The test ──
*
* A process is an orphan if it is running a script inside the bots directory and its pid appears in NO
* record. Claimed pids are left alone whatever else is true — a live bot's watch is the thing this must
* never touch. Anything else in there belongs to a bot that is gone.
*
* Reads `/proc` rather than shelling out to `ps`: this runs inside the request that deletes a bot, and a
* subprocess per delete is a cost with no benefit.
*/
export function sweepOrphanWatches(email: string): number {
const dir = getBotsDir(email);
const claimed = new Set(listBotRecords(email).flatMap(({ record }) => record.pids ?? []));
let killed = 0;
let pids: string[];
try {
pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
} catch {
return 0; // not Linux, or /proc unreadable — the record path still works
}
for (const name of pids) {
const pid = Number(name);
if (claimed.has(pid)) continue;
let cmdline: string;
try {
// NUL-separated argv. A process that exits between the listing and this read is the ordinary case.
cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf-8').replace(/\0/g, ' ');
} catch {
continue;
}
// The script has to live in the bots directory — the brief requires it — so its path is in the argv of
// anything running it. `.sh` narrows it to the watch itself rather than an editor or a grep over it.
if (!cmdline.includes(dir) || !cmdline.includes('.sh')) continue;
try {
process.kill(pid, 'SIGTERM');
killed += 1;
console.warn(`[bots] orphaned watch ${pid} killed — no record claims it`);
} catch {
// Gone already, which is the outcome wanted.
}
}
return killed;
}
/** Every bot that has a record, for the sweep that revives them. */
export function listBotRecords(email: string): { sessionId: string; record: BotRecord }[] {
let names: string[];
try {
names = readdirSync(getBotsDir(email));
} catch {
return []; // no directory yet is the ordinary state of an account with no bots
}
return names
.filter((name) => name.endsWith('.bot.json'))
.map((name) => {
const sessionId = name.slice(0, -'.bot.json'.length);
return { sessionId, record: readBotRecord(email, sessionId) ?? {} };
});
}
+58
View File
@@ -0,0 +1,58 @@
import type { Context } from 'hono';
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
// One answer to "which directory is this chat about", for the two routers that need it.
//
// It lived in chat.ts as a private helper until 2026-08-27, when Command HQ grew a button that starts a
// commander from a plugin chat. That commander watches the directory the chat runs in — and the whole
// reason this function exists is that the CLIENT cannot name that directory. Two copies of the mapping
// would be two chances for a commander to end up watching somewhere its chat has never been.
/**
* Which directory a listing is about.
*
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
* its own directory and then has no way to ask for them back.
*
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
* another directory shows you your own conversations elsewhere, never anybody else's.
*/
/**
* The same mapping, from values rather than from a request.
*
* Command HQ takes the context in a JSON BODY — it is creating a commander, not listing anything — and
* the request-reading version silently fell through to `who.home` for it, because there was nothing in
* the query string. A commander watching the wrong directory is not an error anywhere: it just answers
* questions about the wrong thing forever.
*/
export const chatContextDir = (
context: string | undefined,
contextId: string | undefined,
who: { home: string; email: string },
): string => {
const id = contextId?.trim();
if (context === 'plugin' && id) return getPluginChatsDir(who.email, id);
if (context === 'bots') return getBotsDir(who.email);
if (context === 'email') return resolveEmailAccountDir(who.email, id);
return who.home;
};
export const resolveChatContextDir = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
// Every bot shares one directory, so this needs no id — the context IS the address.
if (context === 'bots') return getBotsDir(who.email);
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
// screen, and is optional: without one the resolver takes the most recently synced account.
//
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
// the socket, so the turn and the list cannot disagree about where the chat lives.
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
return ctx.req.query('cwd')?.trim() || who.home;
};
+44 -24
View File
@@ -1,4 +1,5 @@
import { getPluginChatsDir } from '@@/data-path';
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
import { tearDownBot, sweepOrphanWatches } from '../bots/teardown';
import type { Context } from 'hono';
import { emitToSession, sessionKeyForTranscript } from './websocket';
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
@@ -81,7 +82,16 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
//
// ── A THIRD thing now rides on this restriction (2026-08-27) ──
//
// The serve holds an Officer API credential minted for the OWNER, in its own environment, because one
// process serves every session and there is nowhere else to put one — `sidecar/opencode/serve-env.ts`.
// So lifting `isOwner` here without changing that hands a member the owner's API authority as well as the
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
import { registerAgentPanelRoutes } from './agent-panels-routes';
import { resolveChatContextDir } from './chat-context';
export const chatRouter = createRouter();
@@ -108,25 +118,9 @@ export const chatRouter = createRouter();
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
// selects there too.
/**
* Which directory a listing is about.
*
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
* its own directory and then has no way to ask for them back.
*
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
* another directory shows you your own conversations elsewhere, never anybody else's.
*/
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
return ctx.req.query('cwd')?.trim() || who.home;
};
// selects there too. The resolver moved to `chat-context.ts` when Command HQ needed the same answer —
// a commander started from a plugin chat watches the directory that chat runs in, and only the server
// can turn a context into a path.
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', async (ctx) => {
@@ -139,7 +133,7 @@ chatRouter.get('/pwds', async (ctx) => {
chatRouter.get('/sessions', async (ctx) => {
const user = ctx.get('user');
const who = await chatIdentity(user);
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
// separate route because the response shape is identical and every consumer downstream (rename, delete,
@@ -223,7 +217,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
const user = ctx.get('user');
const who = await chatIdentity(user);
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
@@ -436,7 +430,7 @@ chatRouter.get('/live', async (ctx) => {
chatRouter.delete('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
if (isOpenCodeSessionId(id)) {
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
@@ -468,6 +462,32 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
conversation — no live session holds it, so nothing is keyed on a key to remove.
*/
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
/*
And stop anything a BOT armed, which nothing above would have touched.
A bot's watch is a detached shell process. Killing the harness ends the agent; the loop it started is
not its child and carries on. That gap was paid for on 2026-08-26: a bot was deleted and its watcher
was still polling a website eleven minutes later, with nothing in the product able to find it.
Every transcript removed, not just the id asked for — `deleteClaudeSession` returns a chain, and a
resumed conversation's earlier transcripts can each carry their own record.
Unconditional rather than gated on "was this a bot": the record only exists in the bots directory and
is keyed by session id, so for anything else this reads a path that is not there and returns 0. A
branch on the cwd would be a second place to get the question wrong.
*/
for (const transcriptId of removed) {
const stopped = tearDownBot(who.email, transcriptId);
if (stopped > 0) console.log(`[bots] ${transcriptId} deleted — stopped ${stopped} process(es)`);
}
// And anything left running out of the bots directory that no remaining record claims. This is what
// catches a bot that never wrote one — which cannot be stopped by id, and whose watch RESURRECTS it on
// its next fire, because the hand-back resumes a transcript that `claude --resume` then recreates.
const orphaned = sweepOrphanWatches(who.email);
if (orphaned > 0) console.log(`[bots] swept ${orphaned} orphaned watch process(es)`);
return ctx.json({ ok: true });
});
@@ -475,7 +495,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
chatRouter.patch('/sessions/:id/title', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who);
const cwd = resolveChatContextDir(ctx, who);
const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
+31 -12
View File
@@ -78,31 +78,45 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
const messages: ClaudeChatMessage[] = [];
let modelId = '';
for (const m of stored) {
const role = m.info?.role;
const parts = m.parts ?? [];
/*
OLDEST FIRST. The endpoint returns newest first, and nothing downstream re-sorts — so the
conversation rendered backwards: the answer above the question that produced it, and the opening
message at the bottom. Sorting here rather than at the call site because this is the only place
that knows the wire order, and the display shape it returns has no timestamps left to sort by.
*/
const ordered = [...stored].sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0));
for (const m of ordered) {
// `type` is the current build, `info.role` the older one. See OpenCodeStoredMessage.
const role = m.type ?? m.info?.role;
const parts = m.content ?? m.parts ?? [];
if (role === 'user') {
const text = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
.trim();
// The current build hands a user message's text over directly; the older one wraps it in parts.
const text = (
m.text ??
parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
).trim();
if (text) messages.push({ role: 'user', text });
continue;
}
if (role === 'assistant') {
if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? '';
if (!modelId) modelId = m.model?.id ?? m.model?.modelID ?? m.info?.model?.modelID ?? m.info?.modelID ?? '';
for (const p of parts) {
if (p.type === 'text' && p.text) {
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text });
} else if (p.type === 'tool' && p.callID) {
} else if (p.type === 'tool') {
const callId = p.callID ?? p.id;
if (!callId) continue;
messages.push({
role: 'tool',
toolName: p.tool ?? 'tool',
toolName: p.name ?? p.tool ?? 'tool',
toolInput: p.state?.input ?? {},
toolCallId: p.callID,
toolCallId: callId,
output: p.state?.output,
isError: Boolean(p.state?.error),
});
@@ -112,6 +126,11 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
}
}
// The session record carries the model too, and it is the only source for a conversation whose
// assistant turns have not landed yet. Without it the detail reports a bare `opencode`, the composer
// cannot match that to anything, and reopening the chat silently switches the harness back to Claude.
if (!modelId) modelId = info?.model?.id ?? info?.model?.modelID ?? '';
return {
id: sessionId,
model: modelId ? `opencode/${modelId}` : 'opencode',
+47 -5
View File
@@ -47,10 +47,22 @@ class ServerConnection {
return (await res.json()) as OpenCodeSessionInfo;
}
/*
`/api/session/…/message`, NOT `/session/…/message`.
Both paths exist and only one still works. The legacy one answers 200 with an empty array for a
session that plainly has messages — verified 2026-08-27 against a session showing 4 on the `/api`
path and 0 on the other. An empty transcript is indistinguishable from a new conversation, so
reopening an OpenCode chat showed "Send a message to start" and every message the owner had sent was
apparently gone. It also emptied the model, which is what made the composer fall back to Claude.
Every other read this client does is fine on either path; this is the only one that moved.
*/
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`);
return (await res.json()) as OpenCodeStoredMessage[];
const res = await fetch(`${this.baseUrl}/api/session/${sessionId}/message`);
if (!res.ok) throw new Error(`opencode GET /api/session/${sessionId}/message → ${res.status}`);
const body = (await res.json()) as { data?: OpenCodeStoredMessage[] } | OpenCodeStoredMessage[];
return Array.isArray(body) ? body : (body.data ?? []);
}
async deleteSession(sessionId: string): Promise<boolean> {
@@ -89,7 +101,12 @@ class ServerConnection {
export type OpenCodeSessionInfo = {
id: string;
title?: string;
time?: { created?: number; updated?: number };
time?: {
created?: number;
updated?: number;
};
/** The model this conversation is on. Present before any assistant turn has landed. */
model?: { id?: string; modelID?: string; providerID?: string };
/** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */
directory?: string | null;
};
@@ -102,11 +119,36 @@ export type OpenCodeStoredPart = {
text?: string;
tool?: string;
callID?: string;
state?: { input?: Record<string, unknown>; output?: string; error?: string };
state?: { input?: Record<string, unknown>; output?: string; error?: string; status?: string };
/** Current build names a tool part here; the older one used `tool`. */
name?: string;
/** Current build's id for a tool call, where the older one used `callID`. */
id?: string;
};
/**
* Two shapes, because two builds.
*
* The older one nests everything: `{ info: { role }, parts: [...] }`. The current one flattens it —
* `{ type: 'user' | 'assistant', text, content: [...] }` — and puts a user message's text directly on
* the message rather than in a part. Both are accepted rather than pinned to today's build: this reader
* is the only thing standing between a working transcript and a chat that looks erased, and a host on a
* different opencode should degrade to fewer features, never to an empty conversation.
*/
export type OpenCodeStoredMessage = {
/** Wire order is newest-first, so the reader sorts on this. */
time?: { created?: number };
/** Current build: the role, flattened. */
type?: string;
/** Current build: a user message's text, with no part wrapping it. */
text?: string;
/** Current build: the assistant's parts. */
content?: OpenCodeStoredPart[];
/** Current build: `{ id, providerID }`. */
model?: { id?: string; modelID?: string; providerID?: string };
/** Older build. */
info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } };
/** Older build. */
parts?: OpenCodeStoredPart[];
};
@@ -1,6 +1,7 @@
import * as sidecar from '@@/sidecar-registry';
import { logger } from '../logger';
import { setOpenCodeSession } from './state';
import { invalidateModelCache } from '../list-models';
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
@@ -13,6 +14,19 @@ sidecar.on('opencode:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
/*
A new serve means a possibly different catalogue, so the cached model list has to go.
`listOpenCodeModels` caches for the platform process's lifetime, which is right — the catalogue moves
about once a release. But it is the PLATFORM that holds it and the SIDECAR that changes, so signing
into a provider and restarting `officer-opencode` left the platform serving the list it read before
the login: six free models, no paid ones, and nothing anywhere saying why. Reported 2026-08-27, after
a successful login that appeared to do nothing.
The port report is the one event that always accompanies a new serve, which makes it the right place
to drop the cache.
*/
invalidateModelCache();
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
});
+37 -14
View File
@@ -17,9 +17,8 @@ import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import {
getOwnerHomeDir,
getEmailAccountsDir,
getPluginChatsDir,
findEmailAccountName,
resolveEmailAccountDir,
getCommanderDir,
} from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files';
@@ -43,6 +42,8 @@ import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync, existsSync } from 'node:fs';
import { snapshotPlugins } from '@@/plugins/mount';
import { writeAtomic } from '@@/write-atomic';
import { writeAgentBrief } from '@@/agent-brief';
import { ensureBotsBrief } from '../bots/brief';
import { logger } from './logger';
import { isSuperAdmin } from '@@/super-admin';
import { snapshotClaudeUsage } from './usage-store';
@@ -196,12 +197,18 @@ export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, c
// `accountEmail` will come from the account selector (msg.contextId) later; for now default to the
// owner's first enabled account. Falls back to the email_accounts root if there are no accounts.
async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promise<string> {
// Found on disk, never queried. Email is a plugin and the platform may not import its queries; the
// directory layout is already the platform's, so reading it answers the question with no coupling and
// nothing to throw when the plugin is not installed.
const account = accountEmail?.trim() || findEmailAccountName(ownerEmail);
const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
// `resolveEmailAccountDir` is shared with `GET /chat/sessions`, deliberately: the turn and the list of
// conversations must resolve the same directory, and they did not — the sessions route knew nothing about
// `email` and fell through to the owner's home, so the picker above this composer offered chats from
// somewhere else. See its own comment for why `accountEmail` is verified rather than sanitised.
const dir = resolveEmailAccountDir(ownerEmail, accountEmail);
mkdirSync(dir, { recursive: true });
// The same brief a plugin's own chats get, written where an email chat actually runs.
//
// `installPluginChatBrief` is not reached by the `plugin` branch below, because an email chat does not
// run in the plugin's chats directory — it runs in the ACCOUNT's, beside `emails.db`, which is the whole
// reason this branch exists. The brief follows the chat rather than the chat moving to the brief.
await installPluginChatBrief('email', dir);
return dir;
}
@@ -240,14 +247,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled);
if (!state) return;
const source = join(state.plugin.dir, 'chat', 'CLAUDE.md');
if (!existsSync(source)) return;
// `chat/AGENTS.md`, with the old name still accepted: a plugin is its own repository on its own
// release cadence, so the platform cannot rename a file inside one and assume every installed copy
// followed. Dropping the fallback would silently un-brief a plugin that had not been updated —
// silently, because a missing brief is indistinguishable from a plugin that ships none.
const source = ['AGENTS.md', 'CLAUDE.md']
.map((name) => join(state.plugin.dir, 'chat', name))
.find((path) => existsSync(path));
if (!source) return;
const target = join(dir, 'CLAUDE.md');
const next = await Bun.file(source).text();
// Skip the write when nothing changed, so a directory's mtimes do not churn on every chat start.
if (existsSync(target) && (await Bun.file(target).text()) === next) return;
await writeAtomic(target, next);
// Both names, so opencode finds it too — see `writeAgentBrief`. Content-diffed inside, so a
// directory's mtimes do not churn on every chat start.
await writeAgentBrief(dir, await Bun.file(source).text());
} catch (err) {
console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err);
}
@@ -336,6 +347,18 @@ async function resolveChatCwd(
return dir;
}
/*
Every bot runs in ONE directory — `$DATA_PATH/<email>/bots` — rather than one each.
A commander gets its own because it is a job with procedures and a ledger. A bot is a sentence and a
watch, and a directory per bot would mean a creation step before anything can be said. Sharing it also
means the list is free: `/chat/sessions?cwd=` already answers "what bots do I have", because Claude
groups transcripts by the directory they ran in.
The brief is written here on every turn, so a change to it reaches every bot on the next message.
*/
if (msg.context === 'bots') return ensureBotsBrief(email);
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
if (msg.context === 'chat') {
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
+15 -5
View File
@@ -1,4 +1,5 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { writeAgentBrief } from '@@/agent-brief';
import { getUserByEmail } from 'officerdb';
import { lookupOsUser } from '@@/os-user';
import { join } from 'node:path';
@@ -10,10 +11,11 @@ import { writeCommanderCredential } from './credential';
A commander's own directory, created once, up front — `docs/commander-directory.md`.
$DATA_PATH/<email>/commanders/<dirName>/
CLAUDE.md written here, extended by the commander as the job is agreed
AGENTS.md written here, extended by the commander as the job is agreed
CLAUDE.md a pointer at it, so both harnesses discover the same brief
state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation.
Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the
Both exist before the first turn spawns, which is the whole point: a coding CLI reads its brief from the
working directory at session start, so a commander told about itself here needs no per-turn injection —
see the deleted `commander-block.ts` for what this replaced.
*/
@@ -365,7 +367,8 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
'something broken.',
'',
'```',
"CLAUDE.md this file. the platform's. never edited.",
"AGENTS.md this file. the platform's, extended by you as the job is agreed.",
"CLAUDE.md a pointer at AGENTS.md. the platform's. never edited.",
'JOB.md what was agreed: what you watch for and what you do. yours, written once',
' the job is settled and kept true after that.',
'CLASSIFICATION.md the category rules, if it classifies. yours, and expected to change.',
@@ -671,8 +674,14 @@ export async function createCommanderDirectory(
credentialError = err instanceof Error ? err.message : 'could not mint a credential';
}
writeFileSync(
join(dir, 'CLAUDE.md'),
// AGENTS.md with the brief in it, CLAUDE.md pointing at it — both, because opencode discovers only
// the first and Claude Code only the second, and a commander can run under either.
//
// `onlyIfAbsent`, and that flag is the whole reason this is not the same call the bots make: a
// commander EXTENDS this file over its life. Rewriting it when the template changes would delete the
// job it had agreed with the owner.
await writeAgentBrief(
dir,
commanderClaudeMd({
name: params.name,
referencePath: params.referencePath,
@@ -680,6 +689,7 @@ export async function createCommanderDirectory(
spawnedPath: spawnedRecordPath(email, dirName),
credentialPath,
}),
{ onlyIfAbsent: true },
);
// Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.
+26 -5
View File
@@ -29,6 +29,7 @@ import {
type ClaudeSessionSummary,
} from '../chat/claude-sessions';
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
import { chatContextDir } from '../chat/chat-context';
import {
listCommandHqCategories,
createCommandHqCategory,
@@ -527,13 +528,33 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
commandHqRouter.post('/commanders', async (ctx) => {
const user = ctx.get('user');
const who = await commandHqIdentity(user);
const body = await ctx.req
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
type Body = {
name?: string;
referencePath?: string;
categoryId?: number | null;
/** A chat context to watch, instead of a path — see below. */
context?: string;
contextId?: string;
};
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
const name = body.name?.trim();
const referencePath = body.referencePath?.trim();
if (!name) throw errors.BAD_REQUEST('name is required');
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
/*
A commander started FROM a chat watches the directory that chat runs in.
The client sends the context it already has — `{context:'email', contextId:'me@…'}` — and never a
path, because it cannot know one: a plugin's chats live under `$DATA_PATH/<email>/plugins/<app>/chats`
and an email chat beside its own `emails.db`, both built server-side and deliberately not sent to the
browser. `resolveChatContextDir` is the same function the chat router uses, so a commander cannot end
up watching a directory its own chat has never been in.
This is also what makes the feature reachable by somebody who is not a super user. Pointing a
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
and an account allowed to walk the disk. The button asks for neither.
*/
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
+103 -3
View File
@@ -1,10 +1,11 @@
import { listAllCommanders, getUserById } from 'officerdb';
import { getCommanderStateDir } from '@@/data-path';
import { getCommanderStateDir, getBotsDir } from '@@/data-path';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
import * as sidecar from '@@/sidecar-registry';
import { clearLoopPid, liveLoopPids } from './spawned';
import { listBotRecords, liveBotPids } from '../bots/teardown';
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
// Waking a commander whose watch has died.
@@ -60,6 +61,18 @@ const FIRST_TICK_MS = 120_000;
const PID_FILE = 'loop-pid';
const ATTEMPTS_FILE = 'revive-attempts';
/**
* Where a subject's wake attempts are counted.
*
* A commander keeps them in its own `state/`. A bot has no directory of its own, so its key is
* `bot-<sessionId>` and the file goes in the bots directory under that name — same bound, same window,
* one implementation.
*/
const attemptsPath = (email: string, key: string) =>
key.startsWith('bot-')
? join(getBotsDir(email), `${key}.${ATTEMPTS_FILE}`)
: join(getCommanderStateDir(email, key), ATTEMPTS_FILE);
/**
* How many times this will wake one commander, and over what window.
*
@@ -76,7 +89,7 @@ const ATTEMPT_WINDOW_MS = 6 * 60 * 60 * 1000;
/** Wake timestamps inside the window. Anything older is dropped rather than kept — this is a rate, not a history. */
function recentAttempts(email: string, dirName: string): number[] {
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
const path = attemptsPath(email, dirName);
if (!existsSync(path)) return [];
const cutoff = Date.now() - ATTEMPT_WINDOW_MS;
try {
@@ -93,7 +106,7 @@ function recentAttempts(email: string, dirName: string): number[] {
}
function recordAttempt(email: string, dirName: string): void {
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
const path = attemptsPath(email, dirName);
try {
writeFileSync(path, [...recentAttempts(email, dirName), Date.now()].join('\n'));
} catch (err) {
@@ -267,6 +280,82 @@ async function tellTheOwner(userId: number, dirName: string): Promise<void> {
}
}
/**
* The same question, asked of bots.
*
* A bot's watch dies exactly as a commander's does — a reboot, a killed process tree — and with the same
* silence: the card still lists it and nothing is polling anything. The difference is only where the
* record lives. A commander writes a pid file into its own `state/`; a bot writes `<sessionId>.bot.json`
* beside its script, because bots share one directory and the id has to be in the filename.
*
* A record with no live pid is a watch that has stopped. There is no `armed` flag to consult and there
* should not be: the record is written when it arms, so its existence IS the intent, and the pids in it
* are the fact.
*/
async function reviveBots(userId: number, email: string): Promise<void> {
for (const { sessionId, record } of listBotRecords(email)) {
if (!record.pids?.length) continue; // never armed, or already torn down
if (liveBotPids(email, sessionId).length > 0) continue;
const attempts = recentAttempts(email, `bot-${sessionId}`);
if (attempts.length >= MAX_ATTEMPTS) continue;
console.warn(`[watcher-revive] bot ${sessionId}: watch is dead — waking it`);
recordAttempt(email, `bot-${sessionId}`);
await tellTheOwner(userId, `bot ${sessionId.slice(0, 8)}`);
await wakeBot(userId, sessionId, email);
}
}
/**
* What a bot is told. Shorter than a commander's, because a bot has one job and its record names it.
*/
const WAKE_BOT_PROMPT = [
'Your watch is not running — the recorded process is gone, usually a reboot or something that killed',
'the tree it was started under.',
'',
'Read your own `<session-id>.bot.json` in this directory, arm the script it names again exactly as',
'before, and rewrite the record with the new pid. Do not start a second watch beside it, and do not',
'change what it watches.',
'',
'If the record or the script is missing, say what is gone and stop. Do not reconstruct a watch from',
'memory of the conversation — a watch that is subtly different from the one the owner agreed to is',
'worse than none, because it looks like it is working.',
].join('\n');
async function wakeBot(userId: number, sessionId: string, email: string): Promise<void> {
await new Promise<void>((resolve) => {
let settled = false;
const done = () => {
if (!settled) {
settled = true;
resolve();
}
};
const timer = setTimeout(done, 60_000);
void startClaudeTurn({
email,
username: email,
userId,
ws: null,
sessionId,
model: COMMANDER_DEFAULT_MODEL,
msg: { prompt: WAKE_BOT_PROMPT, resumeSessionId: sessionId },
effectivePrompt: WAKE_BOT_PROMPT,
onOutcome: (outcome) => {
clearTimeout(timer);
if (!outcome.ok) console.error(`[watcher-revive] bot ${sessionId}: ${outcome.error}`);
done();
},
}).catch((err) => {
clearTimeout(timer);
console.error(`[watcher-revive] bot ${sessionId} threw:`, err instanceof Error ? err.message : err);
done();
});
});
}
let timer: ReturnType<typeof setInterval> | null = null;
/**
@@ -305,6 +394,17 @@ export function startWatcherRevive(): void {
await tellTheOwner(commander.userId, commander.dirName);
await wake(commander.userId, commander.sessionId, commander.dirName, user.email);
}
// Bots, same tick. Their records live per account, so this needs the set of owners rather than the
// commander list — an account with no commanders can still have bots.
const owners = new Map<number, string>();
for (const c of await listAllCommanders()) {
const u = await getUserById(c.userId);
if (u) owners.set(c.userId, u.email);
}
const me = await getUserById(1);
if (me) owners.set(1, me.email);
for (const [userId, email] of owners) await reviveBots(userId, email);
} catch (err) {
console.error('[watcher-revive] tick failed:', err instanceof Error ? err.message : err);
} finally {
-189
View File
@@ -1,189 +0,0 @@
import { randomUUID, randomBytes } from 'node:crypto';
// Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it
// against a single-use token until Safari fetches it.
//
// WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app
// hands the URL to the OS browser and loses all control of the request. So the token IS the credential.
// It is therefore minted long, used once, and dropped after five minutes, because the thing it protects
// is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a
// password that stays valid.
//
// WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the
// install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the
// profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute
// window costs the owner one extra tap; persisting the secret costs the guarantee.
//
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
const TTL_MS = 5 * 60_000;
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
const MAX_PENDING = 32;
type PendingProfile = { body: Uint8Array; expiresAt: number };
const pending = new Map<string, PendingProfile>();
const sweep = () => {
const now = Date.now();
for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token);
};
const escapeXml = (value: string) =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
type ProfileParams = {
host: string;
port: number;
username: string;
password: string;
/** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
principalUrl: string;
/** What the user sees as the account name in Settings. */
accountName: string;
};
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
function buildPlist(params: ProfileParams): string {
const { host, port, username, password, principalUrl, accountName } = params;
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
// two Officer instances on one phone do not overwrite each other.
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
const e = escapeXml;
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
<key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(label)}</string>
<key>${kind}AccountDescription</key><string>${e(accountName)}</string>
<key>${kind}HostName</key><string>${e(host)}</string>
<key>${kind}Port</key><integer>${port}</integer>
<key>${kind}UseSSL</key><true/>
<key>${kind}Username</key><string>${e(username)}</string>
<key>${kind}Password</key><string>${e(password)}</string>
<key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
</dict>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadVersion</key><integer>1</integer>
<key>PayloadIdentifier</key><string>${identifier}</string>
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar &amp; Contacts</string>
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
<key>PayloadOrganization</key><string>Officer</string>
<key>PayloadScope</key><string>User</string>
<key>PayloadRemovalDisallowed</key><false/>
<key>PayloadContent</key>
<array>
${account('CalDAV', 'Officer Calendar')}
${account('CardDAV', 'Officer Contacts')}
</array>
</dict>
</plist>
`;
}
// Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS
// terminates upstream. An unsigned profile installs exactly the same way; the only difference is that
// the install screen says "Not Signed" in red instead of naming the signer.
//
// Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy
// works, iOS checks the trust chain and not the key usage) and profiles are signed from then on:
// DAV_PROFILE_SIGN_CERT leaf certificate, PEM
// DAV_PROFILE_SIGN_KEY its private key, PEM
// DAV_PROFILE_SIGN_CHAIN intermediates, PEM (iOS ships roots, not intermediates — without this the
// signature cannot be verified and shows as Not Verified)
//
// Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up
// with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here.
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
// old one is removed. Nothing detects that for you.
const signingConfig = () => {
const cert = process.env.DAV_PROFILE_SIGN_CERT;
const key = process.env.DAV_PROFILE_SIGN_KEY;
if (!cert || !key) return null;
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
};
let warnedUnsigned = false;
async function sign(plist: string): Promise<Uint8Array | null> {
const config = signingConfig();
if (!config) {
if (!warnedUnsigned) {
warnedUnsigned = true;
console.warn(
'[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.',
);
}
return null;
}
// Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
// the file is a signature with no profile embedded in it.
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
if (config.chain) args.push('-certfile', config.chain);
args.push('-md', 'sha256');
const proc = Bun.spawn(['openssl', ...args], {
stdin: new TextEncoder().encode(plist),
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err, code] = await Promise.all([
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (code !== 0 || out.byteLength === 0) {
// Deliberately not fatal. An unsigned profile still installs and still works; failing the whole
// provisioning request over a cosmetic signature would be the worse outcome.
console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`);
return null;
}
return new Uint8Array(out);
}
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
sweep();
if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles');
const plist = buildPlist(params);
const signed = await sign(plist);
const body = signed ?? new TextEncoder().encode(plist);
const token = randomBytes(32).toString('base64url');
const expiresAt = Date.now() + TTL_MS;
pending.set(token, { body, expiresAt });
return {
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
expiresAt: new Date(expiresAt).toISOString(),
signed: signed !== null,
};
}
/** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */
export function claimIosProfile(token: string): Uint8Array | null {
sweep();
const entry = pending.get(token);
if (!entry) return null;
pending.delete(token);
return entry.body;
}
-95
View File
@@ -1,95 +0,0 @@
import { createRouter } from '../../create-router';
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
import * as errors from '../../custom-errors';
import { stashIosProfile } from './ios-profile';
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
// under /api — this is the owner administering their devices from a logged-in browser, which is a
// completely different act from a phone syncing (that is /dav, see sync-router.ts).
//
// The plaintext credential exists for exactly one response and is never stored, so POST is the only
// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the
// row and mint another. That is cheaper than any design where the secret can be read back.
export const davRouter = createRouter();
davRouter.get('/passwords', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
});
davRouter.post('/passwords', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { label?: string } | undefined;
const label = body?.label?.trim();
if (!label) throw errors.BAD_REQUEST('label is required');
const { entry, password } = await createDavAppPassword(user.id, label);
// `password` is returned once, here. Nothing else in the system can produce it again.
return ctx.json({ entry, password, username: user.email });
});
// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app
// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is
// held in memory behind a one-shot token rather than stored.
//
// This is the only endpoint that returns a route to a live password instead of the password itself. It
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
davRouter.post('/provision/ios', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { deviceLabel?: string } | undefined;
const deviceLabel = body?.deviceLabel?.trim();
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
// The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can
// actually reach — the request's own Host header is whatever the reverse proxy forwarded.
const publicUrl = process.env.OFFICERDEV_PUBLIC_URL ?? process.env.PUBLIC_URL;
if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile');
let url: URL;
try {
url = new URL(publicUrl);
} catch {
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL');
}
if (url.protocol !== 'https:') {
// CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that
// cannot work. Better to say so than to ship a phone an account that silently never syncs.
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device');
}
const { entry, password } = await createDavAppPassword(user.id, deviceLabel);
const stored = await stashIosProfile(
{
host: url.hostname,
port: Number(url.port) || 443,
username: user.email,
password,
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
// collection the owner adds later appears without re-provisioning.
principalUrl: `/dav/${user.id}/`,
accountName: 'Officer',
},
url.origin,
);
console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`);
return ctx.json(stored);
});
davRouter.post('/passwords/:id/revoke', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
return ctx.json({ ok: true });
});
davRouter.delete('/passwords/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
return ctx.json({ ok: true });
});
-17
View File
@@ -1,17 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// The officer-caldav sidecar has TWO consumers in the platform and they need different things, so the
// port capture lives here and both import it.
//
// • `caldavRouter` — the ordinary JSON door for Officer's own UI. Behind userMiddleware, forwarded by
// the shared factory with `X-Officer-User`, exactly like every other sidecar.
// • `davSyncRouter` — the DAV door for phones. Cannot use the factory: it forwards only three headers
// (`create-proxy.ts:88`) and DAV dies without `Depth`, and it derives the user from a platform JWT
// that a DAV client has no way to hold.
//
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
export const caldavRouter = proxy.router;
export const getCaldavUrl = proxy.getHttpUrl;
-143
View File
@@ -1,143 +0,0 @@
import { createRouter } from '../../create-router';
import { getUserByEmail, verifyDavAppPassword } from 'officerdb';
import { getCaldavUrl } from './sidecar-server';
// The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter.
//
// WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird
// speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer,
// cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same
// situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform
// session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type.
//
// The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else.
// An app password is NOT a session: userMiddleware must never learn to accept one, or a device that
// syncs a calendar would also reach the wallet.
//
// Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform
// authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the
// same one create-proxy.ts documents, and for the same reason.
export const davSyncRouter = createRouter();
const unauthorized = () =>
new Response('Unauthorized', {
status: 401,
// Without this header a DAV client will not prompt for credentials at all — it just fails. The
// realm string is what the phone shows in its password dialog.
headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' },
});
type BasicCreds = { username: string; password: string };
function parseBasic(header: string | undefined): BasicCreds | null {
if (!header?.startsWith('Basic ')) return null;
let decoded: string;
try {
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
} catch {
return null;
}
// Split on the FIRST colon only — a generated password never contains one, but an email local-part
// legally can, and splitting greedily would break those accounts in a way that looks like a wrong
// password.
const at = decoded.indexOf(':');
if (at < 0) return null;
return { username: decoded.slice(0, at), password: decoded.slice(at + 1) };
}
// Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a
// collection, its children or the whole tree, so dropping it makes every calendar look empty.
const REQUEST_HEADERS = [
'depth',
'content-type',
'content-length',
'if',
'if-match',
'if-none-match',
'destination',
'overwrite',
'lock-token',
'timeout',
'prefer',
'user-agent',
] as const;
const RESPONSE_HEADERS = [
'content-type',
'dav',
'allow',
'etag',
'last-modified',
'location',
'lock-token',
'preference-applied',
'vary',
] as const;
davSyncRouter.all('/*', async (ctx) => {
const creds = parseBasic(ctx.req.header('authorization'));
if (!creds) return unauthorized();
// A credential-less request is the normal opening move — the client is asking to be challenged — so it
// is not worth a line. A credential that is present and wrong is the interesting case, and it is
// indistinguishable from the normal one at the client end: both just say "password incorrect".
const user = await getUserByEmail(creds.username);
if (!user) {
console.warn(`[dav] auth failed: no such user ${creds.username}`);
return unauthorized();
}
const userId = await verifyDavAppPassword(user.id, creds.password);
if (!userId) {
console.warn(`[dav] auth failed: bad app password for ${creds.username}`);
return unauthorized();
}
const base = getCaldavUrl();
if (!base) return new Response('caldav sidecar not available', { status: 503 });
const url = new URL(ctx.req.url);
// The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale
// generates absolute hrefs in its multistatus responses, and if the path the client sees differs
// from the path the server thinks it is at, every href points somewhere that 404s.
const target = `${base}${url.pathname}${url.search}`;
const headers: Record<string, string> = {};
for (const name of REQUEST_HEADERS) {
const value = ctx.req.header(name);
if (value) headers[name] = value;
}
headers['X-Officer-User'] = String(userId);
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) });
return new Response('caldav sidecar unreachable', { status: 502 });
}
const out = new Headers();
for (const name of RESPONSE_HEADERS) {
const value = upstream.headers.get(name);
if (value) out.set(name, value);
}
// One line per request, with the client's own name for itself. A DAV client tells you almost nothing
// when it fails — iOS in particular reports every setup failure as "Cannot connect using SSL" — so the
// only way to know whether a phone ever asked for an address book is to record that it did. Cheap:
// a sync is a handful of requests, and a body is never logged.
console.log(
`[dav] ${method} ${url.pathname} -> ${upstream.status} (user=${userId}, ua=${(ctx.req.header('user-agent') ?? '-').slice(0, 60)})`,
);
return new Response(upstream.body, { status: upstream.status, headers: out });
});
+69 -2
View File
@@ -1,4 +1,5 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
import { createRouter } from '../../create-router';
import { getUsers } from 'officerdb';
import * as errors from '../../custom-errors';
@@ -9,6 +10,23 @@ import * as errors from '../../custom-errors';
const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
/*
ORDER IS LOAD-BEARING, and getting it wrong is silent.
`createSidecarProxy` mounts `router.all('/*')` the moment it is created — a catch-all. Hono matches in
registration order, so anything added to the proxy's OWN router afterwards is shadowed: the request is
forwarded to the sidecar, which has never heard of it and answers a plain `not found`.
That is what happened to `/broadcast`. It was written on 2026-08-24 to replace somebody POSTing to the
sidecar's ephemeral port by hand, it typechecked, it was reviewed, and it answered 404 from the day it
landed — for three days, to a route whose whole purpose was to stop people going around the platform.
A shadowed route does not fail like a missing one; it fails like a sidecar that does not implement it.
So: the platform's own routes on this router first, the catch-all proxy mounted LAST. Same rule, and
the same wording, as `plugins/caldav/api/router.ts`, which learned it the same way.
*/
export const notifyRouter = createRouter();
/*
POST /api/notify/broadcast — one message to every account, owner only.
@@ -19,7 +37,7 @@ const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only.
*/
proxy.router.post('/broadcast', async (ctx) => {
notifyRouter.post('/broadcast', async (ctx) => {
type BroadcastBody = { title?: string; body?: string; code?: string };
const body: BroadcastBody = await ctx.req.json<BroadcastBody>().catch(() => ({}) as BroadcastBody);
const title = body.title?.trim();
@@ -54,7 +72,56 @@ proxy.router.post('/broadcast', async (ctx) => {
return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results });
});
export const notifyRouter = proxy.router;
/*
POST /api/notify/user — one message to ONE named account, owner only.
The gap this fills, in the words of the route above: the choice was everyone or yourself. Notifying a
SINGLE OTHER PERSON — the thing an owner actually wants when somebody's device finishes registering, or
when one member needs telling and the other five do not — had no door, so the honest answer to "notify
him" was a broadcast to the whole household or nothing.
Same authority as `/broadcast` and for the same reason: everything else under this permission acts on
the account asking, and reaching somebody else's phone is administration however few phones it is.
Deliberately NOT reachable by an agent's own reasoning about who deserves a notification — it is
`ownerOnly`, so a member's session key cannot call it at all, and the owner's carries the owner's
authority because it IS the owner. The producer secret stays where it is; this route is the platform
speaking, which is the only thing that ever holds it.
*/
notifyRouter.post('/user', async (ctx) => {
type UserBody = { userId?: number; title?: string; body?: string; code?: string };
const body: UserBody = await ctx.req.json<UserBody>().catch(() => ({}) as UserBody);
const title = body.title?.trim();
const message = body.body?.trim();
const userId = Number(body.userId);
if (!Number.isInteger(userId) || userId <= 0) throw errors.BAD_REQUEST('userId is required');
if (!title || !message) throw errors.BAD_REQUEST('title and body are required');
// Refuse a stranger rather than hand the sidecar an id that resolves to nobody: a 404 here says the
// account does not exist, where the sidecar's silence would read as a delivered notification.
const target = (await getUsers()).find((u) => u.id === userId);
if (!target) throw errors.createError(404, `no account with id ${userId}`);
const base = proxy.getHttpUrl();
const producer = notifyProducerHeaders();
if (!base || !producer) throw errors.createError(503, 'notify sidecar not available');
const res = await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...producer },
// `userId` in the BODY and no `X-Officer-User` — the platform speaking about somebody else, which is
// the distinction `sidecar/notify/resolve-user.ts` draws.
body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId, title, body: message }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw errors.createError(502, `notify sidecar answered ${res.status}`);
// The email, so the caller can see WHO was reached rather than trusting an id they typed.
return ctx.json({ sent: 1, userId, email: target.email });
});
// LAST. Everything above is the platform's; everything else belongs to the sidecar.
notifyRouter.route('/', proxy.router);
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getNotifyServerUrl = proxy.getHttpUrl;
+13 -4
View File
@@ -99,6 +99,9 @@ pluginsRouter.get('/', async (ctx) => {
prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`,
label: e.label,
summary: e.summary,
// What it is FOR, which is how the store is browsed. From the catalogue for a plugin that is not
// here yet, from the manifest below for one that is — the same field either way.
tags: e.tags ?? [],
image: e.iconUrl,
// Same precedence the installed list uses: artwork wins, the glyph name is the fallback.
...(e.iconUrl ? {} : { icon: e.icon }),
@@ -126,11 +129,12 @@ pluginsRouter.get('/', async (ctx) => {
};
})(),
})),
plugins: states.map(({ plugin, install, outdated }, i) => ({
plugins: states.map(({ plugin, install, outdated, release }, i) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
label: plugin.manifest.label,
summary: plugin.manifest.summary,
tags: plugin.manifest.tags ?? [],
icon: plugin.manifest.icon,
// The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies
// `assets/` into `public/plugins/<appName>/` at install and `unpublishAssets` removes it, so a
@@ -147,9 +151,9 @@ pluginsRouter.get('/', async (ctx) => {
version: plugin.manifest.version,
platform: plugin.manifest.platform,
permissions: plugin.manifest.permissions,
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
settings: plugin.manifest.settings ?? [],
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
settings: plugin.manifest.settings ?? [],
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
has: {
api: !!plugin.api,
@@ -161,6 +165,11 @@ pluginsRouter.get('/', async (ctx) => {
enabled: install?.enabled ?? false,
installedVersion: install?.version ?? null,
outdated,
// The tag rule — whether the REMOTE has something this checkout does not. See plugins/release.ts.
updateable: release.updateable,
releaseReason: release.reason,
tagAtHead: release.tagAtHead,
newestTag: release.newestTag,
processStatus: statuses[i] ?? null,
// Probed live, against this machine's PATH, on every read.
//
+70
View File
@@ -0,0 +1,70 @@
import type { Handler } from 'hono';
import { getUserById } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { clientIp } from '../auth/client-ip';
/**
* Sign in AS another account, from user management.
*
* The support tool this is for: "it does not work for them and it does for me". Reproducing that with a
* password reset means locking the person out of their own account to look at it, which is worse than
* the bug.
*
* ── The token says whose it is, and who is really holding it ──
*
* `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole
* payload on the context. Without it an impersonated action is indistinguishable from the person's own
* — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can
* forge activity as somebody else is a different and much worse thing.
*
* ── One hour, not thirty days ──
*
* A session token 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.
*
* ── What it cannot do ──
*
* It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an
* impersonated session cannot impersonate a third account, and — the case that matters — impersonating
* another Super Admin does not hand back user management. Otherwise this route would be a way to launder
* owner actions through somebody else's id.
*
* It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a
* deleted or non-Active user, exactly as it does for an ordinary session.
*
* ── What it deliberately does NOT do ──
*
* It does not touch the owner's own token. Returning to yourself is the client putting back what it
* kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to
* be stranded as somebody else.
*/
export const impersonateUserHandler: Handler = async (ctx) => {
const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown };
// Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route
// where being wrong about it is worst, so it is stated here too rather than assumed one file away.
if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate');
const targetId = Number(ctx.req.param('id'));
if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required');
if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself');
const target = await getUserById(targetId);
if (!target) throw errors.NOT_FOUND('No such user');
// The same refusal sign-in makes. Impersonating a blocked account would be a way to use it.
if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`);
const { id, email, name, username } = target;
const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h');
// Same channel and shape as the distress revoke, so both land in one place when somebody asks who did
// what. Names both parties: the id in every downstream record from here on will be the TARGET's.
console.warn(
`[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` +
`ip=${clientIp(ctx)} — ${new Date().toISOString()}`,
);
return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 });
};
+14
View File
@@ -9,6 +9,7 @@ import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password';
import { permissionAdminRouter } from './permissions-routes';
import { impersonateUserHandler } from './impersonate';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -23,6 +24,17 @@ usersRouter.put('/', updateUserHandler);
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
/*
An impersonated session is not the owner, whoever it is wearing.
`isSuperAdmin` reads the id on the token, so impersonating another Super Admin would otherwise hand
user management straight back — and every action taken there would be recorded against the person
impersonated rather than the person doing it. This one line is what stops impersonation being a way
to launder owner actions through somebody else's account, and it is also what stops it chaining.
*/
if ((ctx.get('user') as { impersonatedBy?: unknown }).impersonatedBy) {
throw errors.FORBIDDEN('An impersonated session cannot manage users');
}
return next();
};
@@ -36,6 +48,8 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
// thing as auth/reset-password.ts, which is the token flow the person runs on themselves.
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Sign in AS somebody, for an hour, on a token that says who is really holding it. See impersonate.ts.
usersRouter.post('/:id/impersonate', ownerGate, impersonateUserHandler);
// Which permissions each role holds. Owner-gated inside its own router.
usersRouter.route('/', permissionAdminRouter);
+8
View File
@@ -32,6 +32,14 @@ export const assetPublicDir = (sidecarId: string): string => join(process.cwd(),
/** The URL a manifest should use for a shipped icon. */
export const iconUrl = (sidecarId: string): string => `/plugins/${sidecarId}/icon.png`;
/**
* The URL for one SCREEN's own artwork, in a plugin that has more than one.
*
* Same published directory and the same copy — `publishAssets` takes the whole `assets/` folder, so
* `assets/calendar.png` needs no separate step to reach the browser.
*/
export const screenArtUrl = (sidecarId: string, segment: string): string => `/plugins/${sidecarId}/${segment}.png`;
/** The URL for a shipped splash. Published by the same copy — `publishAssets` takes the whole dir. */
export const splashUrl = (sidecarId: string): string => `/plugins/${sidecarId}/splash.png`;
+27
View File
@@ -63,6 +63,8 @@ export async function startProcess(name: string, cwd: string): Promise<Pm2Result
const { code, out } = await pm2(args, cwd);
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
// The list changed, so the dump has to. See `saveProcessList`.
await saveProcessList(cwd);
return { ok: true };
}
@@ -105,9 +107,34 @@ export async function deleteProcess(name: string, cwd: string): Promise<Pm2Resul
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
return { ok: false, error: out || `pm2 delete ${name} exited ${code}` };
}
// Saved on REMOVAL too, or the dump keeps resurrecting a sidecar whose plugin is gone — which comes
// back after a reboot as a crash-looping process for code that is no longer on the machine.
await saveProcessList(cwd);
return { ok: true };
}
/**
* Persist the process list, so a reboot brings back what is actually installed.
*
* ── Why this is not optional ──
*
* PM2 resurrects from `~/.pm2/dump.pm2`, written only by `pm2 save`. Nothing in the plugin system ever
* called it, so the dump was whatever a human last happened to save — on 2026-08-27 it was five days old
* and held 13 processes while 26 were running. A reboot would have silently lost every sidecar installed
* since, and the symptom is the worst kind: the machine comes up, the platform is fine, and thirteen
* features are simply gone with nothing saying why.
*
* ── Best effort, and never fatal ──
*
* A failed save leaves the estate correct and only the dump stale, which is exactly where things were
* before this existed. Failing an install over it would trade a working plugin for a bookkeeping error.
* It is logged rather than returned for the same reason.
*/
export async function saveProcessList(cwd: string): Promise<void> {
const { code, out } = await pm2(['save'], cwd);
if (code !== 0) console.error(`[pm2] could not save the process list — a reboot may not restore it: ${out}`);
}
/** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */
export async function processStatus(name: string, cwd: string): Promise<string | null> {
const { code, out } = await pm2(['jlist'], cwd);
+49
View File
@@ -263,6 +263,26 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma
*/
export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins');
/**
* Where every bot runs — `$DATA_PATH/<email>/bots`.
*
* ── One directory, not one per bot ──
*
* A commander gets its own, because a commander is a job with procedures, a ledger and its own rules. A
* bot is one sentence and a watch, and giving each one a directory would mean a tree of near-empty
* folders and a creation step before anything can be said.
*
* Sharing it also gives the list for free: every bot is a chat whose cwd is this, so Claude groups their
* transcripts together and `/chat/sessions?cwd=` is already the answer to "what bots do I have".
*
* A bot that needs to remember something — the headline it saw last time — writes a file here named after
* itself. The `CLAUDE.md` in this directory says so; it is the platform's and is rewritten on every bot
* turn, the same arrangement as a plugin's chat brief.
*
* Confined like `commanders` and `plugins`: a member's bot runs as the member, so this must be theirs.
*/
export const getBotsDir = (email: string) => join(DATA_PATH, email, 'bots');
export const getPluginChatsDir = (email: string, appName: string): string => {
if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') {
throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`);
@@ -351,6 +371,35 @@ export function findEmailAccountName(ownerEmail: string): string | null {
}
}
/**
* The directory an email chat belongs to — the mailbox's own, the one holding `emails.db`.
*
* ── Why this is a function and not two lines at each call site ──
*
* It IS an address, and it had two resolvers. The chat socket resolved it to run the turn; the sessions
* route resolved it to list the conversations. Only the socket knew about `email`, so the picker fell
* through to the owner's home and offered chats from somewhere else entirely — you could send a message
* into the mailbox and then not find it in the list above the composer. Two answers to "where does this
* chat live" is one too many.
*
* `requested` reaches here from the client (the mailbox picker's `?account=`) and is verified against disk
* before it is joined, never sanitised into shape. That matters because the socket's caller ends in
* `mkdirSync`: an unrecognised value would not fail, it would CREATE a directory named after whatever
* arrived. The list's unified view uses the sentinel `all`, which is the value most likely to show up by
* accident.
*
* Falls back to the most recently synced account, and to the accounts root when there is no account at
* all — a real state for a fresh install, and a directory that exists rather than one invented here.
*
* Pure: no mkdir, no writes. The caller that needs the directory to exist says so itself.
*/
export function resolveEmailAccountDir(ownerEmail: string, requested?: string): string {
const named = requested?.trim();
const known = named && existsSync(join(getEmailAccountsDir(ownerEmail), named, 'emails.db')) ? named : null;
const account = known ?? findEmailAccountName(ownerEmail);
return account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
}
// Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the
// 32-char Linux limit are the last trace of the per-container architecture, where this really did name
// a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getEmailAccountsDir, resolveEmailAccountDir } from './data-path';
/**
* Where an email chat runs.
*
* This is regression cover rather than API cover. The address had two resolvers — the chat socket's, for
* running the turn, and `GET /chat/sessions`'s, for listing the conversations — and only the socket knew
* about the `email` context. The list fell through to the owner's home, so the picker above an email
* composer offered conversations from somewhere else and a message sent into the mailbox could not be
* found in the list directly above it. One resolver now, and these are the three answers it owes.
*/
const OWNER = 'resolve-email-dir-test@officer.dev';
const ROOT = getEmailAccountsDir(OWNER);
const account = (name: string, withDb = true) => {
const dir = join(ROOT, name);
mkdirSync(dir, { recursive: true });
if (withDb) writeFileSync(join(dir, 'emails.db'), '');
return dir;
};
describe('resolveEmailAccountDir', () => {
beforeAll(() => {
rmSync(ROOT, { recursive: true, force: true });
mkdirSync(ROOT, { recursive: true });
});
afterAll(() => rmSync(ROOT, { recursive: true, force: true }));
test('a named mailbox that exists is the answer', () => {
const dir = account('someone@gmail.com');
expect(resolveEmailAccountDir(OWNER, 'someone@gmail.com')).toBe(dir);
});
test("`all` is the list's sentinel, not an address — it never becomes a directory", () => {
// The caller ends in mkdirSync, so accepting this would CREATE a mailbox named `all` and run turns in
// it. The panel drops the sentinel; this is the half that does not depend on the panel remembering to.
expect(resolveEmailAccountDir(OWNER, 'all')).not.toBe(join(ROOT, 'all'));
expect(resolveEmailAccountDir(OWNER, 'all')).toBe(join(ROOT, 'someone@gmail.com'));
});
test('a mailbox with no database is not a mailbox', () => {
account('half-configured@gmail.com', false);
expect(resolveEmailAccountDir(OWNER, 'half-configured@gmail.com')).toBe(join(ROOT, 'someone@gmail.com'));
});
test('no account at all resolves to the accounts root, which exists', () => {
const empty = 'no-accounts-test@officer.dev';
expect(resolveEmailAccountDir(empty)).toBe(getEmailAccountsDir(empty));
});
});

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