Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 98177c3bc9 2026.08.21.04 — /login in the chat, account switching, and a spotlight that reaches the plugins
First release in three with a SCHEMA CHANGE. anthropic_accounts is new, and without the push the account
switcher's route fails on a missing relation the first time the usage panel is opened — so the plan names
db:push as a step rather than leaving it to be inferred from the diff.

alpha already runs this code, deployed by hand as a preview. The plan says its pull will be a no-op and to
run the rest anyway, so it does not read as a skipped host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:05:39 +00:00
pastilhasandClaude Opus 5 3cab16ca5b spotlight showed eight items, so every plugin was unreachable
MAX_RESULTS was 8. Core items alone fill eight slots, so on a populated server the empty-query list stopped
at Browser and none of the fourteen plugin tiles below it could be reached — unless you already knew the
name well enough to type it, which is not a thing a launcher gets to require. The owner caught it on alpha
with the dock and the launcher side by side; on this machine, which has few plugins, it looked complete.

The items were there the whole time. This was not a missing source — it was a silent truncation, with
nothing on screen saying "and 20 more", which is exactly how a cap reads as an absent feature.

There was never a layout reason for it either: the results container has scrolled since it was written.
Removed, and the active row now scrolls itself into view with `block: 'nearest'` so arrowing past the fold
works and arrowing through the visible middle does not jump the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:24:01 +00:00
pastilhasandClaude Opus 5 f023ab5da0 spotlight draws the dock's tiles, and lists the settings that actually exist
Two things made it read as a generic list rather than as this app's own, and neither was the item source —
that was already the dock, and plugins were already in it.

TILES. SpotlightItem dropped `tile`, so every row got a `${color}22` tinted square: a third visual language
belonging to neither of the dock's two. Which put a coloured pane behind Calendar, Contacts and every
plugin's logo — the exact thing the dock's own comment calls out as reading like a mistake. Rows now use the
dock's rule: a filled square of the item's colour with a white glyph, or no background at all when the
artwork brings its own frame. A settings row with no colour keeps a muted glyph instead of getting a swatch
invented for it.

SETTINGS. The hand-listed sections had drifted within two days of being written, and the file's own comment
predicted it ("worth revisiting if it drifts"). Nine rows against a settings area that has twenty-one:
Languages and Voice missing entirely, every AI and System section missing, `/settings/users` pointing at a
route that does not exist — it is `/settings/user-management/users` — and four different rows sharing the
User glyph, which is most of what made the panel look generic.

Now twenty rows, copied from the five screens that declare them, with each group naming its source file so
checking is one grep. The comment records that the drift HAPPENED rather than predicting it, and says
deriving them from the screens is the fix if it happens a third time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:17:58 +00:00
pastilhasandClaude Opus 5 b10a49c082 an account with no saved sign-in gets a chip you can read, not one you can use
Three chip states now, and the third is a different question rather than a broken version of the second:

  filled          the account you are
  plain           an account you can become — click switches
  dashed outline  an account you can only look at — click shows its last known usage

"Has my other allowance come back yet" is answerable from a snapshot, and the machinery for it was already
right: a reset time is an absolute instant, and currentLimit rolls a past window forward and zeroes it
instead of showing a stale percentage. Phase one was hiding a correct answer.

What you cannot do is act on it without a /login, so the chip says that before it is clicked (in its title
and in its border) and the panel says it again while you are reading one. Naming the fix rather than the
problem: "No saved sign-in — run /login to use this one".

Viewing is deliberately NOT a general tab selection. Only an account with no credential can be viewed,
because for any other one the useful gesture is switching to it and then reading live numbers — which keeps
phase one's invariant intact: if the panel shows a signed-in account, it is THE signed-in account. Switching
clears the view for the same reason, so the numbers and the chips can never disagree.

Replaces the count line, which was a fact about somewhere else. The staleness line that was already in this
file — "Not signed in · as of 20 minutes ago" — now finally has a case that reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:33:58 +00:00
pastilhasandClaude Opus 5 4b2cb3d266 the account chips show the whole address, because the local part is the shared half
Two chips both read `andre.padez`. They are andre.padez@gmail.com and andre.padez@pertento.ai — one
person's personal and work accounts, which is the entire case this switcher exists for.

The chip showed the local part on the reasoning that the domain is what accounts have in common. That is
true of accounts belonging to different people and exactly backwards here: the local part is the half they
share and the domain is the half that tells them apart. So the strip wraps instead of scrolling, and the
address is not truncated.

The "N other accounts not shown" line was also lying. It counted stored usage READINGS minus the one being
displayed, which since the switcher landed includes accounts whose chip is two lines below — it said "2
other accounts not shown" with both of them on screen. It now counts only accounts with a reading and no
saved credential: an old login you can look at and cannot return to, which is the thing actually worth
saying. On this machine that is one, jrg.conceicao@gmail.com.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:30:06 +00:00
pastilhasandClaude Opus 5 7d7a80782e switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.

ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.

anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).

One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.

What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.

Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.

.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.

Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.

The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.

Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:24:28 +00:00
pastilhasandClaude Fable 5 bd9d26199f the ephemeral pane is open when it exists, not when an effect says so
Refresh on /files/...?view=x.m4a came back with the query string intact and no
viewer, and then double-clicking the file did nothing at all. Two symptoms, one
cause, and the second is what made it read as a click bug.

WorkspaceView gave the pane defaultSize={0} and opened it from an effect calling
panel.resize(), so open/closed was expressed twice — the markup said closed and
the effect was meant to correct it. A correction step can be missed, and this one
was: on a deep link isEphemeralOpen is true on the first render, but
workspace.isLoaded is false until /dashboards answers and the early return means
nothing is rendered, so the effect ran against a null ref and returned. Its deps
were [isEphemeralOpen, defaultBaseSize] — one already true, the other a constant
— so it never ran again. URL open, pane at zero width.

Then the inert double-click follows for free: ?view= already held that name, so
setSearchParams changed nothing, so there was no transition for the effect to
react to. The URL being already correct is exactly why the click had nothing to
say.

Now the pane is rendered conditionally with id + order — the same pair
WorkspaceRenderer already uses — and both panels take declarative sizes. There is
no moment where markup and intent disagree, and a deep link is right on first
paint rather than corrected after it. The try/catch that guarded resizing an
unlaid-out group goes with it; nothing calls resize now.

ephemeral-pane.test.tsx asserts the SIZE the group computes, not that the element
exists — a pane at zero width satisfies any is-it-in-the-document check, which is
the failure being guarded against. Five of its six tests fail against the old
implementation, including the load-order one. Full suite: 1316 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:11:15 +00:00
pastilhasandClaude Opus 5 58615ee5e7 /login can hand you the URL instead of opening it here
Opening the link opens it in THIS browser — already signed into whichever account you are trying to move
away from. Copying it is how you authorise as somebody else: another browser profile, another browser, or
your phone. This dialog exists for a machine with two Anthropic accounts on it, so that is the common case
rather than the awkward one.

Uses helpers/clipboard, which falls back to execCommand when navigator.clipboard is absent, and reports a
failure rather than showing "Copied" over an empty clipboard.

Corrected a claim in that helper's own comment while touching it: it said Officer is reached over the
tailnet on plain http, as though that were the whole story. The owner browses over https and did when it
was written. The http origins are the other ways in, and an install with no certificate has no other way
in at all — which is why the fallback stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:02:57 +00:00
pastilhasandClaude Opus 5 31cdb93691 usage shows the signed-in account only, and says which one it is
Phase one of answering a question the panel could not answer: whose numbers am I looking at.

This machine's owner has two Anthropic accounts, personal and work, and both had stored snapshots — so
both appeared as tabs. After signing in there was no way to tell from a glance which one was now being
spent. The panel was answering "here is usage for two accounts" while the question was "which am I on".

So the hook filters to the account marked `current`, and the absence of one now MEANS something: no current
row renders as "Not signed in" rather than as whichever tab happened to be selected first.

The email also gets its own line instead of sharing one with the plan. Joined as `Team · a@b.c` in a 320px
popover, the email is the half that truncates — and it is the half being read.

The other accounts are said out loud rather than silently dropped: "1 other account not shown". Knowing one
exists and is hidden deliberately is a different thing from wondering where it went.

Temporary and a one-line revert by construction: delete `onlyCurrent` in useClaudeUsage and return `all`.
The server still serves every account, the tab strip is still in the file, and nothing was deleted — the
stored snapshots are a real feature, they were simply drowning the question being asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:56:45 +00:00
pastilhasandClaude Opus 5 8917f36be5 /login works in the chat window
Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open
a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not
do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is
a strange thing to ask of somebody whose entire session is a web page.

It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw
bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for
the person, write the code to stdin.

The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them.
Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against
what the terminal appeared to show.

Three decisions worth keeping:

NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is
where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a
login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401.

On the `chat` grant rather than its own. The pairing is exact: an account that may use the agent must be
able to authenticate the agent, and an account that may not has nothing to authenticate. `confined` then
carries the rest for free — authorize.ts drops a confined grant for an account with no osUser, which is
precisely the account that cannot hold credentials of its own.

Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy,
whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how
you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own
Linux user, into their own home, because their turns bill their own subscription and that only holds if the
credential is theirs.

Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies,
so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the
dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:47:10 +00:00
pastilhasandClaude Opus 5 530e5880e1 todo: what tonight's deploy work left behind, and two items it closed
Closed:

- fleet/an-orchestrator-that-acts-on-every-server-at — built, and as none of the three shapes it
  proposed. Not a script, not a screen, not a chat capability: an agent handed a written procedure and a
  server list it has to read rather than one baked into it. All three verified facts in the item earned
  their place; two things it did not anticipate are now new items.
- fleet/a-way-to-mark-a-commit-this-one-ships-so-a — built as a TAG, which the item explicitly ruled out.
  The owner reversed it, and the argument that changed his mind is recorded: a codeword can be written by
  accident, because every document about deploying contains it, so the trigger needs rules about where in
  a subject line it sits and a rule like that is one somebody eventually gets wrong.

Opened, all from tonight and all with the evidence attached:

- a deploy verifies pm2 and not that the app serves anything — the commander raised this against itself
- the commander cannot finish its own host, so one machine is always a build behind
- the watcher loop is rewritten by an agent every arming, so its correctness is re-rolled each time
- plugins:update restarts every sidecar, so a report naming only officer understates what moved
- an agent spawned by a background job is invisible in the UI while being perfectly readable
- AskUserQuestion can fail to parse its own call
- a commander opened from /chat loses its commander controls, because isCommander comes from the screen
  rather than the session

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:27:00 +00:00
pastilhasandClaude Opus 5 887749722c fofos's capabilities failure was ownership, and one stat proved it
Three deploys reported the same two errors and neither report could explain them:

  dubious ownership in repository at '~/officerdev/capabilities'
  EACCES: permission denied, mkdir '~/officerdev/capabilities/affordances/officerdev'

They read as two problems — git refusing to operate, and Node refusing to create a directory, at different
steps of the deploy — and they were one sentence in two dialects: this path is not yours. Fifty paths under
that checkout were owned by root, .git and affordances among them, all stamped 19 Aug 21:43. Something ran
with sudo and left them.

Mode 755 is what made the shape confusing. Everything was READABLE, so git could see the repo and refused
it on ownership, while the plugin could see affordances and could not write into it. Two tools, two
messages, one inode owner.

The general point, which is why this is in servers.md rather than only in a commit: stat prints the owner
instead of inferring it, so it either proves ownership is the cause or eliminates it — one command, binary,
no theory. Three reports had guessed; none had looked.

The deploy commander's refusal to add a safe.directory entry was right and is recorded as such. It would
have silenced the git message and left the EACCES exactly as it was, spending the obvious explanation on
half the problem.

Fixed by the owner with chown -R; verified after by a mkdir into the directory that used to refuse one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:23:45 +00:00
pastilhas 9ce81547c4 2026.08.21.03 deployed to six hosts — the watcher fired, the claim held 2026-08-21 20:14:36 +00:00
pastilhasandClaude Opus 5 3343b21a17 2026.08.21.03 — a drill, to watch the watcher fire on its own
No code change. The point is to see a tag picked up by a loop nobody is looking at, and to exercise the
claim rule that landed after the last deploy.

Not literally an empty commit, and it cannot be: the commander stops when the tag and package.json
disagree, and stops again when the plan file is missing. So the smallest honest release is a version and a
plan, which is what this is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:06:28 +00:00
pastilhasandClaude Opus 5 aac8af5e1f one attempt per release, and the rule is code rather than a paragraph
The first watcher had an unbounded retry in it. A release whose deploy failed left no .done file, so the
next poll two minutes later spawned a fresh headless agent, and the one after that, all night, each one
costing money. The loop was careful and its logging was honest; the defect was structural.

It is structural because the loop is written FRESH by an agent every time one arms. A rule that lives only
in the prompt gets re-implemented from scratch each time, and the wrong implementation is the one that
looks right: test the .done file. That file only exists on success, so testing it is exactly the retry
storm.

So scripts/deploy-claim.sh owns it. `claim <version>` exits 0 once and 1 for ever after, whatever became
of the attempt that held it — a failed release is not retried, it is looked at. Handing the claim back is
`rm -rf` by a person, on purpose; a loop cannot undo its own claim, which is what makes the storm
impossible rather than unlikely.

Three choices in it worth keeping:

- `mkdir` is the claim. One atomic syscall that both tests and takes, with no window between checking and
  acting — two pollers a second apart is not hypothetical when a watcher gets restarted.
- Bash, not bun. The thing that stops a retry storm must not need a healthy checkout; a deploy that dies
  on `bun install` is precisely when it has to work.
- A version is digits and dots or it is refused. It arrives off a git tag and is concatenated into a path.

A pre-existing .done with no claim beside it still counts as deployed, so deploy/2026.08.21.01 — rolled
out by hand before any of this existed — is not re-attempted.

docs/servers.md corrected from the commander's own report, which was right and specific: on macbookpro
APPEND /opt/homebrew/bin for bun rather than replacing the PATH (this file said "do not help the PATH",
which is right for pm2 and is what made plugins:update fail there), `ls` is aliased to an eza that is not
installed, plugins:update bounces every sidecar before the plan's own restart step, and fofos cannot
publish affordances for what is probably the same ownership problem as its capabilities sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:02:24 +00:00
pastilhasandClaude Opus 5 8be09a012b 2026.08.21.02 deployed to six hosts, and officer-dev is still waiting
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:01:25 +00:00
pastilhasandClaude Opus 5 55ecbf4fb8 the handoff radio was still rendered nowhere, one layer above where it was fixed
"Exit and hand back to the agent, then re-arm" is the only fire behaviour a commander wants, and it is the
dialog's own default inside one. It was missing from the dialog in Command HQ.

The previous fix wired the consumer: isCommander={manager.context === 'command-hq'}, with a comment about
how the prop had never been passed by anybody. The producer was still not feeding it. EmbeddableChat builds
its manager from its OWN props — useEmbeddableChat(params) — and `context` there is a different value from
the `context` handed to useChat. Command HQ set the hook's and not the component's, so manager.context was
undefined, and the radio stayed hidden in the one place it exists for.

Two hooks named alike sitting on either side of that boundary is what hid it twice: hooks/useChat.ts and
EmbeddableChat/useEmbeddableChat.ts, one taking a `context` option, the other a `context` prop, neither
reading the other's.

Found by the owner trying to arm a deploy commander and finding only "stop when it fires", a one-shot,
where a watcher that re-arms was the whole point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:42:04 +00:00
pastilhasandClaude Opus 5 926ce86b83 2026.08.21.02 — command hq's transcripts render, and live separates commanders
Frontend and one chat route. No schema change and no dependency change, verified against the previous
tag rather than assumed: nothing in this range touches src/schema.ts, package.json dependencies or
bun.lock. The plan says so explicitly, because a host that runs db:push here has run something the
release did not ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:33:15 +00:00
pastilhasandClaude Opus 5 e1ad680475 a commander opens in /chat, where you were already reading
Clicking one in the Live list navigated to /command-hq — out of the screen, into a different route with a
different layout, having lost your place in the list you were scanning.

I argued for that in the commit before this one: /chat renders the transcript without the category, cost,
watcher state or the disarm control. It loses to a simpler argument. A commander IS a chat — same harness,
same transcript, resumable by the same route — and being thrown out of the page you are reading costs more
than metadata you did not ask for. Command HQ is where you go to MANAGE a commander, which is a separate
errand from reading what it just said.

Verified rather than assumed, because a commander's cwd is not one of /chat's directory groups: the
default-group load misses, and GET /chat/sessions/:id falls through to the by-id scan, which returns all
six messages and the real cwd with it. So the detail panel resumes it with no cwd hint at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:30:06 +00:00
pastilhasandClaude Opus 5 ebfa09e067 live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations
somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it
is a list where the thing you are looking for is outnumbered.

Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it,
which hides at zero because a standing `0` there says nothing. These two say a lot at zero:
`Commanders (0)` is how you find out the watcher is not running, which is usually why you looked.

The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander
shares one directory, so the directory IS the answer, it costs no query, and it is already right during a
commander's first turn, before any row exists.

It has to be in the payload rather than computed in the panel, and that is the point of the change: the
iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name
them. A deploy commander appearing in that swipe order is precisely what must not happen.

A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders
it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to
disarm it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:25:17 +00:00
pastilhasandClaude Opus 5 6dae3e7a14 a commander's transcript was fetched, returned, and then ignored
The pane still showed "Send a message to start" over a bar reading 1 turn · $0.28. The id fix was
necessary and was not the cause.

useChat seeds its message list with useState(initialMessages ?? []) — read on the first render and never
again. ExistingCommander fetched the transcript AND called useChat in one component body, so the hook's
first render happened with resume still undefined and seeded itself empty. The messages landed a moment
later and the hook had no reason to look. The early `Loading…` return does not help: hooks run above it.

ChatPanelWrapper has always avoided this, and the line that does it is easy to read as a loading
nicety — `if (selectedSessionId && resuming) return null` sits ABOVE the component that owns the hook, so
the hook's first render is its only render, and it has real data. Split the same way here: a fetcher, and
a chat mounted once there is a transcript to mount it with.

Verified server-side while looking for this: loadClaudeSessionById returns the six messages for
172cdd8c, ending on the commander's answer. The data was always there and always reachable. Only the
pane could not see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:22:06 +00:00
pastilhasandClaude Opus 5 2379260ffe the commander pane threw its own conversation away at the end of the first turn
The messages were on screen, streaming, correct. Then the turn ended and the pane went empty under a
metadata bar still reading 1 turn · $0.66.

Nothing was lost — the pane discarded it. Once the transcript id was known, NewCommander navigated onto
it, and CommandHqDetail answered that URL with a DIFFERENT component: ExistingCommander, keyed on the id.
So React unmounted the live chat, closed its socket, and re-read the transcript from the API — a file the
harness had only just finished writing. What survived was whatever that fetch returned, which was nothing.

A permalink is for a refresh, not an instruction to reload. /chat has always understood that: useChat
replaces the URL and remounts nothing. This pane replaced the URL and remounted everything.

So the pane now keeps the session it started. CommandHqDetail remembers which id came from its own
composer and keeps that component mounted while the URL names it, swapping to the resuming component only
for a commander opened from the list. Clearing the memory on a real navigation means coming back later
still resumes from the transcript rather than remounting an empty live chat.

Worse than the empty pane, and the reason this is not cosmetic: the teardown fired at session:claude for
any chat where the harness names its transcript early — the START of a turn. A commander's turns run for
tens of minutes. The pane would have dropped the stream and shown a reader nothing at all while a deploy
rolled across seven servers.

claudeSessionId is now also set at session:claude, not only at result, matching the ref beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:10:54 +00:00
pastilhasandClaude Opus 5 21b893473d a new commander showed "Send a message to start" after answering correctly
The first deploy commander ran, cost $0.66, and reported exactly the right thing — and the pane showed
an empty state, because the page was fetching an id nothing knows.

Two ids for one commander. `session:init` sets `sessionId` to OFFICER's key and arrives first;
`session:claude` carries Claude's transcript id and arrives later. `fileAsCommander` files the row
under the transcript id, and `command_hq_sessions` is keyed on it. But NewCommander navigated on
`chat.sessionId` — the first one it could see — and then set `filed`, which blocked the correction when
the real id turned up. So the URL held officer's key, /api/chat/sessions/<that> 404'd twice, and the
transcript was unreachable from the UI while sitting perfectly readable on disk.

The transcript id was only ever in a REF, deliberately — the reconnect handshake reads it synchronously
— so writing it re-rendered nothing and no caller could react to it. It is now state as well, returned
alongside sessionId, with the ref kept for the handshake that needs it.

setCategory had the same fault and would have silently updated no row: it was keyed on officer's key
against a table keyed on the transcript id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:59:03 +00:00
pastilhasandClaude Opus 5 af78f0ed51 sessions carry isCommander, so the phone can filter what tabs would hide
A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the
two: commanders appeared in the chat list looking like conversations somebody started and forgot.

The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an
implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs
to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew
about would fix one surface and leave the other wrong — which is the shape of half the bugs found
tonight.

Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one
directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already
reads each session's cwd. So it costs one string comparison, adds no query, and still works for a
commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript
rather than from code and records whatever directory the process was actually in.

Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists,
no transcript has that cwd, and Command HQ shows 0 commanders.

Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:41:43 +00:00
pastilhasandClaude Opus 5 7a46612f41 the handoff watcher was built and never rendered
BackgroundJobDialog offers three fire behaviours and gates the third, `handoff` — exit, act, re-arm — on
an `isCommander` prop. Nothing ever passed it. So it defaulted to false everywhere and the option
appeared NOWHERE, including inside a Command HQ commander, where the dialog's own code makes it the
default.

Everything else was there: the fire rule, `handoffContract`, `armWatcher` reaching the server, and a note
recording that it had run for hours self-re-arming without failure. Only the radio button was missing,
which made a working mechanism look unbuilt — and it is the one a deploy watcher needs, since
`keep-going` never hands back to an agent at all.

Found while telling the owner how to start the deploy commander: the dialog in a commander showed two
options where I had said there would be three.

The fix is two lines and no new concept. `useEmbeddableChat` already receives `context` and passed it
into `useChat`; it now also returns it, and InputArea gives the dialog `isCommander={context ===
'command-hq'}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:30:14 +00:00
pastilhasandClaude Opus 5 240d8adbd3 a release is a tag, and the lock that stops a second deploy is local
Three changes, all from the owner's call.

The trigger is a tag, deploy/<version>, and nothing else. The code word it replaces could be written by
accident — every document about deploying contains it, this repository included, and so does every
conversation planning one. Defending that needs a rule about where in the subject line it sits, and a
rule like that is one somebody eventually gets wrong. A tag cannot be typed into prose, it names its own
version, and git ls-remote answers "is there one" without fetching.

The record of a deploy is LOCAL — data/deploys/<version>.done — not the pushed report. This is the part
that matters: making the report the marker means a failed push leaves the release looking undeployed and
the next tick rolls it out again. Pushes here fail for reasons unrelated to the rollout — gitea's public
SSH is rate-limited because every connection arrives from one proxy container IP that bots keep
penalised, which cost several retries tonight. So the lock cannot fail for a network reason, and the
report is an artefact: valuable, not load-bearing. Under data/ so a pull cannot clobber it.

And the commander skips the host it runs on ENTIRELY, naming it in the report. Restarting only officer
and leaving its own sidecar behind would work and is worse — one host in a state nobody chose, a step
behind, found later. The owner restarts it deliberately as the last step.

Tonight's deploy is backfilled: the lock file exists and deploy/2026.08.21.01 is tagged at bd41dff5, so
the mechanism has a precedent rather than starting from nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:21:09 +00:00
pastilhasandClaude Opus 5 8662f94b3b seven platforms, and the login is per host
deadpool-rezio-bridge joins the list: 100.64.0.21, checkout /home/br/officerdev/platform, deployed by
hand and verified — 7/7 processes online, username NOT NULL, uq_users_username_lower present, HEAD at
bd41dff5.

Its login is `br` and always will be. That makes the SSH user a per-host fact rather than a constant,
so the table carries it and the deploy prompt reads it. Worth stating plainly because the failure is
misleading: an orchestrator assuming `pastilhas` gets "Permission denied (publickey)", which reads as a
broken key rather than a wrong username.

Also folded in what tonight established. Three hosts answer to names that disagree with
`tailscale status` — edge-bridge and gitea-bridge are the old ones — so the table says which to use.
`edge-bridge` was listed as "not SSH-reachable" and that was wrong: its SSH was down at the moment I
checked, not absent. The pm2/nvm PATH trap is written in, including that `-lc` is not enough and cost
two wrong conclusions, and that macbookpro must not be helped with an exported PATH because doing so
breaks pm2. And a dirty tree means TRACKED changes; plain --porcelain skipped a host over a stray file.

officer.rezio.dev is recorded under "known wrong, waiting on someone else": it points at gateway rather
than deadpool, which is worse than unavailable — it will serve the wrong box the moment gateway answers
on 443.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:58:46 +00:00
pastilhasandClaude Opus 5 bd41dff59f 2026.08.21.01 deployed — 6/6 hosts
Every platform on eee73e77, schema pushed, processes online, domains answering. Rolled out by hand
one host at a time, since nothing wakes the commander yet.

Three things the report records rather than glosses. officer-claude-code was NOT restarted on
officer-dev — that sidecar holds the session running the deploy, so restarting it would have ended the
rollout mid-flight; every other host got it and it is outstanding here. fofos failed to sync
capabilities with a "dubious ownership" refusal whose stated cause is wrong (ownership is correct), so
it is left unexplained rather than silenced with a safe.directory entry. And macbookpro has no plugins
installed at all, which is worth knowing before somebody expects one there.

Two lessons for the prompt. The dirty-tree check counted untracked files and skipped fofos over one
stray script a fast-forward could never touch — it wants --untracked-files=no. And macbookpro needs
$SHELL -ic and no help: exporting a PATH to reach bun broke pm2, which lives only on the interactive
shell's path.

The pre-flight earned its place: username became NOT NULL with a case-insensitive unique index, and
checking all six first — 12 users, zero nulls, zero collisions — is why db:push applied everywhere
instead of failing on the third host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:37:54 +00:00
pastilhasandClaude Opus 5 eee73e777c DEPLOY_NOW 2026.08.21.01 — the night's work, to all six
First release under yyyy.mm.dd.NN, and the first deploy of the plugin extractions, the regenerated
themes, Spotlight, and the chat fixes.

The plan is deploy/2026.08.21.01.md. Two steps in it are required rather than optional and both would
be easy to skip: bun install, because bun.lock changed and sharp left the platform's dependencies for
atelier's; and db:push, because users.username becomes NOT NULL with a case-insensitive unique index.

That second one can refuse — on a null username, or two names differing only in case. Checked on all
six hosts before writing this rather than discovering it mid-rollout: 12 users, zero nulls, zero
collisions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:29:02 +00:00
pastilhasandClaude Opus 5 2261d16a8a bun run plugins:update, and the deploy prompt stops carrying its own server list
Updating plugins was only possible through POST /api/plugins/update-all, which is authenticated — so a
deploy had to mint an API key to update the machine it was already logged into. A credential created,
used and hopefully deleted, on every host, for a local operation.

Same work through the same functions with the HTTP layer removed, not a second implementation beside
it. Sequential like the route (each update pushes a schema, republishes assets, rewrites the ecosystem
file and rebuilds the frontend — two at once is a race over one file each), one plugin's failure does
not stop the rest, and it exits non-zero if any failed so a deploy can stop rather than report a
success it did not have. Verified: 3/3 in 18s.

Its header says the one thing that is not obvious — it runs OUTSIDE the server process, so files,
schema, assets and the SPA bundle are current when it finishes but the route table is not, until
something restarts officer. A deploy restarts anyway, which is exactly why that would otherwise be
found out rather than known.

The prompt had a table of six hosts under a line saying docs/servers.md is authoritative. Both cannot
be true, and the copy is the one people trust while it rots. Removed, with the reason written in its
place so it does not come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:24:47 +00:00
pastilhasandClaude Opus 5 07f854bf11 a prompt for the deploy commander, and the shape of a release
The agent watches for a commit whose subject BEGINS with the code word, rolls it out to the six
platforms in docs/servers.md, and reports back. It never decides what a deploy needs — the plan is
written for it in deploy/<version>.md, and an agent improvising a db:push is how a database gets a
schema nobody was ready for.

Four traps written in rather than left to be rediscovered, all verified tonight: take the checkout path
from PM2's pm_cwd because alpha has a stale second checkout that pulls successfully and changes
nothing; reach pm2 with `$SHELL -ic` because it lives under nvm in .zshrc and a login shell does not
read that, which is how I concluded twice that a machine had no pm2 while it was plainly running;
remotes differ per host so pull in place rather than assuming a URL; and macbookpro is a laptop that
may be asleep, which is a skip and not a failure.

Idempotence is the report's own existence — deploy/<version>.report.md on origin means deployed. A
watcher that restarts must not roll the same release out twice, and nothing else was standing between
it and doing so. The report's subject deliberately omits the code word, or a report would trigger a
deploy and loop.

Two gaps stated rather than papered over: there is no CLI for updating plugins, only an authenticated
route, so the plan has to say how or the report has to say it was skipped; and nothing wakes the agent
yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:20:46 +00:00
pastilhasandClaude Fable 5 44f6c6d6ac todo: group the url and query-string items into one theme
files-app becomes url-state: the file browser is where the work happened, but where
selection lives and who clears a query string are framework questions. The panel
framework's version of the same question moves in beside them, extracted from the
deep-read item, which keeps it as one of its three questions.

Item ids changed with the theme. Done now deliberately — no board reads them yet, and
this is the last cheap moment to move one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 16:40:39 +00:00
pastilhasandClaude Fable 5 ef2c9c51f7 todo: regenerate the index
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:47:52 +00:00
pastilhasandClaude Opus 5 8d476129ea an answer that goes nowhere now says so, and one that lands says that too
Second half of the AskUserQuestion report: "when I answer, the agent does not react — I see the bubble
for a long time and end up sending a message anyway", intermittently and not reproducible.

Two different causes, both invisible from the outside.

A DROPPED answer was silent at every step. user-instance discarded the boolean, sendFire does nothing
when no sidecar is connected, and the lookup returns undefined for both "no such session" and "not
yours". So a lost answer and a slow agent looked identical: a card that does not change. It now warns
with the reason, and where a session exists it emits an error into the durable log — the same channel a
successful answer uses, so a second pane sees it too, rather than confirming to the browser that
clicked.

A LANDED answer had the opposite problem. The card goes read-only the moment you pick, but `sent` is
local and optimistic while `message.output` only arrives once the harness has produced something. In
between there is a real gap, and nothing filled it — so an answered card looked exactly like one that
had timed out. It now says "Answer sent — waiting for the agent", which makes the two states different
without pretending to know when the agent will speak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:31:25 +00:00
pastilhasandClaude Opus 5 1a215dcfee a turn waiting on a person is not a stalled turn
Reported as: leave an AskUserQuestion unanswered for a while and the chat becomes unusable — refresh,
send another message, the agent is stuck.

armStall knew about compaction and about background tasks but not about a pending gate. Parking on a
question or a plan means emitting nothing until somebody answers; that is what parking IS. So the
watchdog could not tell it from a wedged turn, and at 10 minutes against gates that wait 20, it always
won:

  10m  the turn is declared dead, isGenerating goes false, an error is emitted, the session is torn down
  20m  the gate's own timeout would have resolved it truthfully — never reached

That left a ten-minute window to answer. Miss it and the card is still on screen with nothing behind
it, and clicking an answer does nothing at all: an answer's only receipt is the tool:result the gate
emits when it unblocks, and the gate is gone. From the outside that is precisely "unusable, refresh,
send another message".

A session with an outstanding question or plan is now left alone by the watchdog. Not unbounded — each
gate holds its own 20-minute timer and resolves with a sentence written for the agent — and the timer
is re-armed on both resolve paths, the answer and the timeout, so a turn that genuinely wedges AFTER a
question is still caught. armStall skips itself while any gate remains, so answering one of two
questions keeps the reprieve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:22:04 +00:00
pastilhasandClaude Opus 5 ff7358188d sub-agents: a spawn cannot take the last of a window
The failure this prevents is specific — a sub-agent burns the end of a limit window and the session the
owner is actually sitting in dies mid-turn, because the credits went somewhere nobody was watching. So
it is a condition on spawning rather than a refinement: an endpoint that can start unattended work
without checking is wrong from its first call, and the symptom lands on someone else's conversation.

Wired into POST /command-hq/commanders/new, which — this is the finding — already IS the spawn
mechanism the sub-agents item was asking for. It starts a turn with ws: null, waits only for
session:claude, and returns the transcript id, with command_hq_sessions naming, listing and costing it.
So "commander or something lighter" answers itself: a commander costs nothing extra and a lighter thing
would need its own registry to be visible at all. What is still missing is only the agent-callable
surface.

Refuses rather than queues or downgrades. Queueing means holding work against a window Officer does not
control, and the reset it would wait for is a claim about a shared pool — every claude on the machine
spends these, including terminals and pipelines. A refusal naming the window, the percentage and the
reset is something a caller can act on now.

The floor is 15% and the reasoning is in the file: the interactive failure is not "cannot start a chat",
it is "a turn already in flight is cut off", and a turn costs a fraction of a window.

Fails OPEN, deliberately. The numbers come from an external endpoint with a credential Officer does not
own, so a blip or a signed-out CLI must not stop every spawn on the machine with an error about usage
that has nothing to do with usage. The problem is reported so a caller can say "started, but unchecked".

Seven tests on the pure rule — tightest window decides rather than the average, no limits allowed rather
than blocked, exactly-at-the-ceiling refuses. Verified against the live account, where it correctly
refuses right now at Session 90%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:42:27 +00:00
pastilhasandClaude Fable 5 4c26e13535 todo: OPM — officer's processes on a manager of their own
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:05:57 +00:00
pastilhasandClaude Fable 5 645c8e36eb todo: urgent — per-feature server routing, due tomorrow for reclip
Adds priority/due to the item format and an Urgent section to the generated index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 06:03:51 +00:00
pastilhasandClaude Fable 5 47f3fbaf76 todo: centralise custom components, with per-theme overrides
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:58:42 +00:00
pastilhasandClaude Fable 5 a34ef422f4 todo: turn timer in chat, and a sub-agent spawn endpoint bounded by usage limits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:44:47 +00:00
pastilhasandClaude Fable 5 6071ded175 todo: one file per item, grouped by theme, with a generated index
TODO.md had reached 1050 lines and 72 open items across 28 sections, read mostly
on a phone. It is now a directory: todo/<theme>/<item>.md, one card per file, with
frontmatter carrying id, status, created date and order, and the prose body copied
across verbatim. TODO.md becomes a generated index (bun run todo:index).

Dates: where an item's prose stated one it is used; otherwise its old section's;
otherwise 2026-08-20, floored at the day before the split rather than invented.
date_source records which.

Shaped for a two-way Kanban board, filed as todo/tooling — a card is a file, id is
the identity, frontmatter is the machine's half and the body is the owner's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:42:39 +00:00
pastilhasandClaude Fable 5 8fa560df1b todo: backend turn delivery exists — correct three stale entries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:25:57 +00:00
pastilhasandClaude Fable 5 446f3fa64d todo: one audio inbox and a dispatcher agent that routes it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:18:44 +00:00
pastilhasandClaude Fable 5 31e858bcb7 docs: a running list of servers, and the fleet updater it feeds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:17:14 +00:00
pastilhasandClaude Fable 5 030db140c4 todo: split the watcher — a script transcribes, an agent reads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:10:52 +00:00
pastilhasandClaude Fable 5 589d83f191 todo: a standing agent job may already be the watcher
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:09:16 +00:00
pastilhasandClaude Fable 5 1f234c8282 todo: directory watchers with attached actions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 05:06:57 +00:00
pastilhasandClaude Fable 5 2e76cabf4f todo: prompt library, sudo terminal modal, iphone action button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:51:18 +00:00
pastilhasandClaude Opus 5 03abdeb246 an Update button, so a server is not a terminal away from current
Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the
other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every
time.

Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same
reason: this checkout is somebody's working copy on at least one machine, and a button that discards
uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a
checkout made before the default branch was renamed is on one the remote no longer has — the trap
plugins/update.ts already hit, and it bites hardest on the installs most in need of updating.

It deliberately does not chain into anything. A pull touching bun.lock means an install, which the
frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live
database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file
list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it
worked" is less useful than knowing what arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 04:22:30 +00:00
pastilhasandClaude Opus 5 4e2db3dc2f themes: generate all 22 from Omarchy, with contrast as a test rather than a taste
The eleven ported themes declared 34 of an 84-token contract. The missing fifty were every surface
(--panel-bg, --card-bg, --glass-bg), every state colour (--success, --warning, --busy, --info), the
header, the scrollbars and the grid — so they fell back to whatever the base stylesheet or the
previously applied theme had left behind. They also carried thirteen tokens nothing in this codebase
reads (--chart-1..5, --sidebar-*), which is the tell: they came from a shadcn template, not from the
contract in the README beside them.

So "highlights mask the content" and "text fades into the background" were not taste. They were
absence, and no amount of re-picking colours would have fixed them.

Generated now, from Omarchy 4's colors.toml, which is genuinely semantic — three background depths,
four foreground weights, accent, selection, muted, mode — and consistent: all 24 keys present in all 22
themes, checked. One mapping in scripts/themes/map.ts means one place to be wrong, one place to fix,
and an Omarchy update becomes a re-run.

Every foreground is driven through ensureContrast against the surface it actually sits on, and the
generator then re-checks sixteen pairs per theme and EXITS NONZERO on a failure. It found four real
bugs in my own mapping, which is the argument for having written it first:

  - `on(fill)` picked the better of two palette extremes without checking either passed; rose-pine's
    tones are all mid and came out between 2.2:1 and 4.2:1
  - ensureContrast chose black or white by a luminance>0.5 test rather than by measuring. rose-pine's
    rose sits at 0.25, so it drove toward white and capped at 3.5:1 where black reaches 6:1 easily
  - --muted-foreground was derived against the raised surface but renders on the high one; everforest
    landed at 4.44:1 and miasma at 4.00 — near-misses that look fine in a screenshot
  - borders were held to 3:1, which would ring every panel in a hard line. A panel edge is decoration;
    what must be discernible is a form field's boundary, so --input is checked and --border is not

35 failures at first run, 0 now.

The registry and the stylesheet imports are written from the same list, because the old port maintained
them by hand and drifted — catppuccin-mocha sat in THEMES after its stylesheet was gone. That file is
now deleted; Omarchy renamed it to catppuccin.

Eleven new themes arrive with this: ethereal, flexoki-light, hackerman, last-horizon, lumon, lupine,
miasma, retro-82, solitude, vantablack, white. Duck and Plain are Officer's own and are never generated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 04:18:58 +00:00
pastilhasandClaude Opus 5 a2cca526f9 spotlight opens on shift-space, and stands down inside a text field
ctrl-space focuses the address bar in Brave, so it never reached the app.

shift-space is a real combination inside a text field — it types a space, and people hold shift through
one more often than they think while typing capitals. So the trigger is ignored when focus is in an
input, a textarea or anything contenteditable, and the space is typed as normal.

The cost is worth stating rather than discovering: focus usually IS in the chat composer, so opening
Spotlight from there means clicking out first. If that turns out to be the common case, a double-tap of
shift is the escape hatch — it conflicts with nothing anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:51:57 +00:00
pastilhasandClaude Opus 5 81f32a1faa spotlight: ctrl-space, type a few letters, go
The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you
actually use rather than a complete one — which is the other half of this and is not done yet.

It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed,
permission-filtered answer to "what can this account open here": the shell's screens, plus whatever
installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer
to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in
Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited.

Settings sections are added, because they are what people hunt for and the dock deliberately does not
carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to
IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings
screens to the shell to save five lines.

Matching is subsequence, not substring, and lives in its own file so it can be tested with strings:
"chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode
what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with
shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and
paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests
cover the ORDER rather than the matching, because returning the right set is easy and returning it with
the right thing first is the product.

ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not
cmd-k either — that belongs to the browser on some platforms and to text fields on others.

Mounted beside the header and the dock rather than inside the content region, so it still gets you off
a screen that is maximised, erroring or mid-navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:48:46 +00:00
pastilhasandClaude Opus 5 ee7bc4920c three new todos, seven closed, one real bug fixed on the way
Added: a Spotlight-style launcher on ctrl-space with the dock pruned behind it, plus dock effects and
auto-hide; a repeating-prompt option on the background-job starter, which is the Nudger in the place
it belongs — attached to a job already running rather than a schedule pointing at a session that may
not exist, and inheriting its three refusals; and a deep read of the Workspace/Panels framework, low
priority and explicitly not pain-driven, with the existing panel items folded into it because they are
symptoms of the same unexamined design.

Closed after checking rather than assuming — five of the seven were already done and the entries
outlived the work. The whole offscale rename debt is finished: panels read "Offscale", the picker
heading is right, console session keys were renamed on 2026-08-16, and there are zero stale
/headscale paths or PLUGIN.md self-references. The one remaining mention is a form placeholder,
https://headscale.example.com, which is correct — that IS a Headscale URL. offscale declares ssh as an
osDependency. The manifest module-cache is fixed by importFresh.

example/manifest.ts named web/Router.tsx as the frontend convention; discovery wants panels.ts and
layout.ts, by those exact names, or it refuses the plugin.

One that was NOT already done, and I nearly closed it by mistake: reindex-music.ts moved into the
music plugin but still read process.env.DATA_PATH, which stopped existing on 2026-08-13. Run from the
repo it looked in platform/data/music/.server — one level too deep — and reported the sidecar as not
running. It now imports DATA_PATH from the platform, which resolves to /home/pastilhas/officer-dev/data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:38:37 +00:00
pastilhasandClaude Opus 5 bcf8df9d32 design: notifications from every server one app is signed into
The good news survives checking. An APNs auth key belongs to the developer account, not a machine, and
a device token belongs to the app, not a server — so N installs holding the same .p8 all push to one
phone with no coordination, and the app posting its token per server is something the multi-server
manager does anyway.

One field is missing: the payload carries no origin. That breaks the deep link (a session id is
meaningful only on the server that issued it), and threading, and — least obviously and most
expensively — `mv`. That is a content hash of THAT server's catalogue, and the client's rule is
`mv !== version → resync`. One stored catalogue and five servers means every push from B invalidates
what was synced from A, forever. It would present as a battery and data problem rather than a bug.

Where the identifier comes from is deliberately left to the sign-on design: the multi-server manager
already needs a key per server, and notifications should adopt it rather than invent a second identity
that drifts the first time a host is renamed.

Records what stays per server on purpose — devices, per-chat mute preferences, the catalogue itself —
because the app is what joins them, which is the same shape as the sign-on work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:19:17 +00:00
pastilhasandClaude Opus 5 4984fdc3a2 todo: let people see and switch off the affordances reaching their agents
They are global — affordanceBlock() reads every file and is called with no user context, so every
account gets every one in every conversation.

Fine while they are uncontroversial, and not fine straight after. "Wait for the question" was written
today to one owner's taste, is probably what most people want, and nobody asked anyone. An affordance
nobody can see and nobody can decline is a preference imposed as a fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 03:07:33 +00:00
pastilhasandClaude Opus 5 3fb439fabf plan: nightly visual regression on a second server
Capture every route of a JWT-authenticated dashboard each night, diff against a promoted baseline, and
have an agent look only at what changed.

The blocker is auth, and it fails silently. iris seeds a capture with Cookies, Cookies-wal, Login Data
and Local State — a JWT lives in localStorage, which Chrome keeps in Default/Local Storage/leveldb and
iris does not copy. So an authenticated capture renders the login screen: a real screenshot of a real
page that is the wrong page.

Three ways out, with a recommendation. Copying leveldb works and drags every site's localStorage into
a temp profile. A token in the URL needs no profile at all and removes the desktop plugin from the
plan entirely — that is the one to try first. Seeding through the relay works and needs a browser
paired at 3am, which is the constraint iris exists to avoid.

The rest is deliberate about where the model is spent: pixel diff first, mask what is known to move
rather than raising the threshold, and only changed pairs reach an agent — with "if you cannot tell,
say so" in the prompt, because a confident wrong verdict on night four is how this stops being trusted.

Baselines are promoted by a person, never by a run succeeding, or a regression becomes the baseline on
night two.

Also records what Officer lacks: the queue has retries and no schedules, so this is cron.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 02:44:25 +00:00
pastilhasandClaude Opus 5 d043e6650a name a published extension for itself, not "extension.zip"
extension.zip says nothing once it is in a Downloads folder — not what it is, not which server it came
from, and not which of two it is. The name comes from the extension's own manifest.json, which already
carries what a browser will show: "Officer Browser Relay" becomes officer-browser-relay.zip.

Generic, since extensions are meant to be a marketplace of them: any plugin shipping extension/ gets a
file named after its own manifest, falling back to <appName>-extension.zip when that cannot be read —
still better than extension.zip, and it never throws, because a broken extension manifest should fail
the install for its own reason rather than because a file could not be named.

Uninstall removes both names. An install from earlier today published extension.zip, and leaving that
behind would serve a stale extension for a plugin that is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:52:24 +00:00
pastilhasandClaude Opus 5 cfb2f98ccb plugins can ship a browser extension, published unauthenticated
The browser relay is the first extension, not the last, so `extension/` is now a directory any plugin
may ship — like `assets/` — and the installer zips it to public/plugins/<name>/extension.zip, served
by the existing /plugins/* wildcard with no auth.

Unauthenticated deliberately. An `<a download>` is a plain navigation with no Bearer header, so an
authenticated URL makes Chrome say "Try to sign in to the site. Then, download again" — advice that
cannot work, because signing in is not what is missing. Nothing in an extension is secret; the pairing
token is, it is entered afterwards, and it is not in the archive.

Built at install rather than committed. The file it replaces was hand-built and had drifted 58 lines
behind its source, serving an extension missing the multi-session recovery path.

zip.ts writes stored entries with fixed timestamps — no host `zip` binary, and identical bytes for an
unchanged directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:36:43 +00:00
pastilhasandClaude Opus 5 45d4823238 browser pairing and its extension zip leave the platform
The last two pieces the extraction missed. IntegrationsSettings/BrowserRelay.tsx drove the plugin's
pairing from a platform screen, calling /browser/relay-token and working only because the plugin
happens to serve that path — the same coupling removed everywhere else this morning.

public/browser-relay-extension.zip was a hand-built snapshot that had drifted 58 lines behind the
extension it claimed to be: built in August from something predating a February commit, never rebuilt,
and missing the multi-session recovery path. The plugin builds the archive from its own directory per
request now, so there is nothing left to fall behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:20:38 +00:00
pastilhasandClaude Opus 5 e44c0c7665 the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two
panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history,
and nothing regressed, because none of it had been running.

What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches
a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the
browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so
the registry stops describing one thing in the other's words, and it drops the /browser frontend route
it could no longer serve.

Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering
panels through appType placeholders — a plugin supplies its own route and panels through
Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover
from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no
longer had: dead at runtime, invisible to the compiler.

The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where
it lives now. It was written for whoever did this extraction, and this is that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:05:11 +00:00
pastilhasandClaude Opus 5 581ec28fc5 record the two pieces of plugin infrastructure the desktop extraction needs
Websockets: a plugin's socket has no door at all — mount.ts never sets `ws` and server.tsx's handler
map is a literal. Desktop ships one that cannot be mounted and will not be the last plugin to want one.

Host setup: a manifest declares packages, not a reconfigured display manager. Left manual for desktop.
Noted that a mechanism for this apparently existed months ago and was removed — history first, design
second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:53:42 +00:00
pastilhasandClaude Opus 5 0a1f1ce6df publish desktop, and stop counting node_modules as a plugin
The catalogue row and the bundled offline fallback, so the plugin installs from the marketplace like
every other. Listed honestly: its permission is owner-only at every path, and its websocket is not
mounted yet — the README and BLOCKERS.md say so rather than letting an install discover it.

marketplace-sync globs plugins/ itself rather than going through discoverPlugins, so the node_modules
skip added there did not cover it. Same fix, second place: it was reporting 15/16 and 16/17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:45:05 +00:00
pastilhasandClaude Opus 5 fcdbf02649 the platform stops knowing what VNC is
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and
apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem.

sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants
and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four
functions and a dozen message types in the platform, describing a feature that is not the platform's —
the rule CLAUDE.md states most plainly, and its clearest breach.

What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a
command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever
it likes through them and nothing here has to be edited when it adds a message.

The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind
a plugin permission cannot have and never will, because a permission nobody can delegate is a
contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied
to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not
exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the
key from its list.

Also gone: the app store's `vnc` entry, which would have offered an older second way to install the
same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13.

The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request".
It now uses the generic door, which is the better instrument — that file is about the blast radius of
a disconnect and never had anything to do with VNC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:42:19 +00:00
pastilhasandClaude Opus 5 b1a2d67b76 plugins: node_modules is not a broken plugin
The shared install target for plugin npm packages lives in plugins/ because bun resolves node_modules
up the tree. Discovery then found it, failed to read a manifest, and reported it as broken on every
call — a warning nobody can act on, and a denominator that made the marketplace sync report 15/16.

Mine, from the npm-dependencies work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:19:46 +00:00
pastilhasandClaude Opus 5 4efc2552d0 os-deps: a dependency declares who has to be able to run it
The probe asked the owner's login shell where a binary was, and the install reported that as a fact
about the machine. For a confined plugin the work happens as the member's own Linux user, in their own
home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable.

On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an
absolute path resolved. At 750, the more usual default, every member would have lost every binary with
nothing reporting it: the install would still have said satisfied, having only ever asked the owner.
Nobody chose 751, and that is the part worth fixing.

`scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a
manifest error rather than a silent fall back to the permissive one.

Shared changes three things. It is searched for on system directories only — asking the owner's shell
would find the copy in the owner's home and call it satisfied, the exact false pass this exists to
stop. Reachability is checked as world-executable AND every parent traversable to the root, because a
binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a
release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so
the download itself never runs as root.

An unreachable shared dependency gets its own state and refuses the install ahead of every other
check, because no package manager fixes it: the binary is installed and works, and what is wrong is
where it lives — reinstalling would report success and change nothing.

Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is
runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's
data is gated; its executable is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:15:38 +00:00
pastilhasandClaude Opus 5 87603b800b os-deps: ask the owner's shell where a binary is, instead of guessing directories
`Bun.which` reads the PATH this process started with, and under PM2 that is minimal — a release binary
fetched during an install was reported missing seconds later by the same install. The fix was
prepending ~/.local/bin here.

That is guessing at somebody's environment from inside a crippled one, and guessing does not converge.
rustup puts cargo in ~/.cargo/bin, nvm puts node elsewhere, and every per-user installer adds a
directory nobody listed. The iris plugin duly wrote its own copy of the function with ~/.cargo/bin
bolted on, which then disagreed with this one: the manifest probe refused a rustup-only machine that
the sidecar could build on perfectly well.

There is nothing to guess. The platform runs as the owner, plugins are installed by the owner, and the
owner has a shell whose rc files already put all of it on PATH — that is what those files are for. So
ask it: `$SHELL -lc 'command -v <binary>'`, with the shell taken from passwd rather than
process.env.SHELL, which under PM2 is whatever started the daemon. sessions.mjs already draws that
distinction for terminals; this is the same fact applied to dependency probing.

A login shell because ~/.cargo/env and friends are sourced from profile files an interactive-only
shell may skip. `command -v` because it is a POSIX builtin whatever the shell is. Two-second timeout
so an rc file that waits for input cannot hang an install. Bun.which stays as the fallback, with
~/.local/bin prepended there and only there, for an account with no usable shell.

Exported as locateBinary so a plugin's sidecar asks the same question the install did, rather than
writing a second answer.

Verified on this machine: the login shell resolves ~/.local/bin and ~/.opencode/bin, neither of which
is on this process's PATH, and finds a binary Bun.which cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:04:50 +00:00
pastilhasandClaude Opus 5 43aeffd9d3 api keys: revoked ones stop filling the screen, and can be forgotten
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks
like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a
list whose job is "which of my keys still work" answered with a wall of dead ones.

Nobody decided that. Revoke was implemented and the list was never taught the difference.

Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys"
toggle rather than merely dimmed, because on any account in use for a while they outnumber the live
ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an
account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a
bug in the list.

DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not
null` clause is in the statement rather than checked before it, so there is no ordering in which a
live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key
cannot be honoured again either way, since authentication matches a hash and a deleted row has none.
What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it
was killed — which is why it stays a second, deliberate step rather than what revoke does.

Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key
works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:22:29 +00:00
pastilhasandClaude Opus 5 c4042886fc api keys live in profile, not integrations
Reported by the owner, who went looking for them in user management and then in profile, and found
them in neither. They were under Integrations.

A key is not an integration with anything. It is a second way to present the authority the account
already has, and the permission registry already says exactly that: `/api-keys` is `core`, sitting
beside `/user` and `/dock`, with the comment "a key is not new authority". Profile, next to the
password it stands in for, is where somebody looks.

Moved rather than duplicated, and the route moves with it — /settings/profile/api-keys. Nothing
linked to the old path, so there is no redirect to keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:36 +00:00
pastilhasandClaude Opus 5 10feb76880 permissions: a plugin can mark part of its surface owner-only
A plugin worth granting to a Member can also hold the credential the rest of it runs on. Jellyfin's
/_config stores a server's admin token and drives account provisioning; the rest of the plugin is
exactly what a Member should have. The choice was all of it or none of it.

`ownerOnly` on a plugin permission takes sub-paths relative to the mount prefix. The permission stays
fully grantable — this is not the ownerOnly PERMISSION that manifest.ts refuses, and that refusal
still stands: a permission nobody can delegate is a contradiction. This says a few paths administer
the plugin rather than use it.

Declared by the plugin, enforced by the platform. A plugin could check this itself and the first one
would; the tenth would add a route and forget, with no boot failure and no warning — the shape
assertPermissionTotality exists to prevent. In the gate it is enforced once, on the door they all
pass through, and a route added later by someone who never read the manifest is covered by default.

Checked before selfService and before kind. A path that is somehow both self-service and owner-only
is a contradiction, and the safe reading has to win. A write grant does not unlock it either — level
is not the question this answers.

Also forwards X-Officer-Owner and X-Officer-Level to a sidecar whose proxy names its permission key,
for the finer decisions the platform cannot make for it: whose rows a read returns, which fields a
level may see. Facts, never the role name — a plugin branching on "Super Admin" would give every
plugin repository an opinion about the platform's role vocabulary, and renaming a role would change
authorization in code the platform does not own. These are safe to trust only because the proxy
copies a three-item allowlist off the inbound request; that is now written where it would be changed.

The owner's level is reported as write rather than the absent 'none': they hold everything implicitly
and appear in no grants map, so the raw lookup inverts the meaning for the one account that can do
anything.

No plugin declares one yet. Jellyfin cannot until connections stop being per-user — locking /_config
today leaves a Member with a plugin that only shows a connect form they may not submit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:56:10 +00:00
pastilhasandClaude Opus 5 1915a407a2 write down the plugin route authorization design, agreed and deferred
A plugin's settings routes should be owner-only while the plugin itself stays grantable — jellyfin's
/_config holds a server's admin token and drives provisioning, and a Member granted the plugin can
reach it today.

Agreed: the platform says who is asking, the plugin decides what that means inside its own surface.
That is not new — manifest.ts already refuses an ownerOnly permission on the grounds that finer
visibility is the plugin's business.

Two refinements recorded rather than assumed. Pass isOwner and the effective level, never the role
name: roles are the platform's vocabulary and a plugin branching on "Super Admin" gives every plugin
repository an opinion about it, so renaming a role would change authorization in code the platform
does not own. And nothing may be trusted from the caller — the proxy's three-item header allowlist is
what makes X-Officer-User safe, and a new header inherits that only while it stays an allowlist.

One disagreement, recorded as a proposal rather than settled: pure plugin-side enforcement is the
shape assertPermissionTotality exists to prevent. Jellyfin will check because we are about to write
the check; the next plugin's author forgets a route and nothing notices. So: declare owner-only path
prefixes in the manifest, enforce them in the proxy, and pass the facts to the sidecar as well.

The real prerequisite is not the guard. Connections are per-user, so locking /_config leaves a Member
with a plugin that can only show them a connect form they are forbidden to submit. The fix that fits
what exists is the owner's server URL with the member's own token from jellyfin_accounts — which is
arguably why provisioning mints those tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:50:13 +00:00
pastilhasandClaude Opus 5 da30d1a69e plugins: a reinstalled plugin was still being read from the deleted one
Delete jellyfin, install it again at a newer commit, connect a server — and no accounts were created,
no mapping rows written, and no error anywhere. Every step reported success.

The new checkout writes manifest.ts at exactly the path the old one occupied, and `await import(path)`
returns the module it already has: ESM caches by resolved specifier, and the specifier did not change.
So the platform kept obeying the manifest of a plugin that was no longer on disk. That manifest
predates `userProvisioning`, so the plugin was judged not entitled to the account list, the sidecar
was sent nothing, and provisioning skipped with "the platform has not sent the account list yet" —
a report that goes into the response body nobody reads.

It began working four minutes later for a reason that had nothing to do with the plugin: officer was
restarted for an unrelated deploy, which emptied the module registry.

`importFresh` keys the import on the file's mtime, so a file that changed is evaluated again and one
that did not is still a cache hit — `discoverPlugins` runs on every sidecar registration, every mount
and every plugins page load, so a Date.now() key would leak a module instance each time.

Used for the manifest, the api router and the db schema. For manifests it is complete: every manifest
imports one thing, `import type { PluginManifest }`, which is erased before it runs, so they are leaf
modules. For the other two it narrows the window rather than closing it — only the named file is
re-read, and its own static imports come back from the cache. A restart is still the only thing that
guarantees a plugin's whole module graph is the one on disk, and that is written down in the file
rather than left to be rediscovered.

The test pins the failure and not the mechanism: one case proves a plain import keeps serving a
manifest that is no longer on disk, the next proves importFresh sees the replacement, and a third
proves an unchanged file is evaluated once. mtimes are set explicitly, because two writes inside one
millisecond would otherwise decide the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:42:55 +00:00
pastilhasandClaude Opus 5 16dd67a3ca plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it
stays off the more certainly it is stale, and enabling would quietly run whatever was current on the
day it was switched off — with every step reporting success, which is exactly how the jellyfin
install on alpha re-applied a two-day-old checkout.

Deleting no longer has that problem, since it takes the directory with it and installing is always a
fresh clone. That left disable/enable as the one remaining way to run old code without being told.

The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is
refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty
working tree — every plugin directory on this machine is one — an unreachable forge, or a directory
copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that
will not turn back on because a network is down is worse than one running last week's code. So the
refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails
falls back to the code already on disk, which was running before it was disabled.

Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back
having reported "not refreshed", and the error that surfaces is about not being installed rather than
about git — which is only possible if the refusal was stepped past instead of returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:34:38 +00:00
pastilhasandClaude Opus 5 287cec39d0 plugins: enable, disable, delete — and uninstall is gone
Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still-
there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled
plugin kept its directory, which silently decided what the next install would run. That is how a
jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at
every step.

So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the
sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming
permissions that are about to stop existing.

Delete is offered only on a plugin that is not running — disable is one reversible click, so a live
plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also
delete all its data", unticked and never inherited from last time. Unticked, the directory goes and
the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go
with it, which is the only way to a machine that has never had the plugin.

The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by
anything that already knows how to call the safe verb, and the difference here is somebody's rows.

Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant
naming a key the platform no longer recognises made the whole permissions screen unsaveable, because
the page submits every grant it loaded.

Also corrected two rationales that the change made false — the runbook's "uninstall never destroys
data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:19:32 +00:00
pastilhasandClaude Opus 5 d6aef4cb92 plugins: delete is the second verb, and the list says what it is
Uninstalling a plugin left it under a heading that said "Installed", with a caption saying "not
installed". It never moved, so a successful uninstall read as a failed one — and nothing on screen
said the directory was still sitting there deciding what a reinstall would run.

That mattered more than the wording. Install skips the clone when the directory exists (install.ts
findPlugin, fetch.ts refusing an existing dir) and never pulls, so reinstalling jellyfin on alpha
silently re-applied a checkout from two days earlier. The provisioning code was not on the machine,
jellyfin_accounts was never created, and connecting a server did nothing at all — with no error
anywhere, because every step it did run succeeded.

Two changes, both UI:

- The heading is "On this machine", which is what the list is: every plugin DIRECTORY, installed or
  not. Delete is what moves a plugin back to the marketplace, and now the screen shows why.
- Delete is gated on being uninstalled and is a button rather than a small underlined link. It was
  previously offered in every state, so two clicks separated a running plugin from its tables being
  dropped. Uninstall, then delete — the order the buttons now teach.

Still true and not addressed here: uninstall followed by install reuses the existing directory as it
stands. The detail pane says so now; making install fast-forward a clean checkout is a separate call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:10:37 +00:00
pastilhas e37b1a151c usernames are required, and unique case-insensitively
two platform fixes that make a plugin's job smaller, both from the owner:
downstream services identify a person by name, so the platform has to be the
one holding that name to a single meaning.

`getUserByUsername` compared with `eq`, so `diogo` and `DIOGO` were two
accounts. create-user.ts asks "is this taken?" through that function, which
means the check could not see its own collision. it compares on `lower()` now.

and the constraint is on `lower(username)`, not the column — a plain unique
index is what allowed both to exist. it sits in the schema rather than only in
create-user.ts so a second code path, a migration, or someone at a psql prompt
cannot get around it.

the column is NOT NULL. `validateUsername` has always demanded one, but the
column allowed null, so anything skipping validation could write a user nobody
could name — and a Linux account or a Jellyfin login then has nothing to work
with.

both databases were checked before the change: zero null usernames, zero
case-collisions on either, so no migration was needed.

proved rather than assumed, inside a transaction that was rolled back on the
live database: `PROBEUSER` refused against `probeuser` by
uq_users_username_lower, and a null username refused by the not-null. no rows
written.

this also removes a case the jellyfin reconcile had to carry: an officer
account with no username could not be matched or created there, because
jellyfin authenticates by name and has no email login.
2026-08-20 20:03:18 +00:00
pastilhas a6e61d0c26 user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich
next — needs to know who the platform's users are. it now gets told.

DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles`
arrive on an unauthenticated socket and are believed, so a capability claimed
there would be a plugin granting itself one. the manifest was reviewed when the
owner installed it, and sits beside `permissions` where "this plugin will be
told who your users are" belongs — on the install screen, not discovered later.

PUSHED, not requested. a sidecar has no authenticated way to ask, so a request
could only be answered, never authorised. a push inverts that: the platform
decides who gets it, and a caller registering under a name with no such
manifest gets nothing.

`listProvisionableUsers` is a hand-written projection — id, email, username,
name, role — and NOT `select *`. `users` holds `password` and `os_user`, and
`getUsers()` returns both, so a plugin reaching for the obvious function would
get every account's password hash. naming the columns means adding a secret to
the table cannot silently add it to what plugins receive. pinned by a test that
asserts the key set, not just the absence of today's secrets.

the owner is excluded. they bypass every permission check and already have
whatever upstream account they want; provisioning them is at best a no-op and
at worst a second admin nobody asked for.

sent after the ack rather than inside it, so registration never waits on a
database query — a slow postgres would otherwise stall every sidecar's startup,
and none of them need the list to start.

and re-sent on every account create and delete, because the registration copy
is a snapshot: a plugin still holding a deleted user would keep an upstream
login alive for someone the platform has removed. read once per broadcast, so
two plugins cannot be told different things about the same moment.
2026-08-20 19:18:37 +00:00
pastilhasandClaude Opus 5 1cac61aa00 docs(plugins): add USER_PROVISIONING runbook
Contract for provisioning a user into a plugin's upstream service, with two
worked examples.

Four rules: never hardcode the admin credential; check-then-create (neither
API upserts); force a password change where the server supports it; verify by
actually logging in, since a 201 only means the row was written.

Immich (photos plugin) — verified end to end against a live 3.1.0 instance,
including every error body: duplicate email 400 "Email is not available",
missing field 400 validation, bad key 401. Includes a non-destructive probe
recipe to re-verify after an upgrade.

Jellyfin (jellyfin plugin) — route surface and auth model verified against
10.11.11; request bodies from working knowledge and marked as such. Differs
in shape: MediaBrowser token auth, multi-call create, no forced-password-change
flag, and accounts are per-instance (this deployment runs four independent
servers). Records the decision to set a generated password rather than create
blank-password accounts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:49:55 +00:00
pastilhas c336042b12 remove a dangling export, and write down the lockfile question
`pluginPackageJsonExists` was exported for an uninstall hook that was never
wired. an export with no caller is an unfinished thought pretending to be a
feature, so it is gone — and the behaviour it would have served is right
anyway: uninstall KEEPS the plugin directory, so its packages are still
declared and still wanted. only "delete and all its data" removes the
directory, after which the next install regenerates the merged file without it.
the packages linger until then and go on their own.

and the thing the owner asked to come back to: CLAUDE.md's frozen-lockfile rule
reads as universal, and npmDependencies does not follow it. ranges resolving
per machine is what lets two plugins share one sharp, and it was chosen
knowingly — but the codebase now holds two postures on one risk with only one
of them written down. recorded in TODO.md with the three specific things worth
settling rather than left as a feeling that something is inconsistent.
2026-08-20 18:26:27 +00:00
pastilhas 21bedf1418 npmDependencies in the manifest, installed once for every plugin
replaces the per-plugin package.json from earlier tonight. the owner's call,
and the reason is one i had not weighed: sharp is ~50 MB of platform binaries
and the obvious dependency for anything touching images, so a second and third
plugin wanting it would have meant a second and third copy with no version they
could converge on.

so every plugin's `npmDependencies` merge into ONE generated plugins/package.json,
installed into plugins/node_modules/. bun resolves node_modules up the tree, so
that directory serves every plugin and none of the platform's own code.

ranges intersect rather than alternate. two plugins asking for ^0.33.5 and
^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved
version must satisfy both. `||` would have meant "either", which is the silent
version of this failure: one plugin quietly getting a version the other cannot
import. incompatible requirements now fail the install with bun's own message,
and both askers are named in the steps either way.

generated from the plugins ON DISK, like generatePluginSchemas, and never
edited — so a package cannot appear there without a plugin having declared it.

what this gives up, knowingly: a committed lockfile. ranges are the whole point
and a range resolves per machine, so plugin packages are resolved at install
time like osDependencies are, and unlike the platform's own. CLAUDE.md is
emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest
type rather than left to be discovered.

the generated plugins/package.json and lockfile are gitignored — /plugins/*/
only ignores directories, so files at that level needed naming explicitly.
2026-08-20 18:22:58 +00:00
pastilhas 3450f5eb57 warn when a plugin's packages would block its own updates
update is a git pull and then the ordinary install path, so the npm step is
picked up for free — a plugin that adds a dependency in a later version gets it
installed on update, with no second implementation to drift.

but pullPlugin refuses on ANY uncommitted change, and node_modules/ that
nothing ignores is one. so a plugin shipping package.json without ignoring it
installs perfectly once and can then never be updated again: the button reports
"N uncommitted changes" forever, about a directory the platform itself created,
and nobody seeing that message has a reason to connect it to a package.json
from three versions ago.

every plugin that exists today ignores node_modules, which is luck rather than
a rule — this makes it a checked one.

asked with `git check-ignore` rather than by reading .gitignore: the rule may
be nested, global, or in .git/info/exclude, and all three count.

warned, not fixed. writing into the plugin's own .gitignore would be the
platform editing a repository it does not own, and would itself dirty the
checkout — which is the exact thing being avoided.
2026-08-20 18:13:53 +00:00
pastilhas 7e654d75bc plugins can declare their own npm packages
osDependencies covered binaries on PATH — ffmpeg, radicale, tea — and there
was nothing for a javascript package. so a plugin needing one had to add it to
the PLATFORM's package.json, which is what atelier did with sharp, carefully,
with the frozen-lockfile dance documented in BLOCKERS.md.

it still broke on the first machine that was not this one. alpha pulled the
package.json, nobody ran bun install, and officer-atelier crash-looped on
"Cannot find package 'sharp'" — after the install flow had reported success.

three things were wrong with that shape. the platform carried a dependency
only one plugin needs, and uninstalling the plugin would not have removed it.
installing a plugin could not install it, so "installed" and "can start" were
different facts with nothing checking the second. and it forced the platform's
lockfile open, which is precisely the signal frozenLockfile exists to keep rare.

now: a plugin ships its own package.json and lockfile, committed in its own
repository, and the install flow runs `bun install --frozen-lockfile` in the
plugin's directory. bun resolves node_modules up the tree, so
plugins/<name>/node_modules serves that plugin and nothing else, and uninstall
takes the packages with the directory.

the presence of the file is the declaration — no manifest field, same reason
sidecar/index.ts has none: a second place to say it is a second place to be wrong.

verified by RESOLVING, not by the exit code. sharp resolves to a
platform-specific binary package, so a lockfile that installs cleanly on one
machine can leave nothing importable on another — which is the exact failure
being fixed, and checking the exit code would have reproduced it one layer up.

sharp is off the platform's package.json. the lockfile diff is that removal
plus @img/sharp-libvips-* re-resolving 1.0.x → 1.2.4, because the platform no
longer pins sharp; nothing else moved, checked line by line. the platform still
reaches sharp transitively through next, which is where it came from.
2026-08-20 18:10:08 +00:00
pastilhas b04059cdd6 untrack the atelier panel the platform should never have held
`git mv` from src/ into plugins/ staged a rename, and a staged rename beats
.gitignore — so the platform repo committed a file belonging to a plugin that
owns its own repository. the file is unchanged on disk and is committed where
it belongs, in plugins/atelier.

worth knowing generally: moving a file OUT of a tracked directory into an
ignored one needs `git rm --cached` afterwards. the ignore rule does not undo
a rename that is already in the index.
2026-08-20 17:58:28 +00:00
pastilhas 7b975784b8 atelier providers move into the plugin, where the rule already said
`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: "A plugin contributes panels, not settings sections, so it now
lives inside the email workspace itself."

the atelier extraction brief said the opposite, and the brief was mine and
wrong. sonnet found the contradiction, recorded it in BLOCKERS.md under a
heading that says "against a direct precedent", and followed the instruction
anyway — which is what it was told to do. so the wrong call got implemented
carefully rather than caught.

settings in the platform mean the plugin is not self-contained: it cannot be
configured on an older platform, and uninstalling leaves a dead section in the
settings screen for someone to delete by hand. neither is true of a panel.

the section, its import and its now-unused icon are gone from here. the screen
is `plugins/atelier/web/Providers/` and a fourth panel.
2026-08-20 17:58:06 +00:00
pastilhas 329c47c499 atelier in the marketplace catalogue
the repo exists now — gitea.officer.dev/plugins/atelier, public, master, in the
plugins org like every other one — so the bundled catalogue can name it.

BUNDLED is the offline floor under marketplace.officer.dev, not a duplicate of
it: a machine that cannot reach the marketplace still sees the plugin. the sync
picks it up from gitea on its own, verified — 14 plugins, atelier among them.

`has` says all four, which is true of it: the proxy router, atelier_* tables in
officer_db, the generation sidecar, and three panels.
2026-08-20 17:36:15 +00:00
pastilhas 7579620f81 atelier settings: enter saves the api key
the key field was an Input and a Button side by side, outside a form, so Enter
did nothing. pasting a key and pressing enter is the strongest habit there is
in a password field, and when it silently does nothing it reads as "the key
did not save" — which is the one thing this screen must never be ambiguous
about.

TODO.md tracks nineteen panels with the same defect. this is not the twentieth.

everything else in the screen was already right and is left alone: type=password,
autoComplete=off, the server never returning a key, and the field starting
empty with a placeholder that says whether one is set.
2026-08-20 16:39:51 +00:00
pastilhas 1d1a4a6fa5 atelier: extraction finished — verification, open items, no db/ recorded
Atelier is now plugins/atelier/: manifest, a thin auth-gated proxy router, the sidecar backend
(providers, the generation queue, collections, its own separate Postgres database), three panels
(Generate, Gallery+Lightbox, Collections), and Providers as an Officer settings screen instead of a
fourth panel. Its own local git repository, four commits, not yet pushed anywhere — see BLOCKERS.md.

bunx tsgo is clean. bun test: 1041->1069 pass, 0 fail, 90->92 files (the two new files are ported
tests, no regressions). The real production SPA bundle builds successfully with the plugin included
(Bun.build + Tailwind, 2.2s, zero errors) and the sidecar's HTTP API was exercised end to end against
a real, non-production Postgres database. Not done, and recorded as open: a live PM2 install cycle
against the shared production database, and publishing the plugin's repository to Gitea and the
marketplace — both cross into affecting shared systems beyond this local checkout, which is not a
call to make unattended.

The standalone officer-atelier service — what the mobile app actually talks to today — was not
touched, not stopped, and not migrated. This extraction is additive.

ATELIER-COMPLETE
2026-08-20 04:11:43 +00:00
pastilhas 6d0b2743a5 settings: Atelier providers under Integrations, per plugin decision
decision 2 of the atelier extraction: providers (which adapter, which model, whose key) becomes an
Officer settings screen rather than a panel, since it is owner-level configuration in the same shape
as the Apify token beside it. Talks to /api/atelier/providers over HTTP through useClient() — no
import from plugins/atelier/ in either direction.

recorded in BLOCKERS.md: this goes against IntegrationsSettings' own comment from the email
extraction five days earlier ("a plugin contributes panels, not settings sections"). Followed the
pre-made decision anyway per the kickoff prompt's own instructions, and wrote down the
contradiction rather than picking one silently.
2026-08-20 04:02:29 +00:00
pastilhas c8686ff191 channels: add useAtelierRefresh, for the atelier plugin's Generate/Gallery panels
same stable-bump shape as useFilesRefresh. Generate and Gallery are independent sibling panels with
no props between them (per the panel contract, components only ever receive panelId), so a finished
job reaching the gallery needs a signal rather than the standalone app's onFinished callback prop.
2026-08-20 04:01:00 +00:00
pastilhas 5854ccd4d8 atelier: map the platform, record what stays untouched, add sharp
no platform code references atelier and atelier references no platform code, so this extraction
has no reverse-dependency seam to design around. also records the database-separate decision
(collections and provider_settings both have rows on the live server) and adds sharp as a direct
dependency for the image-inspection code the sidecar needs — promoted from an existing transitive
resolution, no new package versions pulled in.
2026-08-20 03:53:30 +00:00
pastilhas 597597f50e atelier brief: push to master per task, and ATELIER-COMPLETE closes the run 2026-08-20 03:35:36 +00:00
pastilhas adc45c4343 atelier brief: port the ui, do not frame it
the first draft said follow gitea — sidecar serves its own ui, plugin proxies
it, one panel renders an iframe. wrong for this one. atelier was a separate
server because that was the quick answer at the time, and this extraction is
what undoes that: it becomes part of officer, not something officer frames.

and it is smaller than the repository suggests. 1,736 lines of react, but six
of the twenty-five files are shadcn components officer already has, and the
htmx half is eight files that get deleted rather than ported. what is left is
generate, gallery, collections and providers — about 1,200 lines.

providers is a settings screen, not a panel, so it moves to officer's settings
surface. src/servers/providers/ is already adapter-shaped, so the point of
moving it is that a SECOND provider becomes a new file rather than a new
screen. the brief says explicitly not to add one in this run — leave the seam,
say so in PLUGIN.md.

still a sidecar, for the one reason that survives the ui moving: generation is
long-running, capped by ATELIER_MAX_CONCURRENCY, and holds a credential the
platform must not. the ui is officer's; the work is the sidecar's.

added the trap that follows from all of this: "every image has a permanent
URL" was true of a standalone server with its own PUBLIC_URL, and inside
officer that url is behind the session. an <img> cannot carry a token — the
core affordance says why — so anything needing an unauthenticated image is a
blocker to record, not a route to open.
2026-08-20 03:29:05 +00:00
pastilhas 5d49f5ae32 a kickoff prompt for extracting atelier, written for an unattended run
differs from EXTRACT-A-PLUGIN-PROMPT.md in the two ways that matter when
nobody is awake.

it does not say "ask and wait", because there is no one to ask. the five
decisions that would normally be questions are pre-made with reasoning, and it
says to follow a decision even after finding evidence against it — recording
the evidence instead. a half-applied decision is worse than a wrong one
applied consistently.

and it assumes the agent will be restarted mid-run, so it says commit after
every task: a clean tree and a commit message are how the next attempt, quite
possibly the same agent an hour later with no memory of it, knows where it is.

the substantive call is the UI. atelier ships htmx AND a react spa with its own
build, and porting 84 files into panels is not an overnight job. gitea already
solved this exact problem — sidecar serves its own ui, plugin proxies it
same-origin, one panel renders it in an iframe — so the prompt points there and
says to read sidecar/rewrite.ts first, which is where the traps are recorded.

the database decision is deliberately conditional rather than fixed: count the
rows first, fold two empty tables into officer_db, keep the separate database
if either has data. losing someone's generated images to a schema decision at
4am is not a trade worth making unattended.

it opens with a worktree, and says no second agent in this tree. today two
agents with bypassed permissions ended up in one checkout and nothing was lost
only because one of them noticed and stopped.
2026-08-20 03:23:49 +00:00
pastilhas 6db26ae439 nudger: "not generating" was permission to start a SECOND agent
it fired once, in anger, and put a second claude with bypassed permissions
into a repository another agent was mid-build in. nothing was lost only
because that agent noticed the collision and stopped rather than committing
over the work. it also found a real bug while it was there, which is not a
defence of any of this.

the guard was exactly backwards. it asked one question — "is this session
generating" — and treated NO as permission. but a session the sidecar no
longer holds is also not generating, and `startClaudeTurn` given one of those
does not resume it, it starts a NEW one. so the check that was meant to
prevent interference is the thing that guaranteed a second agent, and the
likeliest state for an overnight target — garbage collected after 30 idle
minutes — is precisely the dangerous one.

three questions now, and only one answer is permission:

  not live at all          refuse AND disarm. nudging it would create an
                           agent, which is not what a schedule attached to a
                           conversation means and never was.
  live but generating      refuse, keep the schedule. it is working.
  any other live session   refuse, keep the schedule. two agents with bypassed
                           permissions in one tree is not something to arrange
                           on purpose.

the third is blunter than it needs to be — the sidecar does not report a
session's directory, so it refuses on ANY other live session rather than on a
comparison it cannot make. that will decline some safe nudges. given what the
precise version cost, the blunt one is the right trade until the sidecar can
say where a session is running.
2026-08-20 03:17:39 +00:00
pastilhas 18bfff5e74 nudger: the due query was invalid sql, and failed silently every tick
`dueNudges` passed a JS Date as a parameter on the left of an interval
subtraction. postgres will not infer a type there and refused the whole
statement, so the tick threw once a minute and no nudge could ever fire.

the symptom was the worst kind: the row sat in the table enabled, sent_count
0, last_fired_at null — indistinguishable from a schedule that was armed and
simply had not come due yet. it was found by checking the row after arming it
rather than by trusting the log line that said "armed".

`now()` in sql instead, which also means the comparison uses the database's
clock rather than the platform's.

the test asserts only that the query EXECUTES. what broke was the sql being
invalid, and no amount of reasoning about interval arithmetic would have
caught that — only running it against a real postgres does.
2026-08-20 03:14:21 +00:00
pastilhas 12c5e51ce4 nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a
turn, and at 3am nobody types the next message. this is the schedule that does.

the door already existed. `startClaudeTurn` takes `ws: null` — extracted that
way so command-hq could drive a turn over http — so this is a second caller of
an existing path rather than a new way into a live session. i had said the
platform lacked that; it did not.

the rule that decides the design: never speak while the agent is working. so
`every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the
session is generating, the row is left alone and reconsidered next tick, which
means it fires as soon as the turn ends rather than sleeping through to the
next hour. a strict alarm would skip an agent that finished four minutes late
and leave it idle for the other fifty-six.

`isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be
mid-turn, and interrupting one is the failure this exists to avoid.

matched on both sessionKey and claudeSessionId: officer's handle and the
transcript's filename are different ids, only the agent holds the mapping, and
a schedule stored against one would silently never match a live row keyed by
the other.

one nudge per (user, session) — a second schedule for one conversation is two
alarms for one sleeper. counted even when the turn fails to start, so a
refusal cannot become a tight loop. disables itself at the ceiling rather than
leaving a spent row armed. the tick starts in server.tsx rather than on first
use, because a schedule armed before a restart has to survive one.
2026-08-20 03:07:28 +00:00
pastilhas 2b69334a6c todo: the feature is called Nudger 2026-08-20 03:02:17 +00:00
pastilhas 10e30754f3 todo: a self-nudge control in the chat composer
wanted while leaving an agent to work overnight — "continue every hour if you
have stalled", beside the background-job button.

tonight's script does it from outside, polling the sessions api and speaking
over the chat websocket exactly as a browser does, which is why the platform
needed no changes. that is the workaround; this is the feature.

records the two things the platform actually lacks — a recurring trigger, and
any door into a live session that is not the chat websocket — and the three
design notes that are easy to get wrong: never speak while isGenerating, the
schedule cannot be owned by the session it nudges (a chat background job dies
with its session, taking the watchdog with the agent), and bound the loop.

the same trigger is what the watcher-loop proposal in COMMS has been waiting
for, so it is one piece of infrastructure serving two asks.
2026-08-20 03:02:10 +00:00
pastilhas adb77882b6 nudge: poke an idle chat session on a timer, never a working one
for an agent left running overnight. it is told to record blockers and carry
on rather than wait for a human, and mostly it does — but a turn that ends
leaves it idle until someone types, and at 3am nobody does.

a script rather than a queue job, deliberately. the platform has no recurring
scheduler and no way to put a prompt into a live session — `pushTurn` has
exactly one caller, the chat websocket — so this would be real work in the
queue engine AND the chat api, done tonight, in a repository another agent is
currently working in. instead it talks to the same two doors a browser does
and changes nothing in the platform. if the loop is worth keeping, it belongs
in the queue with a real trigger, and this is what that would replace.

the one rule is that it never interrupts work: `isGenerating` is checked
immediately before speaking and a working session is skipped silently. the
point is to restart a stopped agent, not to hurry a busy one.

`?all=1` on the session list, because without it the list is scoped to the
owner's home and an agent started in a project directory is simply absent —
which this would have read as "the session is gone" and stopped the night.

mints its own owner token: it runs on the box, as the owner, and a token
pasted into a shell is a token in shell history. `--max` defaults to 12, so a
loop nobody is watching ends on its own.
2026-08-20 02:53:34 +00:00
pastilhas aa5bacec46 email recall: the design, and which answers can be trusted
the use case is someone mid-phone-call who needs a fact from eight years of
correspondence now. everything in here is in service of that moment.

the split that came out of the discussion, and it is the whole document:
counting needs EXHAUSTION, retrieval needs SPEED, and they are different
machinery. an agent asked "how many times did she ask X" will search, find
some, and answer with a number bounded by the phrasings it happened to think
of — "i found four" is indistinguishable from "there were nine", and nothing
in the output says which. vector search does not fix that; top-k misses too,
just differently. the only honest count is to judge every message once and
store the verdict.

which makes the compile half the one that answers the questions that prompted
this, and it needs no vectors at all.

embeddings still earn their place for the other row: keyword search finds
"school run" and never finds it from "the pickup arrangement", and on a live
call thirty seconds of searching is a lost moment. the stronger argument is
whatsapp and transcripts sharing no vocabulary with email — but we do not have
those, so they shape exactly one decision (the store is not email-shaped) and
nothing else.

two constraints stated as constraints rather than preferences: the embedding
model runs LOCALLY, because an api means uploading eight years of private
correspondence to a third party, and there is no ollama or embedding code on
any machine here — that is the real new dependency, not the database. and it
is sqlite-vec in the plugin's own file, not a vector database: 19,097 rows is
small, and a separate service would be operational surface bought for nothing.

the hard part is neither. it is quoted reply chains putting the same paragraph
in forty messages of one thread, which naive embedding turns into forty
identical results. step 1 of the build order is chunking with no embeddings at
all, because if that is wrong nothing after it can be right.

a stored verdict is a judgement a model made about someone's divorce, so it
records what it was asked, what it read and when. a number with no provenance
is worse than none, because it will be quoted.
2026-08-20 02:33:31 +00:00
pastilhas 97d1748eba email: one database per account, and the agent only knew about one
`OFFICER_EMAIL_DB` points at a single account's sqlite file, chosen by
`findEmailAccountName` as the most recently synced. that is the right guess for
"what did X say yesterday" and a silent lie for anything meaning ALL my mail:
the agent searches one mailbox and reports as though it searched every one,
with nothing available to tell it the others exist.

the layout stopped being one file per user when several addresses became
possible — DATA_PATH/<owner>/email_accounts/<address>/emails.db — and the
reference an agent reads was still describing the old shape.

so `OFFICER_EMAIL_ACCOUNTS_DIR` is handed to the mcp tool env alongside it. the
directory rather than a list, because listing it is discovery the agent can do
itself; deriving the path from a convention would mean documenting the
convention anyway, and then having two things to keep in step.

the skill now leads with the multi-account layout, names `attachment_cache` as
the entry that is not an account, and says to state which accounts were
actually covered. checked against the live tree, which has exactly those two
entries.

the index is unchanged at 608 characters — the whole point of the split is
that the body can grow to meet the truth without any conversation paying for it.
2026-08-20 01:12:40 +00:00
pastilhas 17243c2201 rescan: say when a skill exists but is not offered
the button counts skills — one directory with a SKILL.md is one skill. it now
also reports how many are actually OFFERED to an agent, which is a different
number: the loader skips any without a `description`, because a line that does
not say when to reach for something costs context in every conversation and
earns nothing.

so a skill can be present, listed, editable in the UI, and invisible to every
agent, with nothing anywhere saying why. the toast names the gap and the reason
rather than leaving two numbers to be noticed by whoever thinks to compare them.
2026-08-20 01:10:16 +00:00
pastilhas 49f7761f53 skills: publish the index, leave the body on disk
the inverse of an affordance, and deliberately so:

  affordance   body always compiled in, description never sent
  skill        description always compiled in, body read on demand

an affordance is a fact about the surface — short, and every conversation
needs it. a skill is reference material that is dead weight in a conversation
about something else. so what reaches the prompt is the index: name, when to
reach for it, and the path. measured on the first one: 608 characters in the
prompt against 6,070 bytes left on disk.

no protocol. progressive disclosure is normally a harness feature — claude
code discovers .claude/skills, opencode has its own conventions, the next one
will have a third — and pointing at a file needs none of that, because every
harness worth running can already read a file. the cost is that the agent has
to CHOOSE to open it, which is what the description is for and why a vague one
makes a skill useless rather than merely verbose.

the first skill is the email database, moved out of tools/email-db/TOOL.md
where NOTHING has been reading it: the loader parses frontmatter and discards
the body, so that schema and its eight queries have never reached any claude
code session. written for pi, inert since pi left.

it carries two things the schema alone gets wrong, both measured against the
real 19,097-message database rather than copied from the old doc: `read` is
populated on 93 rows, so the documented unread query returns essentially the
whole mailbox; and `labels` is a comma-separated string with real values like
`updates,inbox`, so `labels = 'inbox'` misses most of the inbox.

plugins can ship skills, published and unpublished on install like affordances
and notification catalogues. flat namespace unlike those two — a skill's
directory name is the path an agent reads, and `officerdev/gitea/SKILL.md` in
a prompt is noise. a skill has no authority to usurp, so nothing needs
protecting.

wired to claude code. opencode still gets NOTHING — its runner sends no system
prompt at all, and session create takes only a directory. that needs its own
channel and a guess would be worse than the gap.
2026-08-20 01:09:07 +00:00
pastilhas fe7fe49cee chat: stop reassigning a live session to fable
three defects, one shape: the code has two names for "which session is this",
and every guard checked the one no caller uses.

`fable` is the FIRST entry in the claude-code catalogue, so it is what
ModelSelector picks whenever it decides a conversation needs a default. On a
key without fable entitlement that lands as a 429, after a real mid-session
switch — the reported header read OPUSPLAN → FABLE.

1. the settings default was applied to RESUMED chats. the guard was
   `!initialSessionId`, and no chat caller passes that when resuming — the id
   arrives as `options.resumeSessionId`, and ChatPanelWrapper goes as far as
   `resume ? undefined : sessionKey`. so a user's default model was written
   over sessions that already had one. both names are checked now.

2. `hasStarted` was `!!preloadedMessages?.length`, which is FALSE for the
   first frames of a resumed chat — before its messages arrive. ModelSelector
   reads "not started" as permission to pick a default, so a reload reassigned
   live sessions during that window. a session id is known on the first render
   and a chat that has one is by definition not new.

3. harness lock. `crossesHarness` returned false when the current harness was
   unknown, which reads as "nothing crosses" and is the opposite of what is
   known — with no current harness, everything might. so a started session
   offered a switch to opencode while its model was still resolving, a switch
   that cannot work: it hands a `ses_…` id to `claude --resume` and dispatches
   to the wrong sidecar. unknown is not permission; the strip locks until the
   answer arrives.

the settings themselves were never wrong — chat.defaultModel reads
claude-code/opus on the affected server. nothing was ever reading it for the
sessions that got reassigned.
2026-08-20 00:32:44 +00:00
pastilhas ab214d1a9e rescan: count affordances, and say what a rescan cannot do
the header's rescan walks ITEM_TYPES, and affordances are not one — no
directory per item, no canonical file — so they were invisible to it. write a
new affordance, press the button, and nothing in the response mentions it.

counted by LOADING them rather than counting .md files, so the number means
"affordances the agent will actually receive" and not "markdown files
present": one with no body is skipped by the loader and should not be reported
as though it were working.

counting is as far as this can honestly go, and the toast now says so.
affordanceBlock() is read when a chat session is CREATED, so a new affordance
is already live without a server restart — for the NEXT session. nothing can
retrofit one into a conversation already running, because its system prompt
was assembled when it started. the button's value here is "the file is on disk
and the platform can see it", which was the part genuinely unknowable from the
UI.

also creates the affordances directory, so it is discoverable on a machine
that has never had one, the same way the item dirs already are.
2026-08-19 23:35:11 +00:00
pastilhasandClaude Opus 5 b14b1bf678 green that means "healthy" becomes the success token
Fifteen sites across thirteen files where a raw green stood in for a
semantic state: connection dots, health lights, completed-job ticks, the
copied-to-clipboard check, the installed badge, the checksum-matched line.
All of them meant "on / connected / succeeded", and matched each other only
because several people happened to pick the same palette number.

The values do move: --success is #16a34a where green-500 was #22c55e and
emerald-500 #10b981. That is the point — one token, so a theme can change
its mind once, and under plain.css (the leak-detection theme, which greys
--success to #404040) these now go colourless instead of staying green.

Left alone, deliberately: the confetti palette and the Start button in
start-confetti-button, the duck-avatar debug HUD, MetricCard's up/down
delta ramp, CommandBlock's check on its hardcoded dark block, the
react-email template, and the plain.css comment that names the anti-pattern.
Reds are untouched — that is a separate pass, so some ternaries now read
bg-success : bg-red-500 until it happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:35:01 +00:00
pastilhas a53b56bbf6 chat header: one green, and it is the token
the connection dot was `bg-green-500` and the bell followed it there to match.
both are `success` now — #16a34a where the palette value was #22c55e.

the raw palette value was the wrong thing for either to hold. tone.ts already
says why: once a view can reach for any of tailwind's twenty-two palettes,
green stops reading as "open" and starts reading as "the author liked green
here". two elements in one header meaning the same thing should say so by
sharing a token, not by two people picking the same number.
2026-08-19 23:24:49 +00:00
pastilhasandClaude Opus 5 0a9649a12b brand: the drawn Officer wordmark, unskewed
Replaces the old mark everywhere it is referenced — the header reads it through
--logo-src and every theme points at the same file, and the auth landing page and
the emailer read it directly, so one asset covers all three.

The CSS skew goes with it. That is where the old logo's slant came from, and
applying it to this one shears letterforms that were kerned pair by pair by hand
and signed off as finished. See landing/assets/brand/WORDMARK.md for the
construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:23:18 +01:00
pastilhas 9ca2287e50 bell: move it left, and let its colour say what it will do
the bell sat on the right between the connection dot and the end-session plug,
which put a SETTING in among the STATUS. everything on the right reports what
the chat is doing now; the bell says what it will do later. it moves to the
left of the header, on its own.

and it is coloured by preference, so the setting is readable without opening
the menu: green for every end of turn, amber for input-needed, muted for none.

green is `green-500` rather than the `text-success` token deliberately — it
has to be the SAME green as the connection dot a few elements away, and a
semantic token that drifted from it would leave two greens in one header
meaning two different things. amber is `text-warning` (#f59e0b), which is the
conditional setting: it will speak, but only sometimes.

`none` keeps the struck-through bell as well as the muted colour. colour alone
is not a state anyone should have to learn, and it is the one setting where
being wrong is silent.
2026-08-19 23:19:09 +00:00
pastilhas 34e21317da chat: the copy buttons sit on the code block, bottom right
they were sticky at the top, which put them over the first line — and on a
one-line block, which is most of them in a chat, over the whole thing.

absolute bottom-right inside the surface now, with `.code-surface` given the
`position: relative` it never had. that omission is why a control could end up
escaping to the nearest positioned ancestor and floating below the block it
belongs to.

`padding-bottom` on the pre to go with the existing `padding-right`: the last
line is the one they overlap now, and it needs its own room the same way the
first line needed it before.

the trade the old comment was worried about is real but smaller than it reads:
a block taller than the viewport now has its controls at the bottom rather
than the top. that is where you end up after reading it anyway, and it is a
worse place to be only for a block you never scroll — which is exactly the
block whose first line the old position was covering.
2026-08-19 23:14:56 +00:00
pastilhas bf57201a11 bun run doctor, and provisioning checks its own work
the tenth install in a row with something new wrong, and the pattern behind
them is not the individual faults. every step in provisionOsAccount is
deliberately non-fatal — a member whose account provisioned fine should not be
rolled back because their shell config failed. that is right, and it means
"nothing reported an error" is a far weaker statement than it reads as.

the acl-mask bug was the sharp version: a clean provisioning run, every named
entry correct, and an account that could reach nothing. it was found by a
person three days later, through a file browser message three steps removed
from the cause.

so provisioning now VERIFIES rather than assuming, and it verifies by making
the same readdir the file browser makes. deliberately not by inspecting the
acl: reading getfacl and reasoning about masks is exactly how this was missed,
because the acl looked right. a check that tests something adjacent to the
failure is how you get a green report and a broken server.

a failed check is reported FIRST, ahead of keys and shells — an account that
cannot read its own home is broken in a way that working ssh does not make up
for.

and `bun run doctor` for the nine servers that already exist: same check
across every account, plus the one that is invisible by construction — a
confined grant held by an account with no linux user, which sits in the table,
shows as ticked in the UI, and resolves to nothing.

read-only, so it is safe on a production box. exits 1, so it can gate the end
of a setup run. same posture as assertPermissionTotality, except this reports
instead of refusing to boot: one broken member must not stop the platform
serving everyone else.
2026-08-19 23:07:47 +00:00
pastilhas 069dd4b23f provisioning: re-assert the acl mask, last
on a fresh server an Admin granted files, chat and terminal reached none of
them. the grants were right, the linux account existed, the named acl entries
were on the home — and the file browser still raised EACCES.

a posix acl mask is not independent of the mode bits. chmod recomputes it from
the GROUP bits, so a directory closed to 700 gets `mask::---`, which clamps
every named entry to nothing. the platform's grant survived as
`user:pastilhas:rwx #effective:---`: present, correct-looking, and inert. it
reads as a working acl in every place anyone thought to check.

ensureOsUser already knows this and orders itself correctly — chmod, then
setfacl, with a comment saying why. verified that order is right by running
both ways on the affected host: chmod-then-setfacl gives mask::rwx,
setfacl-then-chmod gives mask::---.

what it cannot account for is the steps AFTER it, each of which creates a
directory and then closes it: .ssh at 700 from the key setup, .config and
.local from the claude installer. each inherits the default acl at creation
and clamps its own mask a moment later. the correlation on the live host was
exact — every 770 directory had mask::rwx and every 700 one had mask::---.

so the mask is re-asserted at the very end of provisionOsAccount, recursively,
and nothing else is touched: the named entries were never wrong, and
re-granting them here would hide it if they ever were.

non-fatal if it fails, like every other side effect in that function. but it
is the difference between an account that works and one that only appears to.

repaired on the affected server by hand first, and confirmed: home readable by
the service user, zero `effective:---` entries left under it.
2026-08-19 23:03:14 +00:00
pastilhasandClaude Opus 5 c982d82dde todo: four foreign keys churn on every push, deferred not fixed
Found while pushing chat_notification_prefs. Harmless — eight extra
statements, no prompt, no data touched — but the database CLAUDE.md told
the next person to expect only the two pk_music_now_playing lines and to
treat anything else as their own change, which is no longer true. Both
corrected; the fix itself is deliberately not done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:11:19 +00:00
358 changed files with 13404 additions and 5710 deletions
+7
View File
@@ -105,6 +105,13 @@ src/databases/officer_db/migrations/
# to the platform; the plugins do not.
/plugins/*/
# Generated by src/servers/plugins/npm-deps.ts from every plugin's `npmDependencies`, and installed into
# plugins/node_modules/. It describes whichever plugins are on THIS machine, so committing one would
# claim a dependency set nobody chose. The rule above only ignores directories, so these need naming.
/plugins/package.json
/plugins/bun.lock
/plugins/bun.lockb
# Visual baselines for the theme extraction — large, machine-specific, regenerate with scripts/theme-snapshot.ts
.snapshots/
+190
View File
@@ -0,0 +1,190 @@
# Atelier extraction — blockers and decisions made without asking
Unattended run, started 2026-08-20. Per the kickoff prompt: decisions made here without being able to
ask, in the order found. Nobody is awake; each entry says what I did and why, then the run continues.
## Map, before anything moved
Grepped the whole platform for `atelier` (case-insensitive, every `.ts`/`.tsx`/`.md`/`.json`/`.cjs`
file). Two hits, both incidental:
- `src/workspaces/officerdev/src/apps/Chat/components/chat-images.test.ts:10` — a test fixture using
`https://atelier.officer.dev/images/....png` as an example markdown image URL. Not a dependency.
- `src/servers/api/chat/task-restart.ts:181` — a comment mentioning a stray `bun dev` process that once
ran from `~/dockers/officer-atelier` and got respawned by accident. Historical, not a dependency.
**No platform code imports anything from Atelier, and Atelier imports nothing from the platform.**
Unlike music/offscale, there is no reverse dependency to find and no "this stays behind" piece. It comes
out whole, the way offscale did before the imports were found — except here there really are none.
Also checked: `ecosystem.config.cjs` (platform's own) has no `atelier` entry — the standalone service is
managed by its OWN `ecosystem.config.cjs` inside `~/officer-dev/dockers/officer-atelier/`, entirely
outside this repository.
## The standalone service stays running, untouched
`officer-atelier` (PM2, port 3002) is a **live production service** — its own `ecosystem.config.cjs` says
plainly "this is the instance the mobile app talks to, so it holds still." It serves
`https://atelier.officer.dev` via nginx and holds real data (87 generations, 2 collections, 5 providers,
1 provider_settings row, checked directly against Postgres).
I am not stopping it, not touching its nginx config, not touching its PM2 entry, and not touching the
mobile app. This extraction adds a new, additive plugin inside the platform; cutting the mobile app over
to it (or deciding whether to) is the owner's call, not mine to make unattended. Recorded here so nobody
reads the plugin's existence as "the old service is now redundant, kill it" — it is not redundant until
someone repoints the mobile app.
## Decision 3 — database rows checked, both non-empty → database stays separate
Queried the live `atelier` Postgres database directly (via a throwaway `postgres` client, same
credentials as `.env.production.local`):
```
collections 2
collection_items 73
provider_settings 1
providers 5
generations 87
```
`collections` (2) and `provider_settings` (1) are the two tables the brief named — both non-empty. Per
decision 3: **the `atelier` database stays separate**, not folded into `officer_db`. No
`plugins/atelier/db/schema.ts` (that convention is for `officer_db`-prefixed tables); the sidecar keeps
its own Postgres connection to the `atelier` database, exactly as today, just running from inside the
plugin's `sidecar/` directory instead of a standalone repo.
**The brief undersold the schema.** It says "tables: collections, provider_settings" — the real schema
has five: `generations` (the actual data — 87 rows, prompts/images/costs), `collection_items` (the join
table), `providers` (configured adapter+model+key rows — 5 of them; a newer, richer replacement for
`provider_settings` that the brief doesn't mention at all), plus the two named. `generations` alone
being non-empty would have settled decision 3 on its own. Following the letter of decision 3 (check the
two named tables) rather than re-deriving it from the fuller schema, since both answers point the same
way here.
**Migration to `officer_db` is a real follow-up**, not done here: fold `collections`, `collection_items`,
`providers`, `provider_settings`, `generations` into `officer_db` as `atelier_*`-prefixed tables once
someone is awake to watch a real data migration. Until then the plugin's sidecar owns a second Postgres
connection, to a second database, which is new territory for this plugin system (every other plugin's
`db/` is officer_db-prefixed tables) — documented in PLUGIN.md.
## Postgres connection for the separate database
The sidecar needs its own connection string. Added `ATELIER_POSTGRES_URL` as a new platform `.env`
variable (same pattern as `OFFICERDEV_POSTGRES_URL` for officer's own database) rather than routing it
through the secret store — a database URL carrying a password is handled as a plain `.env` value
everywhere else in this codebase (`OFFICERDEV_POSTGRES_URL` itself), and the secret store's own docs
scope it to "every encryption and signing key the platform uses" plus foreign bearer credentials
(APNs, FCM, webhooks) — not connection strings. Kept consistent with the existing convention rather than
inventing a new one.
**Not set automatically.** I don't have a safe way to guess whether the owner wants the plugin sidecar
to point at the _existing_ `atelier` database (sharing state with the still-running standalone service —
two writers to one queue table, which `generations.status` and the `claimableGenerations`/`markRunning`
claim logic were written assuming is single-process) or a fresh one. Left unset, with a clear startup
error, and documented in PLUGIN.md. **Do not just paste the standalone service's `POSTGRES_URL` in and
restart both** — two processes racing the same `generations` queue is exactly the hazard `markRunning`'s
atomic claim exists to prevent for restarts of _one_ process, not for two live ones.
## Decision 2 — Providers as an Officer settings screen, against a direct precedent
Found `IntegrationsSettings/index.tsx`'s own comment, written when email was extracted on 2026-08-18:
> "Email account management left with the feature (`plugins/email/`). A plugin contributes panels, not
> settings sections, so it now lives inside the email workspace itself."
That is the opposite of decision 2, written five days before this run, in the exact settings screen
decision 2 points Atelier's Providers into. Per the brief's own instructions ("if you find evidence one
is wrong, write it down, follow the decision anyway"): implemented Providers as a genuine platform
settings section — `IntegrationsSettings` gained an "Atelier" entry under the Enterprise tab — as new
**platform** code (`src/apps/officer-web/...`) that talks to `/api/atelier/providers` over HTTP via
`useClient`, same as any other authenticated screen. It does not import anything from
`plugins/atelier/`, so it does not violate "a plugin importing platform code is fine; the reverse is
not" — there is no import in either direction, only an HTTP call, which is what every frontend/backend
pair in this codebase already is.
Recording the contradiction here rather than silently picking the email precedent, since decision 2 was
explicit and pre-made.
## Images served through an authenticated route, not the old `PUBLIC_URL` scheme
The standalone service served `/images/*` unauthenticated, off Bun's top-level route table, with
`PUBLIC_URL` baked into every response so the URL was durable outside the process. Per "What you may NOT
do", that is gone: images are served from `GET /api/atelier/files/:filename`, mounted like every other
route, behind the permission gate. Named `files` rather than `images` so it cannot sit one path segment
away from `/api/atelier/images/generations/:id` — the JSON job API already owns `/images`.
The frontend needs `<img src>` to carry a credential a browser cannot put in an `Authorization` header.
Used the platform's existing `?token=` escape hatch — already read by both
`permission-gate.ts:37` and `user-middleware.ts:22`, already used by `FileViewer/file-types.ts` for
exactly this (`getRawUrl`, `getAudioTracksUrl`, ...) for the file browser's raw/audio/subtitle routes.
Not a new mechanism, not an unauthenticated route: the same query-token path every other authenticated
media URL in this app already takes.
## `sharp` added as a direct dependency
Atelier's `inspect.ts` uses `sharp` to measure width/height/alpha-channel/opacity of a written image —
load-bearing (it is how a `background: transparent` request that silently produced an opaque image gets
caught). `sharp@0.33.5` was already resolved in `bun.lock` as an optional transitive dependency of
`next`, so promoting it to a direct dependency of the platform added no new resolution — verified the
lockfile diff is exactly that promotion, nothing else moved. Done via the documented dance:
`frozenLockfile` commented out in `bunfig.toml` for one `bun install`, restored immediately after,
diff reviewed before committing both files.
## Publishing to the Marketplace — not done, left as the one open step
`plugins/atelier/` is its own local git repository (`git init`, per "Done means"), but I did not create
`gitea.officer.dev/plugins/atelier` or push to it. I found no Gitea API token or credential available to
this session, and creating a new repository on a shared Gitea instance and registering it in the
marketplace catalogue (`scripts/marketplace-sync.ts`, `BUNDLED` in `src/servers/plugins/marketplace.ts`)
is exactly the kind of action with effects beyond this local checkout that the run's own ground rules
say to flag rather than take unilaterally, especially with no verified way to confirm it succeeded.
Left for the owner:
```
cd plugins/atelier
git remote add origin git@gitea.officer.dev:plugins/atelier.git # after creating the repo on Gitea
git push -u origin main
cd ../..
bun --env-file=.env scripts/marketplace-sync.ts
```
Then add a `BUNDLED` entry in `src/servers/plugins/marketplace.ts` (offline fallback), matching the
other three plugins' entries.
Until that happens, the plugin still installs fine from a checkout that already has the directory
(`plugins/atelier/` committed to its own local repo, present on disk) — it just cannot be discovered by
the Marketplace screen on a machine that has never had it, per "Done means" #2. This is the one piece of
"done" I could not complete unattended.
## No `plugins/atelier/db/`, on purpose
"Done means" lists `db/` among the plugin's expected pieces, matching offscale's and music's shape. This
plugin has no `db/schema.ts` at all — `discoverPlugins()` correctly reports `schema: null` for it. That
convention is specifically for `officer_db`-prefixed tables pushed by `pushPluginSchema`; decision 3
keeps Atelier's tables in their own separate Postgres database instead, so there is nothing for that
convention to describe. The database code lives inside `sidecar/db/` as an internal implementation
detail of the sidecar process, not as a platform-recognised `db/` directory. Documented in full in
`plugins/atelier/PLUGIN.md`, under "The database".
## What I verified, and what I deliberately did not
- `bunx tsgo`: clean throughout.
- `bun test`: baseline 1041 pass / 0 fail / 90 files → after this run, 1069 pass / 0 fail / 92 files.
The two new files are the ported `serialize.test.ts` and `generations/response.test.ts`. No
regressions, file count only grew.
- `discoverPlugins()` finds the plugin and the manifest validates with no problems.
- The platform-side proxy router mounts and correctly 503s with no sidecar running.
- The sidecar's own Hono app was exercised directly against the real `atelier_dev` Postgres database
(not `officer_db`, not the production `atelier` database) with real HTTP requests and no port bound:
providers, providers/adapters, collections and images all answered correctly; a missing file 404s; an
unknown route gets the OpenAI-shaped error envelope; provider rows whose keys were encrypted under the
standalone service's own key correctly fail to decrypt rather than crashing (a fresh `secret.key` was
generated for this checkout at `$OFFICER_ROOT/data/atelier/secret.key`, which is expected and correct
— see PLUGIN.md).
- **Deliberately not done**: installing the plugin through the live, running `officer` PM2 process. That
process serves the owner's real session, its `plugins/` directory is the main checkout (not this
worktree, so it would not even find this code), and going through its install flow would write a real
`plugin_installs` row into the shared production `officer` Postgres database from a session with no way
to confirm the result against a live browser. Full PM2 install/enable/disable/uninstall/purge cycle,
per the runbook's verification table, is left for the owner — noted as open item 5 in PLUGIN.md.
+18
View File
@@ -421,6 +421,9 @@ explaining why it was safe.
- `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
@@ -435,6 +438,21 @@ explaining why it was safe.
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/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
+67 -706
View File
@@ -1,713 +1,74 @@
# TODO
# TODO — the index
Deferred work.
Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here.
Format and the two-way-sync contract: `todo/README.md`.
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission permission model
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
below that proposed deleting it have been removed rather than left to mislead the next reader.
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
## Multi-user
- [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in
`os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards
(`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still
unproven" has the five-step validation, and it has to happen on the production host with a throwaway
account that has **a shell left open** and **a container writing as a non-root user** — those are the
two cases the quiet path passes vacuously.
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
`appendBuffer`, so a replay reproduces output and never re-issues requests.
- [ ] **The web terminal renders a long URL as unreadable fragments.** Claude Code's first-run login prints
a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing
selectable. Half worked around by `2a8f004` (OSC 52, so "press c to copy" reaches the clipboard) — the
rendering itself is undiagnosed. This is every new member's first five minutes.
`docs/open-threads-after-per-user-claude.md` §1 has what is known and where to start.
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
missing property. `docs/open-threads-after-per-user-claude.md` §2.
- [ ] **`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained.** A
floating rejection inside the SDK's own input pump, with no frames from our code, so no `await` of ours
can catch it. It crashed `officer-agent` four times on 2026-08-11, once truncating a turn mid-sentence;
the `unhandledRejection` backstop has caught it once since. Best hypothesis is the `claude` CLI exiting
while `streamInput` is still pumping. It needs looking at after the next occurrence, not catching in the
act — markers to grep in `docs/open-threads-after-per-user-claude.md` §3.
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
Settings → User management. Created accounts are `status: 'Active'` — the column defaults to
`'Unverified'` and `signin.ts` refuses anything else with a bare UNAUTHORIZED, which is the trap
the hand-INSERT route fell into. The owner sets the password and reads it out; `passwordChangedAt`
stays null. Directories come from the shared `provisionUserDirs`/`USER_DIRS` in `data-path.ts`,
which `scripts/provision-user-dirs.ts` now imports rather than restating.
- [ ] **Still no invite flow, and no password reset for a member.** The owner types the password and
tells the person, which means the owner knows it and the member cannot change it back if they
forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets
their own) needs a mail path. This is the next piece, not a nice-to-have.
- [x] **A second Super Admin was storable, and made the owner nondeterministic.** Fixed 2026-08-11.
`ck_users_owner_is_super_admin` pins user 1's role but a row-level CHECK cannot see other rows, and
`updateUserRoleHandler` happily promoted anyone — while `getOwnerUser()` was
`WHERE role='Super Admin' LIMIT 1` with no ORDER BY. Two holders would have made "who owns this
server" a question the query plan answered, and that answer feeds the agent sidecar's identity,
vault access and origin scoping. Both write paths now refuse the role, the list endpoint offers
`assignableRoles` without it, and `getOwnerUser()` orders by id.
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
the table is a composite PK `(user_id, id)` — it matches the `uq_dashboards_user_id` index
already there and keeps every stored `ws-layout-<id>` address valid, which uuid ids would not.
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
`docs/workspace-panel-todo.md` §3.
- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
registry functions and the totality check; the resolver that does the owner bypass, the grant
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell.
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal.
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
every caller resolves to the owner's real login home. Safe only because all seven callers sit
behind `execution` permissions. If per-user home confinement is ever attempted, this is the
function to start from.
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
thing to fix first if any of them is ever granted.
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
sidecar itself is fully per-user and confines every JSON read to `/dav/<userId>/`. The platform
side is ready for members; the CalDAV server underneath is not.
- [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves
every account from it. Favourites, playlists and now-playing _are_ per-user. Deliberate for now
(one household, one library) but worth stating rather than discovering.
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
single global lane; ownership is enforced one layer up, in `pipeline-jobs-routes.ts`, by an
explicit `job.userId !== user.id → 404` on every by-id route. Correct today, but the queue itself
has no notion of whose work it is running.
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
`notify` permission could deregister another's device; and `POST /_officer/notify` let a request
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
- [ ] **Remove dead `username` plumbing.** `send-claude-code.ts` declares `username` in two types
without using it. (`toShellUsername` is NOT dead — `server.tsx:190` and
`pipeline-job-manager.ts:267` both call it. The `provision.ts` caller this item used to name no
longer exists.)
## Email
Email became a plugin on 2026-08-18 (`plugins/email/`, `gitea.officer.dev/plugins/email`). Its work queue
went with it — the open items pointed at `sidecar/email/store.ts`, a path in another repository, which is
exactly the way a leftover list sends someone to edit files that are not there.
Anything still owed to the mail client belongs in that repository's own TODO.
## Transmission
Phase 1 (parity with `_references/transmission-web`) shipped 2026-07-30: the `officer-transmission`
sidecar plus `/transmission` (torrents / stats / settings). Everything below is **phase 2** — none of
it exists in the reference app, so none of it was in scope for parity.
### Probably needed regardless
- [ ] **Resizable detail pane.** `TorrentsView` pins it at `DETAIL_HEIGHT = '45%'`, which is a guess.
Either a drag handle or a persisted height in `useLocalStorageState`, alongside the column set.
- [ ] **Revisit `DEFAULT_VISIBLE_COLUMNS`.** Eleven of thirty, chosen before ever seeing the table
rendered against the real 43-torrent library. Likely wrong in both directions.
- [ ] **Files tab on huge torrents.** `detail/FilesTab.tsx` builds the whole tree and renders every
node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix is
`useVirtualizer` over a flattened visible-node list, the same shape as `TorrentTable`.
### Ideas, unprioritised
- [ ] **Container→host path mapping.** The daemon reports its own namespace (`/downloads/complete`),
which is not a path on this host. A mapping table (`/downloads` → `~/hdds/08_TB_01/Torrents`,
`/downloadsTV` → `14_TB_02/Torrents`) would make locations clickable through to `/files` and
make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference
app has no such feature, so it would have been config nothing read.
- [ ] **Push instead of poll.** Transmission RPC has no push channel, so `useTransmissionData` polls
every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and
what every other live surface in the platform already does.
- [ ] **Cross-surface links.** Soulseek and `download-media` both land files in the same library;
Transmission is the third door to it. Worth a think about whether they should know about each
other at all.
## Headscale
Gaps against headscale's own API, found while reading the app on 2026-08-05 — all closed the same
day. Kept here for what the policy work turned up, which is not obvious from the code.
- [x] **ACL policy.** `/api/v1/policy` GET/PUT behind `/_officer/policy`, with a plain HuJSON textarea
that sends the text byte for byte and shows headscale's verdict verbatim (line and column
included). Officer does not pre-validate: headscale owns the only parser that resolves groups,
tags and hosts, and a second weaker one would disagree with the thing that actually enforces.
**The mode cannot be read.** A file-backed policy is still served over GET; only a PUT refuses,
with "update is disabled for modes other than 'database'". So the first save is what discovers
writability, and a refusal becomes a persistent read-only banner. Verified live, not inferred.
- [x] **A node can be moved between users.** `/api/v1/node/{id}/user`, in the expanded node card next
to the tag editor — both being the things that decide which policy rules apply to a node.
- [x] **User rename** — was already shipped end to end (`/users/:id/rename`, `UsersView`); this entry
was stale when it was written.
- [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
- [x] **Device invites, platform side** (`COMMS/OFFSCALE_INVITE_ENROLLMENT.md` §5, built 2026-08-05).
Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke —
`/_officer/enroll/invites` in `invites.ts`, `InvitesView`. **The records are not Officer's.** They
proxy to the server's Officer Companion, because the joining phone has to claim without an Officer
account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when
the platform is down" argument says the same. Officer stores no invite and no claim token.
**Live on all four servers since 2026-08-05.** The 401 that blocked it was a companion-side prefix
parse (a Headscale key prefix is a fixed 12 chars and may contain `-`; they split on the first one);
fixed upstream. Verified end to end against pastilhas-eu: create returns the
`officer-offscale://join#…` link, list shows the record, revoke flips it to `revoked`. The list
envelope is not in the spec, so `pickInvites` takes the body's array whatever it is keyed under.
Four gaps were sent back to the spec author: `keys.ts` has no
sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable
behind nginx, `tailnet` is not a headscale concept and has to be recorded on the invite, and
`/api/v1/enroll/*` collides with headscale's own namespace.
## Workspaces & Panels
Its own list — **`docs/workspace-panel-todo.md`** — because it is long and actively worked. Full
analysis behind it: `COMMS/workspace-panel-framework-analysis-2026-08-07.md`.
Headlines: the framework has **no panel lifecycle** (a panel can be destroyed but never told, which is
the real cause of the terminal orphan leak); three state-key families are **silently dropped on every
write**, orphaning a pty per reload; Running Shells has **404'd since 2026-07-31**; there are **zero
error boundaries** anywhere in the repo; and `dashboards.id` is a **global** primary key fed by
`slugify(name)`, so two members naming a dashboard the same thing collide.
- [ ] **Renaming a panel key is a silent data migration, and the repair is destructive.** A saved layout
holds `appType` strings. `normalizeLayout` replaces any appType not in the allow-list with the
caller's `fallback`, and `WorkspaceView.tsx:97` writes that back — so a renamed key turns every
saved layout into N copies of whichever panel is `allowed[0]`, permanently, on one page load.
Renaming `headscale-*` → `offscale-*` did exactly this: `/offscale` rendered three server pickers,
`ServersView` never mounted, and it survived every restart because the code was correct and the
data was not (2026-08-17, `ed3806f9`).
Plugin screens are now cleared at install, which covers plugins and nothing else. The general fix
is a rule about wholesale mismatch: **a layout in which NO panel has a recognised appType is
residue from older code — discard it and use the screen's default**, rather than collapsing it.
`fallback: allowed[0]` is arbitrary (it is whatever `panels.ts` happens to list first) and should
probably not be a per-panel substitution at all.
## Code editor
- [ ] **The owner wants to go deeper here — ideas pending (noted 2026-08-15).** Raised right after the file
browser gained its first links into `/code-editor`. Do this after the file-browser test pass.
What is worth knowing before starting, all established on 2026-08-15:
**It has been effectively invisible for six months.** The app landed 2026-02-21 (`9e9acd96`), the
screen 2026-02-17, and `1fa3a659` on 2026-07-25 is titled "**restore** the code editor screen" — so
it was dropped and brought back at least once. It IS in `CORE_DOCK_ITEMS` as "Editor", but
`DEFAULT_DOCK_PATHS` is `['/', '/files', '/terminal', '/dashboards', '/chat']` and the owner's own
`dock_configs` row does not list it either. Until today the only way in was typing the URL.
**So it has no users, and that is the risk.** A surface nobody reached for six months is where things
rot quietly. Nothing about the editor itself has been verified — whether saving works, how the file
tree behaves, what closing the last tab does, what an unsaved-changes navigation does. Only the
plumbing was checked: `?file=` is read (`EDITOR_FILE_PARAM`), `/code-editor` renders with `urlState`,
and `CodeEditor.tsx:60-76` fetches a path that arrives in the URL rather than only matching already
open tabs.
**One bug of exactly this kind was already found and fixed** in the FileViewer's editor on the same
day: the Edit toggle was gated on `content` rather than `content !== null`, so an empty file — the
one "New file" produces — could not be edited at all. Assume siblings.
**Open question, deliberately not decided:** `Open in editor` in the file browser's row menu still
navigates to `/code-editor`, which is the last action that leaves the browser. Editing now happens
in place via double-click → the viewer's pane. Either `/code-editor` earns its keep as a genuinely
different tool (tree, tabs, multi-file) or that menu item should go.
## Files app — routing
**The URL model is done (2026-08-15).** The folder is the pathname —
`/files/archive/Tests/platform/docs` — and `?view=` is a NAME within it, not a second copy of the path.
Deep links and refresh work, which they never had. Landed across `559560de`, `98ba604a`, `7e31564c`,
`0dc88b51`, `7359af2f`, with `files-route.ts` + 23 tests as the one owner of encoding and of which params
belong to the overlay.
Four bugs came out of that work and are fixed: closing a pane sent you home; `?view=` was stripped on
every page load by a mount effect that had made deep links impossible since 2026-02-23; navigating kept a
pane belonging to the folder you left; and the breadcrumb opted out of that last rule because it is a
`<Link>` and never called the function enforcing it.
- [ ] **Rows are still not links.** This is the half that did not get done, and it is the original
finding. Both list and grid render `<div onDoubleClick>` (`FileItem.tsx:701,748`), and search
results render `<div onClick>` (`FileViewContainer.tsx:187`) — the "opaque click" that
`docs/navigation-audit.md` names. No cmd-click into a new tab, no middle-click, not
link-focusable, and the target lives in a closure rather than the DOM.
It is now much easier than it was: `folderHref(path)` gives a folder's URL and `hrefForPath` gives
a full `To` with the overlay already stripped, so a row becomes
`<Link to={hrefForPath(childPath)}>` and a file row becomes a link that sets `?view=<name>`. The
breadcrumb has done exactly this since before today and is the model.
Watch for: a row is also a click target for SELECTION (single click selects, double opens,
shift/cmd extend). An anchor changes what those gestures mean by default, so the selection
handlers have to keep working and `preventDefault` where they win.
- [ ] **The acts that clear the overlay have no owner, only the list does.** `VIEWER_PARAMS` and
`withoutViewerParams` live in `files-route.ts` and are shared. But every site still decides FOR
ITSELF whether to call them, which is precisely how the breadcrumb shipped wrong an hour after the
rule was written. Three bugs in a row came from this. A single `navigateToFolder()` that every
caller must go through — rather than a helper they may remember — is the fix if a fourth appears.
- [ ] **A panel's folder is still not addressable.** `urlPath` is true only on the `/files` screen;
panels keep the path in local state, so they always open at their base and cannot be linked or
restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both —
but worth re-deciding rather than inheriting.
## Release-binary dependencies (2026-08-19)
Built: a plugin can declare `release: { url, identify }` for a dependency no package manager carries.
Gitea's `tea` is the motivating case and the first user.
Two things it does NOT do, both deliberate and both worth knowing:
- **No checksum.** The download is verified by RUNNING it through the same `identify` rule the probe
uses, which catches an HTML error page, a truncated file and a wrong-architecture build — none of them
print what the plugin said to look for. It does not catch a compromised upstream. A checksum per
os/arch is a table that goes stale every release and would stop installs the day upstream re-cuts a
tag; HTTPS and a URL the owner reads in the install dialog is what stands behind it today.
- **The version is pinned in the manifest.** `tea` is fixed at 0.15.1, so a new release needs a plugin
update. That is the right default — an install that silently fetches whatever is latest is not
reproducible — but there is no "check for a newer one" anywhere.
## Inputs outside forms (2026-08-19) — swept, fixed, closed
The "19 files" count in the previous version of this entry came from one grep
(`components/ui/input`) and was a starting point, not the answer. A wider sweep — bare `<input>` and
`<textarea>` too, across the platform and every installed plugin — found **87 files with a matching
element and no enclosing `<form>`**. Three were false positives (the `Input`/`Textarea` primitives
themselves, and a `DataTable` test file). Of the remaining 84, **41 were fixed** (real forms, single
fields with an obvious action, or multi-field editors) and **43 were left alone**, almost all because
they're a search/filter box with nothing to submit.
**Fixed — platform (24 files, one commit):** `CapabilityPage`, `ApifyConfig`, `ApiKeys`,
`DavAppPasswords`, `GoogleOAuthConfig`, `AIHarnessesSection` (local-provider connect, its auth step,
and both API-key edit rows), `OCRSection`, `SMTPSection` (split into two independent forms — save vs.
send-test-email, since one form would have made Enter in the test-email field trigger Save),
`STTSection`, `TTSSection`, `AppStoreDetail`, `ChatHeader`, `BackgroundJobDialog`, `QuestionActivity`,
`WebpageDialog`, `DirPickerModal`, `SessionRow`, `CommandHqCategories`, `FileItem`, `NewEntryDialog`,
`TaskRunnerModal` (the Agentic and Pipeline ready phases), `PreviewHeader`, `widgets/DailyGoals`,
`widgets/Pomodoro`.
**Fixed — plugins (17 files, one commit per plugin, each pushed to its own remote):**
`bitcoin-wallet` (`ChainSourceCard`, `RescanCard`, `WalletSettingsView`), `email` (`Compose`,
`EmailAccounts`), `invoices` (`DocumentEditor`, `RecordEditors`, `SendDocumentDialog`), `offscale`
(`NodesView` — rename and tags fields), `soulseek` (`SoulseekConnection`), `transmission`
(`ConnectionView`, `SettingsView`, `AddTorrentDialog`, `MoveTorrentDialog`, `RenameDialog`,
`TorrentOptionsDialog`), `vault` (`VaultPanel`). `jellyfin` and `music` needed no changes.
**Left alone — search/filter boxes with no submit action** (the large majority of the 43): `JobsPage`,
`SettingsPanel`, `SearchInput`, `sounds/Catalog`, `widgets/QuickNotes`, `Dashboards/DashboardListApp`,
`SystemMonitor/DockerView`, `FileBrowser*` toolbar/header/view-container files, `ChatHistory` list
filters, `invoices`' six list views (`CustomersListView`, `EstimatesListView`, `ExpensesListView`,
`InvoicesListView`, `ItemsListView`, `ReportsView`, `components.tsx`), `jellyfin` (`PlayerControls` —
range sliders, not text fields — and `SearchSection`), `music` (`MusicBrowser`, `MusicPlayerHost`),
`offscale` (`Cards`, `PolicyAssistant` — already handles Enter/Shift+Enter correctly, `PolicyView` — a
textarea, never auto-submits), `soulseek` (`SearchResults`, `SharesBrowser`), `transmission`
(`TorrentToolbar`), `FileViewer` renderers (`AudioRenderer`/`VideoRenderer` — volume/seek sliders), and
the main chat composers (`InputArea`, `ChatLauncher`, `SoulseekChat`) — all textareas with their own
Enter-sends/Shift+Enter-newlines handling, where a `<form>` would add nothing and risks regressing that
handling.
**Left alone — genuinely ambiguous, flagged rather than guessed:**
- `TaskRunnerModal`'s `ScriptRunner` ready phase offers three simultaneously-visible, non-equivalent
primary actions ("Run here" / "Queue" / "Run as job"); there's no single canonical action for Enter to
trigger.
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
`<form>` can't span two panels without restructuring the wizard.
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a
plain controlled textarea with no submit of its own), so there's nothing to wrap yet.
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
obvious Enter target (which of N matches?).
**Noticed, out of scope, not fixed:**
- `ModelSelector`'s provider-picker button has no `type` attribute — harmless today only because it
currently sits outside every `<form>`; a landmine if that layout changes.
- `MusicBrowser`'s filter input uses `(e) =>` instead of the house `ev` convention — pre-existing, not
touched since the file needed no fix.
- `EmailList.tsx`, and a few plugin files touched by this sweep (`VaultPanel`, `ConnectionView`,
`SoulseekConnection`, `ChainSourceCard`), have pre-existing `prettier --check` drift outside the lines
this pass touched — left as found, not reformatted, per "don't reformat what you're not fixing."
Relatedly: **`prettier --write` on a `plugins/<name>/…` path silently no-ops when run from the
platform root**, because Prettier v3 respects the root `.gitignore` and `/plugins/*/` is in it — it
exits 0 with no diff and no error. Formatting a plugin file requires running prettier from inside
that plugin's own directory (still resolves the platform's `.prettierrc` by walking up).
- A few "Save"/"I've saved it"-style buttons (`ApiKeys`, `DavAppPasswords`, `DirPickerModal`) have no
`type` attribute but currently sit outside any `<form>`, so it's harmless today — worth remembering if
a future edit wraps that region.
## Affordances (2026-08-19)
Agreed and written down in `plugins/CLAUDE.md`; **nothing is built**. An affordance is what the surface
on the other end offers the agent — declared by the code that implements it, read at runtime. The first
two are image rendering (core) and gitea (`git` for git, `tea` for everything else).
Built 2026-08-19: `servers/affordances/load.ts` reads the tree and compiles one block, and
`claude-manager.ts` appends it to every Claude session via
`systemPrompt: { type: 'preset', preset: 'claude_code', append }`. Read per session, so a plugin
installed an hour ago is live on the next message.
Still to build:
- `capabilities/affordances/` created by the setup script, with `officer/core/` populated from an
officer-capabilities repository. The directory exists on this machine by hand; a fresh install gets
nothing, so the first affordance is currently a local artefact rather than a shipped one.
- **Opencode delivery.** It has its own driver and gets nothing, so image rendering is currently a claim
that holds on one harness — exactly the drift affordances exist to remove. Deliberate: opencode work is
paused pending its v2 migration and this would be thrown away. Revisit when v2 lands.
- A bare `claude` run in a terminal on the host gets nothing either, since it never passes through
`claude-manager`. Would need a generated file, which is the thing `user-instance.ts` refuses to write
for the owner.
- ~~Plugin contribution~~ — built 2026-08-19 (`affordances/publish.ts`). An `affordances/` directory in
the plugin repository is copied to `capabilities/affordances/<publisher>/<plugin>/` on install and
removed on uninstall. Gitea is the first to ship one.
- **The other categories.** A plugin can contribute affordances and nothing else. Skills, tools, tasks
and processes are the obvious next ones and were deliberately left out — one mechanism, proven, before
five. When they land, `publishAffordances` generalises rather than gaining siblings.
- **`officer/core` and `officerdev/<plugin>` are both correct — do not normalise them.** The path uses
`publisher` verbatim, so a plugin published by officerdev lands under `officerdev/`. `officer/core` is
not a plugin at all: it is the PLATFORM's own, shipped by the setup script from the capabilities
repository. Two spellings marking two different things, arrived at by accident and kept on purpose.
Anyone tidying this into one name is removing the distinction, not the inconsistency.
Open, and deliberately deferred:
- **Scoping.** Everything is loaded into every conversation. Fine at two files, not at twenty. A
`surface` field was drafted and dropped because it was doing two jobs at once: for image rendering it
means _what the client renders_ (a phone, a web bubble, a cron run with nobody watching), for gitea
_what tooling exists on this machine_. Those are different axes and one field cannot be both. Decide
the axis before adding the field — and note this codebase has already been bitten by a field that
validated itself and was never consumed (`unauthenticatedPaths`, see `plugins/public-paths.ts`).
- **Drift.** The rule is "declared where implemented", but a hand-authored markdown file in a repository
is not that — `ChatImage` could stop rendering images and no file would notice. Accepted for now
because prose that reaches every harness beats a registry that reaches one; revisit if it bites.
## Plugin ecosystem — before it opens to third parties (2026-08-18)
Not now. Months away. Recorded so it is not re-derived.
- [ ] **A plugin install is not pinned, so a review would bind a repository rather than code.**
`fetch.ts:70` is `git clone --depth 1 <source>` — no ref — so it takes whatever the default branch
points at when the install runs. A plugin reviewed at commit X installs at whatever was pushed
since, and `--depth 1` leaves no history to compare against.
This is the shape of the npm attack already documented in `CLAUDE.md` (2026-08-04), and the same
shape as the Chrome Web Store's real failures — not bad extensions passing review, but good ones
sold or compromised and then pushing an update to users who had already accepted them.
Fix: a `reviewedCommit` on the catalogue entry, checkout detached at that SHA, and **verify
`rev-parse HEAD` matches or refuse**. The verification is the part that matters. An update then
becomes "a new reviewed SHA exists" — a catalogue change rather than something the author triggers.
- [ ] **Third-party plugin sidecars are unconfined.** A plugin sidecar runs `bun run <script>` with
`cwd: PLATFORM_DIR`, so Bun auto-loads `.env` and it gets `POSTGRES_URL`; it runs as the service
user, which owns `secrets/officer-keys.db` and can therefore mint an owner JWT. Fine for
first-party plugins, total compromise for anyone else's.
The mechanism to fix it already exists and is pointed elsewhere: `spawn-as-member.ts` drops
privileges with `setpriv --reset-env` and a six-name allowlist for exactly this reason.
- [ ] **No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the
"build your own, give it to a friend" path has no affordance. Small, and needed before the
unofficial channel is real.
## Plugin system — gaps the email extraction found (2026-08-18)
- [ ] **A plugin cannot declare its own npm dependencies, so the PLATFORM is carrying them.** `imapflow`,
`mailparser` and `@types/mailparser` are in the platform's `package.json` and are imported by nothing
in `src/` — they exist solely for `plugins/email/sidecar/`. Plugins have no `package.json`, run with
`cwd: PLATFORM_DIR`, and resolve from the platform's `node_modules`, so deleting those three lines
breaks the installed plugin on its first sync.
This does not scale and it is the wrong party holding the risk: the platform cannot be answerable for
what an arbitrary plugin decides to install, and `bunfig.toml` sets `frozenLockfile = true` precisely
so a dependency change is a deliberate, reviewed act. Publishing will be reviewed rather than a
free-for-all, which narrows the threat but does not move the responsibility.
What it probably needs: a `dependencies` field in the manifest (the manifest already holds
`osDependencies` for host binaries, which is the same shape of problem solved once), installed into
the plugin's own directory at install time rather than merged into the platform's tree. Note the
install path already learned not to trust `$PATH` — `pushPluginSchema` resolves a binary rather than
spawning `bunx` — so whatever runs the install has to do the same.
Until then: `imapflow` and `mailparser` STAY. Removing them is not tidying, it is breaking email.
- [ ] **`email_accounts/` is created by the PLATFORM for every account, and should be the plugin's job.**
`USER_DIRS` in `data-path.ts` pre-creates it at 0700 for every user, whether or not the email plugin
is installed — so a core install provisions a directory named for a plugin it does not have.
Ownership should invert: the plugin provisions its own storage, in all three moments —
1. **on plugin install**, for accounts that already exist;
2. **on user provisioning**, for accounts created afterwards;
3. **lazily**, the first time a provisioned user opens the plugin, so a machine that missed either of
the above still heals itself.
The third is the one that makes the other two non-critical, and it belongs in the email sidecar
rather than in the platform's account handler.
This is entangled with the `email_db` leak below — core reads that directory today, so the plugin
cannot become its sole owner until that is resolved too. Do them together.
- [ ] **The platform still knows email's on-disk vocabulary, deliberately, and it is untested by anything.**
`findEmailAccountName` in `data-path.ts` reads `DATA_PATH/<user>/email_accounts/*/emails.db` and picks
the newest by mtime. Two core callers use it: `user-instance.ts` (`OFFICER_EMAIL_DB`, for the agent's
`email_db` MCP tool) and `chat/websocket.ts` (`resolveEmailCwd`, for a chat opened against a mailbox).
`ChatPanelWrapper.tsx` hardcodes `workspace.id === 'email'` to trigger the second.
This traded a CODE dependency for a CONVENTION, which was the right trade at the time — the code
dependency is what put `officer-agent` into a restart loop on 2026-08-13 (`fbb6d6c7`). But the
convention has no enforcement on either side: if the plugin ever renames `emails.db`, moves to one
database per folder, or nests differently, core silently points the agent at a path that does not
exist. No import breaks, no type fails, no test covers it. The tool just goes quiet.
The clean version needs two things the platform does not have: a way for a plugin to ship an MCP tool,
and a way for a plugin to declare a chat context. Until both exist this stays — but it should be a
decision someone re-takes, not one that is inherited.
## Plugin repos — what writing their READMEs turned up
Three READMEs were written on 2026-08-16 (`music`, `offscale`, `example`) and pushed to their own repos.
Writing them meant reading each plugin against its own documentation, which is the cheapest audit there
is. Everything below was found that way and none of it is fixed.
**Offscale is still branded headscale in shipped, user-visible places.**
- [ ] `web/panels.ts` registers three panels named "Headscale servers", "Headscale", "Headscale" — these
are what a user reads in the panel picker.
- [ ] `web/OffscaleServerPicker.tsx:26` renders the literal heading `Headscale`.
- [ ] `web/ConsoleView.tsx:22` mints terminal session ids as `headscale-console-<id>`. That is persisted
state rather than a comment, so renaming it orphans live console sessions — cheap now, less so later.
- [ ] ~12 stale `/headscale` route paths in comments across `web/`, plus `App.tsx:78` in the platform.
- [ ] `PLUGIN.md` has ~11 stale self-references (`headscale_servers`, `listHeadscaleServers`, permission
key `headscale`, `/api/headscale`, panel keys, `useHeadscaleSection()`).
**Documentation that actively lies.**
- [ ] `plugins/offscale/PLUGIN.md:445-449` says the ACL assistant is deliberately unwired and must not be
wired up. It IS wired — `sidecar/assist.ts`, `claude-proxy.ts`, `web/PolicyAssistant.tsx` and the
manifest's `readOnlyWrites` all ship.
- [ ] `plugins/offscale/OFFSCALE_API.md` claims to publish the contract "in full" and omits six live
surfaces, including all of `/_officer/companion/*` and `/_officer/enroll/invites`.
- [ ] `plugins/music/MUSIC_API.md` says favourites and now-playing are served by the platform from
Postgres; the sidecar serves them itself, scoped by `X-Officer-User`. It also documents the playlist
item key as `<albumRel>/<file>` while the schema and favourites use the home path — the same file
contradicts itself.
- [ ] `plugins/music/MUSIC_API.md` documents now-playing as one snapshot per user with no mention of
`?device=`, which the schema makes part of the primary key. A mobile client written to that doc is
correct only by accident.
- [ ] `plugins/example/manifest.ts:12` lists `web/Router.tsx` as the frontend convention; discovery
requires `web/panels.ts` AND `web/layout.ts`. The same file documents an `ownerOnly` field that does
not exist and never did.
- [ ] The Companion contract docs the offscale code cites (`COMMS/OFFSCALE_COMPANION_API.md`,
`COMMS/OFFSCALE_INVITE_ENROLLMENT.md`) exist nowhere — `COMMS/` was deleted. Two of the plugin's
eight sections depend on a service with no published spec.
**Conventions a plugin author has to guess.** All verified in `discover.ts`/`mount.ts`, none documented
outside the example's new README:
- [ ] Panel `key`s and layout `appType`s must agree, and a mismatch fails SILENTLY — `PluginScreen.tsx`
pins `appTypes.allowed` with `fallback: allowed[0]`, so a typo renders the first panel.
- [ ] The `<appName>_` table prefix is unenforced. The barrel is `export *` across every plugin into one
`public` schema, so two plugins choosing the same unprefixed name collide with no error until push.
- [ ] A manifest is module-cached for the process lifetime: editing it does nothing until
`pm2 restart officer`, and `outdated` cannot see a version bump either.
- [ ] Permission-key prefixing is convention, not validation. Only a collision with a CORE key is refused,
silently, at mount, with a console error.
**Genuinely missing.**
- [ ] `plugins/offscale` spawns the `ssh` binary (`sidecar/ssh.ts`) but declares no `osDependencies`, so
install never checks for it and the Console fails at runtime instead of refusing at install.
`PLUGIN.md:421` already lists it as a host dependency — the manifest is the file that is wrong.
- [ ] `scripts/reindex-music.ts` resolves its port file from `process.env.DATA_PATH ?? cwd/data`, and
`DATA_PATH` stopped being an env var on 2026-08-13. Run from the repo it looks in
`platform/data/music/.server` and never finds it. One line.
- [ ] `plugins/music/web/MusicPlayerHost.tsx` header says it is mounted once in the persistent
DashboardLayout; it moved into `MusicDetail`. That is exactly the comment that would mislead
someone about why playback stops off-route.
- [ ] A plugin's `assets/icon.png` is only published while installed, so its real icon is invisible in the
store before install. The catalogue's `iconUrl` works around it, and an author has to know to add one.
## Waiting on the owner to run it
- [ ] **The AI usage panel works for one account; the multi-account half is unproven.** Landed and verified
live 2026-08-18 — click the harness pill in the chat composer and it shows Claude Code's own usage
display, limits with countdowns, tokens by day and by model.
What is NOT proven is the reason it was built: a second Anthropic login, the account tabs, and a
stored row surviving a switch. **Group C of `docs/ai-usage-panel.md`** is that, and test 10 is the one
most likely to be wrong — a refresh must not overwrite an account it could not measure, and that
failure is silent.
Deliberately deferred rather than forgotten: the owner switches accounts when the work needs it, and
the worst case is one wrong tab. Run Group C the first time a second login lands on this machine.
Also unexercised: the model dropdown no longer renders the literal string `claude-code` for a chat
that named no tier. Needs `officer-claude-code` restarted, not just `officer` — the parser in the
sidecar is what emits it.
- [ ] **Plan mode is live and has never been exercised.** Landed `1b811dfa` on 2026-08-17; all three
processes were restarted the same evening, so the code IS running. Not one part of it has been used:
not the toggle, not the plan card, not the round trip from the sidecar's pending promise to a browser
and back, not the stored column, not `settleAllPlans`.
**`docs/chat-plan-mode-test-plan.md` is the checklist** — eleven ordered tests, what each should show,
and what its failure looks like. Test 4 (approve) is the one most likely to be wrong: it is the case
that changed the design, because approving `ExitPlanMode` drops the harness into `default` rather than
back into `bypassPermissions`.
Safe to leave untested. Plan mode is opt-in and off by default, so a conversation that never touches
the toggle behaves exactly as it did before. The one failure worth knowing in advance is a turn that
never ends after a plan is answered — that would be a leaked `canUseTool` promise, and
`pm2 restart officer-claude-code` clears it.
Design and the probe results behind it: `docs/chat-plan-mode.md`.
## Known bugs
- [ ] **Background job logs appeared INSIDE an open file in the code editor.** Reported 2026-08-16 by the
owner, on the production host, running as a member with a `bun dev` background job streaming. The
editor had a file open; the runner's log output was rendered into that file's buffer. **It was never
saved** — the file on disk was intact, and the editor was not marked dirty as far as the report goes.
So this is a DISPLAY-layer mixing, not a write path: the bytes reached a buffer that only exists in
the browser.
Not reproduced, not diagnosed, and deliberately not guessed at further than this. What is worth
recording is where the two streams could meet, because both were added in the same week and they are
the only two things writing text into a panel:
- `useTaskDetail` polls `GET /chat/tasks/:id` every 2s while a job runs and returns `{kind:'log', text}`.
`CodeSurface` renders it — and `CodeSurface` is shared with the editor's own surfaces.
- The editor keys open files by PATH (`useEditorState`), while the task panel keys by TASK ID, and
several panel-scoped stores in between are keyed by `panelId` through `useGlobal` /
`usePanelMemory`. A key collision between those namespaces would produce exactly this: one panel's
text arriving in another's buffer, with no server involvement and nothing persisted.
**Screenshot 2026-08-16 answers the first question and kills two theories.** The tab carries a DIRTY
dot, and the log lines have Monaco line numbers continuing the file's own sequence (…180, then eight
lines of dotenv/POSTGRES_URL output, then 189 `</Link>`). So the MODEL was mutated — this is the
editor's content state, not its render, and the earlier "the file was okay" means it was simply never
saved. Note the splice is mid-file, not a replacement: JSX text and log text share line 180
(`Forgot?--env-file=.env --watch src/server.tsx`).
Ruled out by that: `CodeSurface` renders with shiki, not Monaco, so the two cannot share an editor
instance. And the app's only other Monaco mount (`FileViewer/renderers/EditorRenderer.tsx`) passes no
`path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`.
So the pollution arrives as TEXT through the editor's own content path — `openFile`, `setContent`, or
the end-of-turn refresh that re-reads every clean file. That last one is the prime suspect: it is the
only thing that writes a buffer without a keystroke, it runs exactly when a background job is
producing output, and it lands its result by path.
**Narrowed 2026-08-16, and a trap is now set.** `reloadFile` sets `originalContent` and clears
`isDirty`, so the end-of-turn refresh CANNOT produce the dirty dot in the screenshot — the refresh
path is out. Nothing in the app injects text into the page either: no `execCommand('insertText')`,
no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change
event, which means it looks like a paste or a programmatic `executeEdits`.
**`docs/editor-buffer-corruption.md` is the procedure — read that when someone reports this.** A
trap is set: `reportSuspiciousEdit` in CodeEditor.tsx records any insertion over 200 characters
arriving while the editor is NOT focused, with the stack at that moment, into
`localStorage['officer:editor-buffer-trap']` (last five). It persists rather than only logging
because this arrives as a report hours later, when the console is long gone — the question becomes
"paste this key" instead of "what did you see?". Delete all of it once the cause is known.
One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `<Editor>` a real
`path`, so models are now keyed by file URI globally rather than being per-mount `inmemory://`. Two
panels open on the same file now share one model. That should be an improvement, but it is a change
in exactly the layer that turned out to be involved.
Worth noting because it is the reason to look at all: an editor that can show content the file does
not contain is a correctness problem even when it never writes — the next person's instinct will be to
save the file to "fix" it.
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
paths and an outdated package name, so `installPi` always fires and floods the logs with
`EEXIST` noise. Should detect `@earendil-works/pi-coding-agent` at the real npm prefix.
- [x] **VNC mirror can orphan/duplicate x11vnc across sidecar restarts.** FIXED 2026-08-01 in the
Xvnc rewrite. The diagnosis was right and survived the move off mirroring: the running desktop is
tracked in module-level state, so a sidecar restart forgot it while the server kept running
orphaned, and `waitForPort` treated ANY listener on 5900 as success — so the next start reported
success while the browser talked to the stale process. Now `reclaimPort` frees 5900 (TERM, then
KILL after 2s) before spawning, and `waitForPort` also fails if the process we spawned has
exited, so a listener that is not ours can no longer be mistaken for a healthy start.
## Infra (alpha)
- [ ] **Delete `/etc/systemd/system/officer-vnc.service`.** Hand-installed unit (not in this repo)
that ran `vncserver :1 -geometry 1920x1080 -localhost yes -fg` with `Restart=on-failure`,
enabled at boot — it kept a whole parallel XFCE session alive on `:1` (283 MB, 166 tasks)
independently of the platform, and silently respawned it whenever the display was killed.
Obsolete now that the Desktop panel mirrors `:0` via x11vnc. Disabled 2026-07-15
(`systemctl disable --now`), but the unit file is still on disk. Nothing in the codebase
recreates it and nothing documents it, so delete the file rather than leave a mystery service
one `systemctl enable` away from returning.
- [ ] **`ufw` blocks port 9010 on the LAN.** Default deny incoming; only `22/tcp`, `80/tcp`,
`443/tcp`, and everything on `tailscale0` are allowed — so `http://192.168.47.196:9010` is
unreachable from the LAN while localhost and Tailscale work.
Fix: `sudo ufw allow from 192.168.47.0/24 to any port 9010 proto tcp`
- [ ] **Move the remaining env credentials into the secret store.** `putSecret`/`getSecret` and
`bun run secret:set` landed 2026-08-19, and notify now reads `apns-p8`, `apns-key-id`,
`apns-team-id`, `fcm-service-account` and `discord-webhook` from the store with env as a
fallback. Every other plugin credential is still env-only — same argument applies: bun
auto-loads `.env` into every PM2 process, so each one is readable from `/proc/<pid>/environ`
of twenty processes that have no business with it.
## Backburner
- [ ] **Music tagging feature** (`/music`, Mp3tag-inspired). v1 was scoped as a two-panel workspace —
file browser left, table right, single "Load" context-menu item flattening audio files into the
table. Discarded before completion; would need `GET /file-browser/flatten-audio`, a
`useFilesAPI.flattenAudio` method, and the Music app panel rebuilt from scratch.
## Urgent
- **due 2026-08-22** — **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
## Themes
**84 open, 22 closed**, across 28 themes.
| Theme | Open | Closed |
| --- | --- | --- |
| [Affordances](todo/affordances/) | 1 | 0 |
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
| [Backburner](todo/backburner/) | 2 | 0 |
| [Background jobs](todo/background-jobs/) | 2 | 0 |
| [Chat interface](todo/chat/) | 1 | 0 |
| [Code editor](todo/code-editor/) | 1 | 0 |
| [Components — the shared UI layer](todo/components/) | 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/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 4 | 1 |
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
| [Multi-user](todo/multi-user/) | 17 | 3 |
| [Notifications](todo/notifications/) | 1 | 0 |
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 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 |
| [Shell — spotlight and the dock](todo/shell/) | 1 | 1 |
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 1 | 1 |
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
| [Tooling — the list about the list](todo/tooling/) | 1 | 0 |
| [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/) | 2 | 0 |
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
## Records — no items, kept for the reasoning
- [Affordances (2026-08-19)](todo/affordances-runtime/_index.md)
- [Email](todo/email/_index.md)
- [Inputs outside forms (2026-08-19) — swept, fixed, closed](todo/inputs-outside-forms/_index.md)
- [Release-binary dependencies (2026-08-19)](todo/release-binaries/_index.md)
## Newest ten
- `2026-08-21` **No way to see which affordances are reaching your agents, or to turn one off** — [affordances/no-way-to-see-which-affordances-are-reaching](todo/affordances/no-way-to-see-which-affordances-are-reaching.md)
- `2026-08-21` **Generalise it: deliver a turn to a named session, from anywhere in the backend** — [agent-delivery/generalise-it-deliver-a-turn-to-a-named](todo/agent-delivery/generalise-it-deliver-a-turn-to-a-named.md)
- `2026-08-21` **The cost of resuming grows with the transcript** — [agent-delivery/the-cost-of-resuming-grows-with-the-transcript](todo/agent-delivery/the-cost-of-resuming-grows-with-the-transcript.md)
- `2026-08-21` **'An agent spawned by a background job is invisible in the interface'** — [background-jobs/a-spawned-agent-is-invisible-in-the-interface](todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface.md)
- `2026-08-21` **Show how long a turn took, beside its cost and tokens** — [chat/show-how-long-a-turn-took-beside-its-cost](todo/chat/show-how-long-a-turn-took-beside-its-cost.md)
- `2026-08-21` **Map every custom component, centralise it, and let a theme override it** — [components/map-every-custom-component-and-centralise-it](todo/components/map-every-custom-component-and-centralise-it.md)
- `2026-08-21` **Watch a directory, and attach actions to what lands in it** — [file-watchers/watch-a-directory-and-attach-actions-to-what](todo/file-watchers/watch-a-directory-and-attach-actions-to-what.md)
- `2026-08-21` **Settings to choose which server each feature uses** — [fleet/settings-to-choose-which-server-each-feature](todo/fleet/settings-to-choose-which-server-each-feature.md)
- `2026-08-21` **'A deploy verifies PM2, not that the app still works'** — [fleet/a-deploy-report-verifies-pm2-not-that-the-app](todo/fleet/a-deploy-report-verifies-pm2-not-that-the-app.md)
- `2026-08-21` **'The deploy commander cannot finish its own host'** — [fleet/the-deploy-commander-cannot-finish-its-own-host](todo/fleet/the-deploy-commander-cannot-finish-its-own-host.md)
+43
View File
@@ -0,0 +1,43 @@
# 2026.08.21.01
The first release under this scheme, and the first deploy of the night's work. Everything since
`0.1.0`, which is to say everything.
## What is in it
- **Two plugins extracted**: `desktop` (VNC) and `browser` (the relay, its extension and its panels),
with the platform losing the VNC vocabulary it should never have had.
- **Themes regenerated** — all 22 from Omarchy, 84 tokens each, contrast-checked. The eleven that
shipped with 34 of an 84-token contract are replaced.
- **Spotlight**, on `shift-space`.
- **Chat fixes**: a turn waiting on a person is no longer killed as stalled, and an answer that goes
nowhere now says so.
- **Plugin infrastructure**: `importFresh` (a reinstalled plugin was being read from the deleted one),
`ownerOnly` paths, dependency `scope`, extensions published unauthenticated, `plugins:update`.
- An **Update button** in maintenance, and the **sub-agent budget guard**.
## Steps, in order, on every host
Same on all six — nothing here is host-specific.
1. `git pull --ff-only`
2. `bun install` — **required.** `bun.lock` changed and `sharp` was removed from the platform's
dependencies (it moved into atelier's `npmDependencies`). Without this the lockfile and the tree
disagree, and the frozen-lockfile rule turns that into a failed install later rather than now.
3. `bun run plugins:update`
4. `bun db:push` — **required.** `users.username` becomes `NOT NULL` and gains a case-insensitive
unique index.
5. `pm2 restart officer`
6. Then, and only for hosts that have them: `pm2 restart officer-claude-code officer-pty` — both load
changed sidecar code. **Not** the whole ecosystem, ever.
## The one thing that could have gone wrong, and did not
`db:push` refuses if any account has a null username, or if two differ only by case. **Checked on all
six before writing this**: 12 users across the fleet, zero nulls, zero case-collisions. If a future
host fails here, that is why, and the fix is data rather than schema.
## Post-deploy check
`pm2 jlist` — every restarted process `online` with a moved `restart_time`, and the platform's own
domain answering 200. `docs/servers.md` has the domain per host.
+42
View File
@@ -0,0 +1,42 @@
# 2026.08.21.01 — deployed
**6/6 hosts.** Every platform is on `eee73e77`, schema pushed, processes online and every domain
answering 200.
Rolled out by hand from `officer-dev`, one host at a time, because nothing wakes the commander yet.
| Host | Before | After | install | db:push | plugins | restarted | domain |
| ---------------------------- | ---------- | ---------- | ------- | ------- | -------------- | ------------------------- | ------ |
| `alpha` | `44f6c6d6` | `eee73e77` | ok | applied | 12/12 | officer, claude-code, pty | 200 |
| `fofos` | `6db26ae4` | `eee73e77` | ok | applied | 1/1 | officer, claude-code, pty | 200 |
| `edge-pertento-bridge` | — | `eee73e77` | ok | applied | 2/2 | officer, claude-code, pty | — |
| `production-pertento-bridge` | — | `eee73e77` | ok | applied | 1/1 | officer, claude-code, pty | — |
| `macbookpro` | `2e76cabf` | `eee73e77` | ok | applied | none installed | officer, claude-code, pty | — |
| `officer-dev` | `eee73e77` | `eee73e77` | ok | applied | 3/3 | officer, pty | 200 |
`bun install` reported no changes everywhere — the lockfile diff was `sharp` leaving, and nothing had
it as a transitive need.
## What was NOT done, and why
- **`officer-claude-code` was not restarted on `officer-dev`.** That sidecar holds the session doing
the deploy; restarting it would have ended the conversation mid-rollout. Every other host got it.
**Outstanding — run it when convenient.**
- **`fofos` did not sync its capabilities.** `git` refused the checkout as "dubious ownership" and
printed the usual `safe.directory` remedy. Ownership is actually correct (`pastilhas:pastilhas`,
running as `pastilhas`), so the cause is not what the message suggests and papering over it with a
config entry would hide a real question. Its one plugin updated fine. **Unexplained.**
- **`macbookpro` has no plugins installed**, so `plugins:update` was a no-op there. Not a failure —
worth knowing before someone expects the browser relay on it.
## Notes for the next one
- **The dirty-tree check was too strict.** `git status --porcelain` counts UNTRACKED files, and
`fofos` was skipped on the first pass for one stray `scripts/chk.ts` that a fast-forward could never
touch. The rule is `--untracked-files=no`; untracked files get a note, not a skip.
`prompts/deploy-commander.md` says "refuse a dirty tree" and should say which kind.
- **`macbookpro` needs `$SHELL -ic` and nothing else.** Exporting a PATH to reach `bun` broke `pm2`,
which is only on the interactive shell's path. Do not help it.
- **The pre-flight was worth it.** `users.username` became `NOT NULL` with a case-insensitive unique
index; checked across all six first — 12 users, zero nulls, zero collisions — so `db:push` applied
everywhere without a data fix.
+33
View File
@@ -0,0 +1,33 @@
# 2026.08.21.02
Command HQ made usable: a commander's transcript renders, and Live separates commanders from the
conversations somebody is having.
Frontend and one chat API route. **No schema change, no dependency change** — verified against
`deploy/2026.08.21.01..HEAD`: nothing touched `src/schema.ts`, `package.json` dependencies or `bun.lock`.
So there is no `db:push` and no `bun install` in this plan, and a host that runs one has run something
this release did not ask for.
## What is in it
- Three separate defects that each made a commander's conversation invisible: the pane navigated to
officer's session key instead of Claude's transcript id, then remounted and discarded the live chat at
the end of the first turn, then seeded `useChat` with messages that had not arrived yet.
- Live splits into `Agents (n)` / `Commanders (n)`, and `/chat/live` now carries `isCommander` so a client
with no room for tabs — the iPhone app's swipe order — can filter on it.
- The handoff watcher's controls, which were built and never rendered.
## Every host
In the order the procedure gives. Nothing here is host-specific.
1. `git pull --ff-only`
2. `bun run plugins:update`
3. `pm2 restart officer`
**`officer` only.** No sidecar changed. Restarting `officer` rebuilds the SPA on boot, which is what
ships the frontend — there is no separate build step to run.
## Then check
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else should have restarted.
+76
View File
@@ -0,0 +1,76 @@
# 2026.08.21.02 — deployed
**6/6 hosts.** Every platform the plan reaches is on `55ecbf4f`, `officer` restarted and back `online`,
and no other process on any host was touched by the restart step.
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this commander runs
on. See below.
Rolled out one host at a time from `officer-dev`, by the procedure in `prompts/deploy-commander.md`.
## The release
Tag `deploy/2026.08.21.02` → `55ecbf4f`; `package.json` says `2026.08.21.02`. They agree.
Plan: `deploy/2026.08.21.02.md` — pull, `plugins:update`, restart `officer` only. No `bun install`,
no `db:push`, and neither was run anywhere.
| Host | Before | After | plugins | restarted | restart_time | back |
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
| `alpha` | `eee73e77` | `55ecbf4f` | 12/12 | officer | 60 → 61 | online |
| `fofos` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 15 → 16 | online |
| `macbookpro` | `eee73e77` | `55ecbf4f` | none installed | officer | 2 → 3 | online |
| `edge-pertento-bridge` | `eee73e77` | `55ecbf4f` | 2/2 | officer | 4 → 5 | online |
| `production-pertento-bridge` | `eee73e77` | `55ecbf4f` | 1/1 | officer | 6 → 7 | online |
| `deadpool-rezio-bridge` | `bd41dff5` | `55ecbf4f` | none installed | officer | 0 → 1 | online |
| `officer-dev` | `55ecbf4f` | `55ecbf4f` | not run | **none** | 155 | skipped |
Every host was clean of TRACKED changes and every pull was a real fast-forward. `pm2 jlist` after each
restart showed zero processes in any state other than `online`, so nothing was collaterally bounced.
Checkout paths were taken from each `officer` process's `pm_cwd`, not assumed — so `alpha`'s stale
second checkout at `~/dockers/officer.dev/platform` was never touched.
## What was NOT done, and why
- **`officer-dev` was skipped completely** — no pull, no `plugins:update`, no restart. This session runs
inside `officer-claude-code` on it, and its `officer` process would have been restarted out from under
the rollout. Its checkout is already at `55ecbf4f` (the tag was cut here), so **the code is present and
the running process is a build behind**. `pm2 restart officer` on `officer-dev`, when the owner is happy
with the rest, finishes this release. **Outstanding.**
- **`deadpool-rezio-bridge` was two releases behind, not one.** It came in at `bd41dff5` — the
`deploy/2026.08.21.01` commit — while every other host was at `eee73e77`. The fast-forward carried both,
14 files, and it is now level. Nothing was wrong with it; it simply had not had the interim commits.
- **`fofos` still cannot sync its capabilities.** Same `dubious ownership` refusal as `2026.08.21.01`,
same unexplained cause, still not papered over with a `safe.directory` entry. New this time and probably
the same root: its gitea plugin could not publish affordances —
`EACCES: permission denied, mkdir '/home/pastilhas/officerdev/capabilities/affordances/officerdev'`.
Both point at that capabilities checkout being owned by somebody other than `pastilhas`, which is worth
one `stat` before the next deploy. `plugins:update` exited 0 and the plugin itself updated. **Unexplained,
and now costing two things instead of one.**
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — that is the rule the last
report asked for and it worked.
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither.
## Notes for the next one
- **`macbookpro` needs the PATH helped after all — carefully.** The last report said "`$SHELL -ic` and
nothing else"; that is right for `pm2` and wrong for `bun`. `bun` is at `/opt/homebrew/bin/bun`, and
`.zshrc` does not put `/opt/homebrew/bin` on the interactive PATH, so `bun run plugins:update` failed
`command not found` before it ran a line. What works is **appending** that one directory for that one
command (`export PATH=$PATH:/opt/homebrew/bin`) and running `pm2` as a separate command. Appending
shadows nothing — `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. **Replacing** the PATH is
what broke `pm2` last time. `docs/servers.md` says "do not help the PATH" and should say "append, never
replace, and only for `bun`".
- **`$SHELL -ic` on `macbookpro` also aliases `ls` to `eza`**, which is not installed, so a probe using
`ls` reports a missing file that is plainly there. Use `command ls` or a plain non-interactive shell for
file tests.
- **`plugins:update` restarts sidecars itself.** It prints `sidecar: started` per plugin, so on `alpha`
twelve sidecars bounced during step 3 — before the plan's restart step, and not something the plan asked
for. That is the script's own behaviour and it is not new, but a report that only lists `officer` under
"restarted" is understating it. Worth a line in the plan format.
+30
View File
@@ -0,0 +1,30 @@
# 2026.08.21.03 — a drill
**No code change.** This release exists to watch the watcher fire, and to exercise the claim rule that
landed after the last one.
There is nothing to bump but the version and this file, and that is deliberate rather than lazy: a tag
whose `package.json` disagrees with it makes the commander stop, and a tag with no plan beside it makes it
stop too, so the smallest honest release is still a version and a plan.
## What is being tested
- The watcher noticing a tag and spawning an agent on its own, with nobody watching the composer.
- `scripts/deploy-claim.sh` taking the claim before anything is deployed, and the deploy recording success
through the same script.
- A second pass over the same version being refused rather than repeated.
## Every host
1. `git pull --ff-only`
2. `bun run plugins:update`
3. `pm2 restart officer`
`officer` only. No `bun install`, no `db:push` — nothing in this range touches a schema, a dependency or
the lockfile, and `git diff deploy/2026.08.21.02..HEAD` is two files.
## Then check
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Nothing else restarted.
`scripts/deploy-claim.sh state 2026.08.21.03` says `done`.
+83
View File
@@ -0,0 +1,83 @@
# 2026.08.21.03 — deployed
**6/6 hosts.** Every platform the plan reaches is on `3343b21a`, `officer` restarted and back `online`,
and no other process on any host was left in a state other than `online`.
`officer-dev` is the seventh platform and was **skipped entirely** — it is the host this deploy ran on.
See below; its skip is now two releases deep.
This was a drill. The release changes `package.json` and adds its own plan file, nothing else, and it
exists to watch the claim rule work. It did: `scripts/deploy-claim.sh claim 2026.08.21.03` was taken
before any host was touched, and `state` reads `done`.
## The release
Tag `deploy/2026.08.21.03` → `3343b21a`; `package.json` at that commit says `2026.08.21.03`. They agree.
Plan: `deploy/2026.08.21.03.md` — pull, `plugins:update`, restart `officer` only. No `bun install`, no
`db:push`, and neither ran anywhere.
| Host | Before | After | plugins | restarted | restart_time | back |
| ---------------------------- | ---------- | ---------- | -------------- | --------- | ------------ | ------- |
| `alpha` | `55ecbf4f` | `3343b21a` | 12/12 | officer | 61 → 62 | online |
| `fofos` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 16 → 17 | online |
| `macbookpro` | `55ecbf4f` | `3343b21a` | none installed | officer | 3 → 4 | online |
| `edge-pertento-bridge` | `55ecbf4f` | `3343b21a` | 2/2 | officer | 5 → 6 | online |
| `production-pertento-bridge` | `55ecbf4f` | `3343b21a` | 1/1 | officer | 7 → 8 | online |
| `deadpool-rezio-bridge` | `55ecbf4f` | `3343b21a` | none installed | officer | 1 → 2 | online |
| `officer-dev` | `3343b21a` | `3343b21a` | not run | **none** | 155 | skipped |
Every host came in at `55ecbf4f` — the previous release, level for the first time in three deploys — and
every pull was a real fast-forward. Checkout paths were taken from each `officer` process's `pm_cwd`, so
`alpha`'s stale second checkout at `~/dockers/officer.dev/platform` was never touched. `macbookpro` was
awake.
**More than `officer` restarted, and the plan did not ask for it.** `bun run plugins:update` bounces each
plugin's sidecar as it goes: twelve on `alpha`, two on `edge`, one each on `fofos` and
`production-pertento-bridge`. Every one came back `online`. Nothing was left down.
## What was NOT done, and why
- **`officer-dev` was skipped completely** — no pull (it is already at the tag, which was cut here), no
`plugins:update`, no restart. This session runs inside `officer-claude-code` on it, and restarting
`officer` there ends the rollout mid-flight.
**Its running process is now two releases old.** `restart_time` is 155, unchanged since before
`2026.08.21.02` — so the same restart the last report left outstanding is still outstanding, and it now
carries both releases. The code for both is on disk. `pm2 restart officer` on `officer-dev` finishes
them, whenever the owner wants it. **Outstanding, and no longer new.**
- **`fofos` still cannot sync its capabilities.** Third deploy running, same two failures, same
unexplained cause:
- `capabilities: failed — fatal: detected dubious ownership in repository at
'/home/pastilhas/officerdev/capabilities'`
- `affordances: NOT published — EACCES: permission denied, mkdir
'/home/pastilhas/officerdev/capabilities/affordances/officerdev'`
Both say that checkout is not owned by `pastilhas`. Still not papered over with a `safe.directory`
entry, because a `safe.directory` line would silence the first message and leave the `EACCES` — they are
one cause, and one `stat` settles it. `plugins:update` exited 0 and the gitea plugin itself updated, so
this costs capability sync on that host and nothing else. **Unexplained.**
- **`fofos` still carries one untracked file**, `scripts/chk.ts`. A note, not a skip — the rule works.
- **No `bun install` and no `db:push` ran anywhere.** The plan says this release needs neither, and
`deploy/2026.08.21.02..deploy/2026.08.21.03` is two files.
- **Nothing was verified beyond `pm2`.** `officer` is `online` with a moved `restart_time` on all six
hosts; no HTTP request was made to any domain. That is what the plan asked to check.
## Notes for the next one
- **The `macbookpro` PATH rule in `docs/servers.md` is now correct and was followed as written.**
`export PATH=$PATH:/opt/homebrew/bin` for the `bun` command, `pm2` as a separate command, and both
worked first time. Nothing to change.
- **`$SHELL -ic "pm2 jlist"` with stderr discarded came back empty once**, on `edge-pertento-bridge`, and
the same command with `2>&1` a second later returned the full list. Transient, and it looked exactly
like a dead pm2. Capture stderr and parse the JSON out of the stream rather than trusting the whole of
stdout to be JSON — an interactive shell puts its own noise (`starship`, aliases) in there.
- **`officer-dev`'s skip needs a home other than a report.** It has been carried forward twice now, and a
report is not a place work waits — the next deploy will write the same paragraph. Either the owner
restarts it, or it belongs in `TODO.md`.
+51
View File
@@ -0,0 +1,51 @@
# 2026.08.21.04
`/login` in the chat window, switching between Anthropic accounts from the usage pills, and a Spotlight
that can actually reach the plugins.
**This release has a SCHEMA CHANGE.** The previous two did not, and said so; this one adds
`anthropic_accounts`. Without the push, `GET /api/claude-auth/accounts` fails on a missing relation the
first time the usage panel is opened — so `bun db:push` is a step here, not an optional tidy.
No dependency change: nothing in `deploy/2026.08.21.03..HEAD` touches `package.json` dependencies or
`bun.lock`. Do not run `bun install`.
## What is in it
- **`/login` works in the chat composer.** `claude auth login` behaves the same through plain pipes as
through a terminal, so it is a subprocess with two reads and one write — no pty. Copy-link beside the
open-link button, because opening it here opens it in the browser already signed into the account you are
leaving.
- **Account switching from the usage pills.** `anthropic_accounts` keeps each login's OAuth credential
encrypted under a per-account key from the secret store, so a switch needs no browser round trip. An
account with no saved credential still gets a chip you can read — cached percentages and a correct reset
time — marked as needing `/login`.
- **Spotlight reached eight items.** Core filled all eight slots, so on a populated server every plugin was
unreachable. The cap is gone, rows draw the dock's own tiles, and the settings list went from nine
hand-written rows to twenty read off the screens that declare them.
- The ephemeral pane opens from the markup rather than an effect.
## Every host
1. `git pull --ff-only`
2. **`bun db:push`** — new this release, see above
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only; no sidecar changed. Restarting it rebuilds the SPA, which is what ships the frontend.
**`alpha` is already at this code**, deployed by hand as a preview before the tag. Its pull will be a
no-op and that is expected — run the remaining steps anyway rather than skipping the host, so its
`restart_time` moves with everyone else's.
**On `macbookpro`, append to the PATH for `bun`** — `export PATH=$PATH:/opt/homebrew/bin` — and run `pm2`
as a separate command. `docs/servers.md` has the detail; replacing the PATH is what broke `pm2` on an
earlier deploy.
## Then check
`pm2 jlist`: `officer` is `online` and `restart_time` moved. Nothing else restarted by step 4 — note that
step 3 restarts every plugin's sidecar on its own, which is the script's behaviour and worth recording
rather than omitting.
`scripts/deploy-claim.sh state 2026.08.21.04` says `done`.
+23
View File
@@ -0,0 +1,23 @@
# `deploy/`
One file per release, and one report per release.
```
deploy/
├── 2026.08.21.01.md the plan — what to run, in order, on which hosts
└── 2026.08.21.01.report.md what actually happened, written by the commander
```
The plan is written by hand before the release commit. The report is written by the deploy commander
afterwards and pushed back here.
**A release is a tag**, `deploy/<version>`, and nothing else triggers a deploy — not a commit message,
not a branch. A tag cannot be written into prose by accident, which a code word can and which every
document about deploying does.
**What marks a release deployed is LOCAL**: `$OFFICER_ROOT/data/deploys/<version>.done`. Not the report.
If the lock were the pushed report, a failed push would mean the release looked undeployed and the next
tick would roll it out again — and pushes here fail for unrelated reasons. So the report is an artefact,
valuable and not load-bearing.
See `prompts/deploy-commander.md`.
+159
View File
@@ -0,0 +1,159 @@
# Recall over your own correspondence
**Status: DESIGN, NOT BUILT.** Sketched 2026-08-20, for the email plugin.
## The goal, which is not the technology
Someone is on a phone call with their ex-wife. She says something about the weekend arrangement. He has
eight years of email with her, and he needs the fact **now** — not after a search, not after reading
twenty messages.
That is the whole requirement. Everything below is in service of it, and any part of this that does not
make that moment faster or more accurate is decoration.
The user will never write a query. They ask the agent a question in their own words, and the agent picks
its tools. So the design question is not "what can the user search" — it is **"what does the agent have
to work with, and which of its answers can be trusted"**.
## Two mechanisms, because there are two kinds of question
The examples that prompted this split cleanly, and they need different machinery:
| the question | what it needs |
| ---------------------------------------------------------------- | ------------------------------------- |
| _"how many times did she ask me to break the weekend agreement"_ | **exhaustion** — every message judged |
| _"what did she say about the school run"_ | **retrieval** — the best few, fast |
```
COMPILE exhaustive, once, expensive → counting, timelines, "show me all of them"
RETRIEVE cheap, approximate, always → exploring, mid-call, "what did she say about…"
```
They are not competing designs. The agent should have both and choose.
### Counting cannot be done by searching
This is the load-bearing point, and it is the one most likely to be skipped.
An agent asked "how many times" will search, find some, and answer with a number. That number is bounded
by the phrasings it happened to think of. **"I found four" is indistinguishable from "there were nine"**,
and nothing in the output signals which it was.
Vector search does not fix this. Top-k also misses — it just misses differently. Ask for the top 20 and
the 21st is silently gone.
The only honest way to count is to judge every message once and store the verdict. Then counting is
`SELECT count(*)`: exact, instant, and re-askable a hundred ways without re-reading anything.
**A stored verdict is a judgement a model made about someone's divorce.** It records what it was asked,
which message it read, and when — so the answer can be inspected, disbelieved and redone. A number with
no provenance is worse than no number, because it will be quoted.
### Retrieval is where embeddings earn their place
Keyword search finds `school run`. It will never find it from _"the pickup arrangement"_ or _"when I
collect her on Fridays"_. Over eight years, the same thing is described twenty ways and never once in the
words you would search for.
An agent closes some of that gap by itself — it knows those are synonyms and can fire several searches.
But that is **guess-bounded**: it finds the phrasings it thought of, and cannot know the ones it did not.
Two things decide it in favour of embeddings anyway:
- **Latency.** Five keyword searches and twenty candidate reads is thirty seconds. On a live call that is
a lost moment. A vector query is milliseconds plus one read.
- **What comes later.** WhatsApp and audio transcripts share no vocabulary, schema or structure with
email. Embeddings collapse all of it into one space. ASR output is noisy in a way keyword search is
brittle against and embeddings are not.
**But we do not have WhatsApp or transcripts.** They shape exactly one decision — see the schema — and
nothing else. Build against email.
## It has to run locally
Every message passes through an embedding model. Doing that through an API means uploading eight years of
private correspondence to a third party.
For this content that is not a trade-off, it is a disqualification. **Local embedding is a requirement,
not a preference.**
Today there is no ollama and no embedding code on any of these machines. **That is the real new
dependency — not the database.**
## Not a vector database
`sqlite-vec` is an extension to the SQLite already in use. Same file as `emails.db`, no service, no
sidecar, no Docker, no port, nothing to supervise or back up separately.
For a plugin that already owns that file, this is **a table, not an architecture**. A separate vector
database would add an operational surface for a corpus of 19,000 rows, which is small — vector stores
begin earning their keep in the millions. What is being bought here is recall on paraphrase and speed on
a phone call, not scale.
## The genuinely hard part is chunking
Not the vectors. **Quoted reply chains.**
The same paragraph exists in forty messages of one thread. Embed naively and every query returns that
thread forty times, and it will feel worse than what exists today.
So: strip quoted history, thread properly, embed the new text of each message. `emails` already has
`thread_id`. This is the work.
Two other email-specific traps measured on the real 19,097-message store:
- **Bodies are ~0.5 GB of HTML.** Embed the text, not the markup.
- **`labels` is a comma-separated string**, and `read` is populated on 93 rows. Neither is a filter to
build on. See the `email-db` skill.
## Schema sketch
Deliberately **not** email-shaped. That is the one concession to a future that does not exist yet, and it
costs nothing now:
```
recall_chunks
id integer pk
source text -- 'email' today. 'whatsapp', 'transcript' later.
source_id text -- the message id in ITS store — no foreign key across sources
account text -- which mailbox/number this came from
thread_id text -- for dedupe and for showing context
author text -- who said it
said_at timestamp
text text -- quoted history stripped
embedding float[] -- sqlite-vec
recall_verdicts -- the COMPILE half
id integer pk
question text -- what the model was asked, verbatim
source text
source_id text
verdict boolean
reasoning text
model text -- which model, so a re-run is comparable
judged_at timestamp
```
Everything else assumes email until something else exists.
## Build order
1. **Chunking and dedupe**, with no embeddings at all. Strip quotes, thread, count what is left. If this
is wrong nothing after it can be right, and it is testable on its own.
2. **A local embedding model**, and a decision about which. The new dependency.
3. **`sqlite-vec` + backfill** of the existing corpus. One-off, slow, resumable.
4. **A retrieval tool** the agent can call, returning chunks with their message ids so it can go read the
original.
5. **The compile half** — a verdict table and a way to ask "judge every message against this question".
6. Multi-source, if and when WhatsApp or transcripts exist.
Steps 1 and 5 are useful without any of the others. Step 5 in particular answers the questions that
prompted this, and needs no vectors at all.
## Open questions
- **Which local model**, and how big. Quality against a laptop-or-server-sized footprint.
- **Re-embedding.** Changing model means re-embedding everything. Store which model produced each vector.
- **Retention and deletion.** A deleted email should lose its chunks and its verdicts. Nothing here
currently cascades.
- **Who can query it.** This is the most sensitive data in the platform, and it is per-account.
+112
View File
@@ -0,0 +1,112 @@
# Notifications from more than one server
**Design, not built.** Written 2026-08-21, for the single-sign-on work.
The good news first, because it is the part that needed checking and it holds: **one Officer app can
receive notifications from every server you are signed into, and almost nothing has to change to allow
it.** One field is missing. This is what it is and why it matters more than it looks.
## Why it works at all
An APNs auth key (`.p8`) belongs to the **developer account**, not to a machine. There is no server
binding in it. So every Officer install can hold the same key, mint its own provider token from it, and
push to the same app.
The device token belongs to the **app**, not to a server either. One Officer app has one token, whichever
servers it talks to.
So N servers pushing to one phone needs no coordination between them. Each just needs the token, which the
app posts when it signs in — which the multi-server manager is doing anyway.
Two facts about the shared key, neither blocking:
- **Apple allows 2 active auth keys per account.** Sharing one across servers is the intended pattern;
one key per server is not an option past two.
- **Revoking it kills push everywhere**, with no way to tell which machine leaked it. That is the real
cost of copying it to ten boxes, and the reason it belongs in each one's secret store rather than
in `.env`.
## The missing field
A payload today:
```json
{
"aps": { "alert": { "title": "Officer" }, "thread-id": "chat:abc", "mutable-content": 1 },
"officer": { "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 }
}
```
There is no origin. With one server that is correct and minimal. With five it breaks three things, and
only the first is obvious.
**1. The deep link opens the wrong thing.** `officer-chat://session/{id}` — that id is meaningful only on
the server that issued it. The app has no way to know which, so it picks one. Sometimes it opens nothing;
sometimes it opens somebody else's conversation with a matching id, which is worse.
**2. `mv` starts a resync fight.** `mv` is a content hash of **that server's** catalogue, and servers with
different plugins have genuinely different catalogues. The client's rule is `mv !== version → resync`.
With one stored catalogue and five servers, every push from server B invalidates the copy synced from
server A, and the app resyncs forever, always against whichever pushed last.
This is the one that would look like a mysterious battery and data problem rather than a bug.
**3. Threading collapses across servers.** `chat:{id}` groups per conversation. Two servers' ids
coinciding is unlikely with UUIDs and not impossible, and "these two unrelated chats are one banner" is a
confusing failure to diagnose.
## The change
**A server identifier in the `officer` block**, and the app keeping **one catalogue per server** keyed on
it:
```json
"officer": { "s": "<server-id>", "c": "officer/core:chat.turn-ended", "id": 42, "mv": 1873 }
```
`mv` then means "version of that server's catalogue", threading becomes `<server>:chat:{id}`, and the deep
link resolves against a known server.
### Where the identifier comes from is an SSO decision, not a notifications one
This is the part to settle tomorrow, and it belongs to the sign-on design rather than to this file.
The multi-server manager already needs a key for each server — to store its API key against, to label it
in a switcher, to route a deep link. **Whatever that key is, notifications should adopt it.** Inventing a
second identity here means two ids for one server, drifting the first time somebody renames a host.
It has to survive a hostname change, be stable across reinstalls of the app, and not be a secret — it
travels in a push payload, which is the one place content is deliberately withheld. A server-generated
opaque id created at bootstrap fits; a URL does not, because URLs change.
## What stays per server, correctly
Worth stating so nobody tries to unify it:
- **`push_devices`** — each server holds its own registrations. The same phone appears once per server.
- **`chat_notification_prefs`** — muting a conversation is per server, because the conversation is.
- **The catalogue** — a server's codes come from its own plugins. Two installs with different plugins
legitimately have different vocabularies.
None of that wants a shared store. The app is what joins them, which is the same shape as the sign-on
work: the app holds the list, each server holds its own truth.
## Current state, verified 2026-08-21
| | alpha | officer-dev |
| ---------------------- | -------------- | ----------- |
| iOS devices registered | 2 | 0 |
| APNs credentials | partly present | absent |
So only alpha can push today, and notifications seen elsewhere are the in-app bell rather than push. That
is not a limitation of the design — it is two things absent on the other machines.
## Order of work
1. **Settle the server identifier** with the SSO design. Everything else waits on it, and it is a
half-hour conversation rather than a build.
2. Add `s` to the payload; keep it optional so an older app ignores it.
3. Per-server catalogue storage in the app, keyed on `s`. **This is the one that must land before a
second server starts pushing**, or the resync fight above begins.
4. Thread ids become `<server>:chat:{id}`.
5. Copy the `.p8` into the other servers' secret stores, and have the app register its token per server.
+91
View File
@@ -0,0 +1,91 @@
# Plugin dependency scope
**Built 2026-08-21.** A plugin declares WHO has to be able to run each host binary, and the platform
installs and checks accordingly.
## The bug
`os-deps.ts` probes a dependency by asking the owner's login shell where the binary is. That is the right
question for most plugins and the wrong one for some, and nothing distinguished them — a per-user answer
was reported as a fact about the machine.
It matters because `confined` plugins do their work as **the member's own Linux user**, in their own home.
A binary in the owner's `~/.local/bin` is not on that user's PATH, and reaching it means traversing the
owner's home directory.
On alpha it happened to work. `/home/pastilhas` is mode **751** — world-traversable, not readable — so an
absolute path resolved for members. Mode 750, which is the more usual default, would have broken every
member's binary with nothing reporting anything: the install would still say satisfied, because the
install only ever asked the owner.
Working by accident is the part worth fixing. Nobody chose 751.
## The field
```ts
osDependencies: [{ binary: 'iris', reason: '…', packages: {…}, scope: 'shared' }]
```
`owner` is the default and stays right for most things — anything an `execution` plugin uses runs as the
owner in the owner's home by definition. `shared` says every account with a Linux user must be able to run
it, which changes both the install target and the check.
An unrecognised value is a manifest error rather than a silent fallback to `owner`, because the fallback
is the permissive one.
## What changes when it is `shared`
**Where it is looked for.** System directories only — `/usr/local/bin`, `/usr/bin`, `/bin`, and the sbin
trio. Deliberately not the owner's login shell: that shell finds the copy in the owner's home and reports
the dependency satisfied, which is the exact false answer this exists to stop.
**What counts as reachable.** Two things, and checking only the first is how this hid:
- the file is world-executable (`0o001` — group is not enough, members are not in the owner's group)
- **every directory above it is traversable**, walked to the root
A binary at 755 inside a home at 700 is unreachable, and `stat` on the file alone says it is fine.
**Where a release binary is installed.** `/usr/local/bin`, via `sudo -n install -m 0755`. The download is
still staged in the owner's own space — fetching straight into a root-owned directory would mean running
the download as root, which is a bigger ask than moving the result. `-n` so a plugin install never blocks
on an invisible password prompt.
**What blocks an install.** A shared dependency that is present and working but unreachable gets its own
state, `unreachable`, and its own refusal — checked before everything else, because no package manager
fixes it. The binary is installed; what is wrong is where it lives. Reinstalling the package would report
success and change nothing.
## What this deliberately does not do
**It does not gate execution on a grant.** A binary in `/usr/local/bin` is runnable by any account with a
shell, whether or not they hold the plugin's permission. The kernel does not know about `role_permissions`
and never will.
That is the same boundary `ffmpeg` and everything else in `/usr/bin` already has, and it is the right one:
the plugin's **data** is gated by the permission system, its **executable** is not. For iris it is benign —
a member who runs it captures their own `$HOME` profile and their own sessions, which is the isolation the
wrapper already depends on.
If a binary must not merely be _reachable_ but _unusable_ without a grant, `scope` is not the mechanism.
That needs the plugin's own surface behind the permission gate, and the binary not on anyone's PATH.
## Iris
The motivating case, and still the only `shared` one. `PLUGIN.md` had already recorded it as open item 3 —
"the binary lands in the owner's `~/.local/bin`, and a confined member's agent cannot read it there" — but
that had not been connected to the probe being owner-scoped, so the install reported success either way.
`iris` and `iris-browser` are built by the sidecar rather than fetched, so declaring `scope: 'shared'`
makes the probe honest about who can run them; moving the sidecar's build output into `/usr/local/bin` is
the remaining half, and it is a change to the sidecar rather than to this.
## Status
| | |
| ----- | -------------------------------------------------------------------------- |
| Built | `scope` on a dependency, validated, defaulting to `owner` |
| Built | shared probing on system paths, with a traversal walk to the root |
| Built | shared release binaries installed to `/usr/local/bin` via `sudo -n` |
| Built | an unreachable shared dependency refuses the install, naming the directory |
| Open | iris's sidecar still builds into `~/.local/bin` — see above |
+140
View File
@@ -0,0 +1,140 @@
# Plugin route authorization
**Platform half built 2026-08-21.** The gate enforces `ownerOnly`, the manifest accepts it, and the proxy
forwards `X-Officer-Owner` / `X-Officer-Level`. **No plugin declares it yet** — jellyfin's `/_config` is
still reachable by anyone granted the plugin, and cannot be locked until the credential question at the
bottom of this file is answered.
The question: a plugin's **settings** routes should be reachable only by the owner, even when the plugin
itself is granted to other accounts. Jellyfin is the case that raised it — connecting a server, storing
its admin token and provisioning accounts is not something a Member should reach, but Jellyfin as a whole
is exactly the sort of plugin a Member should have.
## The shape we agreed
Authorization stays where the platform already puts it — the gate answers "may this account reach this
plugin, and at what level" — and the plugin decides what that means **inside** its own surface. That is
not a new principle. `src/servers/plugins/manifest.ts` already says it, in the comment refusing an
`ownerOnly` field on a permission:
> Every plugin permission is grantable, per role, at read or write … Finer visibility — whose rows a
> member sees, what a read means for this plugin's data — is the PLUGIN's business and lives in its own
> queries.
"Which of my routes are administrative" is the same kind of question as "whose rows does a read return".
It belongs to the plugin, because only the plugin knows that `/_config` is settings and `/_jf` is bytes.
So: **the platform tells the plugin who is asking; the plugin decides what they may do.**
### What gets passed, and what does not
Today the proxy sends exactly one fact (`src/servers/sidecar/create-proxy.ts`):
```
X-Officer-User: <id>
```
The extension is one or two more headers alongside it. Two points about how, both load-bearing:
**Pass derived facts, not the role name.** The instinct is to send `X-Officer-Role: Super Admin`. Don't.
Roles are the platform's vocabulary and they are not frozen — `Admin`, `Member`, `Developer` today, with
grants keyed on the role string in `role_permissions`. A plugin that branches on a role name gives every
plugin repository an opinion about that vocabulary, and renaming a role silently changes authorization
in code the platform does not own and cannot see.
Send instead what a plugin can act on without knowing anything:
```
X-Officer-User: <id>
X-Officer-Owner: 1 | 0 — is this the server owner
X-Officer-Level: none|read|write — this account's effective level for THIS plugin's permission
```
`isOwner` is the fact that is structurally stable: user id 1, pinned by a CHECK constraint, bypassing
every permission check. It will mean the same thing in a year. `Super Admin` might not.
A plugin that genuinely needs a third tier — "Admins may also do this" — should not get it by reading a
role. It should declare a second permission key and let the owner grant it. That keeps the answer to
"who may do this" on the permissions page, where the owner can see and change it, rather than compiled
into a plugin's `if`.
**Nothing may be trusted from the caller.** The proxy builds a fresh header object and copies a
three-item allowlist; it does not forward arbitrary inbound headers. That is why `X-Officer-User` is
safe to trust, and any new header inherits the same safety **only for as long as the allowlist stays an
allowlist**. If that loop is ever changed to a copy-everything-except, a client-supplied
`X-Officer-Owner: 1` becomes a privilege escalation against every plugin at once.
**Absent means denied.** A sidecar that does not see the headers — reached directly, an older platform,
a bug — must resolve to no privileges rather than to the owner. Same rule as `authorize.ts`, where every
catch returns deny.
## Where I disagree, and it is one point
Plugin-side enforcement has a failure mode the platform has already been bitten by, and it is worth
naming before it happens again rather than after.
`assertPermissionTotality` exists because a Member could 403 on `GET /api/tasks` and open
`/api/tasks/pipeline/ws` with a 101 in the same minute. The lesson written down at the time was
"**a patch does not survive the next door; refusing to boot does.**" Pure plugin-side authorization is
that same shape: jellyfin will check, because we are about to write the check. The next plugin's author
forgets one route, and nothing anywhere notices — no boot failure, no warning, no test.
So the recommendation is not "platform enforces instead". It is **declare in the manifest, enforce in the
proxy, and pass the facts to the sidecar as well**:
Built as a field on the permission rather than at the manifest root, following `readOnlyWrites` — both
are sub-paths written relative to the plugin's prefix with a rule attached, and one precedent is better
than two shapes:
```ts
// manifest.ts
permissions: [{ key: 'jellyfin', label: 'Jellyfin', description: '…', ownerOnly: ['/_config'] }],
```
- **Declared by the plugin**, because the plugin knows which of its paths are administrative.
- **Enforced by the platform**, in the proxy, before the request reaches the sidecar — so forgetting the
check inside the plugin is not a hole, and the rule holds for a route added later by someone who never
read this file.
- **Visible at install**, next to the permissions the owner already reviews, and greppable across every
plugin from one place.
- **Still passed to the sidecar**, because the plugin's own finer rules — whose rows, which fields — are
genuinely its business and cannot be expressed as a path prefix.
This does not contradict the `manifest.ts` comment above. That comment refuses to make a **permission**
owner-only, because a permission is a thing the owner delegates and one nobody can delegate is a
contradiction. A path prefix is not a grant; it is a statement that a route administers the plugin
rather than uses it.
## The frontend is a courtesy, not the lock
Hiding the Settings entry in the plugin's nav for a non-owner is worth doing, and it is not security.
Same rule as `usePermissions`, which fails open on purpose: hiding an entry is politeness, the 403 is the
lock. Write the guard on the route first and the nav second, never only the second.
## What must be settled before any of this lands
**A member with no server has no Jellyfin.** Connections are stored strictly per user — every query in
`plugins/jellyfin/db/queries.ts` filters on `eq(jellyfinServers.userId, userId)`. Lock `/_config` to the
owner today and a Member gets a plugin that can only ever show them the connect form they are forbidden
to submit.
The resolution that fits what already exists: a non-owner resolves to the **owner's active server URL**
authenticated with **their own token**, which provisioning already mints and stores in
`jellyfin_accounts`. That is arguably why those tokens are minted at all — per-user identity upstream, so
resume points, watched marks and next-up stay each person's own rather than everyone sharing the owner's.
That is a change to credential resolution, not to authorization, and it is the actual prerequisite. The
guard is a few lines; this is the work.
## Status
| | |
| ----------- | ------------------------------------------------------------------------------------- |
| Agreed | plugin decides what a level means inside its own surface |
| Agreed | pass `isOwner` and the effective level; never the role name |
| Agreed | fail closed when the headers are absent |
| Built | `ownerOnly` on a plugin permission, enforced by the gate before self-service and kind |
| Built | `X-Officer-Owner` and `X-Officer-Level`, sent when a proxy names its permission key |
| Built | manifest validation — a sub-path without a leading slash is a manifest error |
| Blocked on | credential resolution for non-owners, above |
| Not started | any plugin actually declaring one |
+134
View File
@@ -0,0 +1,134 @@
# Servers — the running list
Every machine the owner runs, how to reach it, and what of Officer is on it. Kept here so an
orchestrator ("pull and update every platform", "restart officer everywhere", "which host is behind")
has one place to read rather than a fact re-derived per session.
**Verified 2026-08-21** from `officer-dev` by SSH and `pm2 jlist`, except where marked assumed.
## How access works
All of it is over the tailnet, key-based, no `~/.ssh/config`, no passwords, no jump hosts, no ports.
**The login user is NOT always `pastilhas`.** It is per host and it is in the table below. `deadpool` runs
as `br` and always will — the owner's key is authorised for that account. An orchestrator that hard-codes
a username fails on that host and will fail on the next one like it.
```bash
ssh alpha # pastilhas, implied
ssh br@deadpool-rezio-bridge # not implied
```
## Officer platforms
Seven, all running.
| 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` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
| In `tailscale status` | Also | The machine's own hostname |
| ----------------------- | ---------------------------- | -------------------------- |
| `edge-bridge` | `edge-pertento-bridge` | `edge-pertento` |
| `gitea-bridge` | `production-pertento-bridge` | `PPROD` |
| `deadpool-rezio-bridge` | — | `deadpool` |
The middle column is what to use. `edge-bridge` and `gitea-bridge` are the old names and are being
retired; anything reading `tailscale status` will still see them.
### Things that will catch out an orchestrator
All verified, none cosmetic.
- **The checkout path differs per host** — four distinct shapes across seven machines. Take it from PM2:
the `officer` process's `pm_cwd`. `alpha` carries a SECOND, stale checkout at
`~/dockers/officer.dev/platform`, and pulling that one succeeds while changing nothing.
- **`pm2` is not on a non-interactive PATH.** It lives under nvm, and nvm's setup is in `.zshrc`, which a
_login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is **not** enough — it
reports "command not found" on machines where pm2 is plainly running, which cost two wrong conclusions
on 2026-08-21.
- **On `macbookpro`, APPEND to the PATH for `bun`. Never replace it.** This said "do not help the PATH"
until 2026-08-21, and that was half right in a way that cost a deploy step: `pm2` is only on the
interactive shell's PATH, so replacing it breaks `pm2` — but `bun` is at `/opt/homebrew/bin/bun` and
`.zshrc` does not put that directory on the interactive PATH either, so `bun run plugins:update` fails
`command not found` before it runs a line. `export PATH=$PATH:/opt/homebrew/bin` for that one command
shadows nothing: `pm2` stays at `~/.local/bin/pm2` and `node` at `node@22`. Run `pm2` as a separate
command.
- **`$SHELL -ic` on `macbookpro` aliases `ls` to `eza`, which is not installed.** A probe using `ls`
reports a missing file that is plainly there. Use `command ls`, or a non-interactive shell for file tests.
- **The git remotes differ.** `fofos` pulls over HTTPS; the others over SSH. Never assume a URL — pull in
place.
- **A "dirty tree" means TRACKED changes.** `git status --porcelain` counts untracked files, and a stray
script a fast-forward could never touch is not a reason to skip a host. Use `--untracked-files=no`.
- **`macbookpro` is a laptop.** It may be asleep. Unreachable is a skip with a note, not a failed deploy.
- **`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin — on
`alpha` that is twelve processes bouncing during the plugins step, before any restart the plan asked for.
Not a fault, but a deploy report that lists only `officer` under "restarted" is understating what moved.
- **`fofos` failed its capabilities sync for three deploys, and it was ownership.** Fixed 2026-08-21.
Two errors that read as unrelated problems — `dubious ownership` from git, and
`EACCES … mkdir '…/capabilities/affordances/officerdev'` from the gitea plugin — were one cause in two
dialects: fifty paths under that checkout were owned by `root`, including `.git` and `affordances`, all
stamped `19 Aug 21:43`. Something was run with `sudo` and left them behind. Mode `755` is why the split
looked odd: everything could be READ, so git saw the repo and refused it on ownership while the plugin
saw `affordances` and could not write into it.
One `stat` settled it, and that is the general lesson — `stat` prints the owner rather than inferring
anything, so it either proves ownership is the cause or eliminates it, in one command. Fixed with
`sudo chown -R pastilhas:pastilhas ~/officerdev/capabilities`; verified after by a `mkdir` into the
directory that used to refuse one.
**A `safe.directory` entry was deliberately NOT used**, and that call is worth keeping. It would have
silenced the git message and left the `EACCES` untouched — one symptom of a two-symptom cause hidden,
with the obvious explanation spent.
## Not platforms — ignore for now
A checkout may be present; no `officer` process runs on any of these. Whether they are retired, staging
or half-installed is **not established** — ask before treating them as targets.
| Host | Tailnet IP |
| ----------- | ---------- |
| `headscale` | 100.64.0.2 |
| `omega` | 100.64.0.3 |
| `gateway` | 100.64.0.4 |
## Other machines on the tailnet
- **Not SSH-reachable** (port 22 refused): `edge-dns` (100.64.0.8), `officer-bridge` (100.64.0.5).
Assumed service-only containers — not confirmed. `edge-bridge` was listed here until 2026-08-21 and was
wrong: its SSH was simply down at the time of checking.
- **Client devices, not servers:** `iphone16`, `andres-ipad`, `pixel9a`, plus other people's devices in
the tailnet (`diogos-ipad`, `iphone-diogo`, `iphone-bruno`, `fedras-iphone`,
`guilhermes-macbook-pro`, `macbook-pro-de-joo`).
## Known wrong, waiting on someone else
- **`officer.rezio.dev` resolves to 100.64.0.4** — that is `gateway`, not `deadpool` (100.64.0.21). Not
merely unavailable: pointed at a different machine, so it will serve the wrong box the moment gateway
answers on 443. Ports 80 and 443 are closed on `deadpool`, so nothing fronts it yet. Waiting on the
domain owner.
## Keeping this list true
It rots when a server is added, a checkout moves, a platform is retired, a remote changes or a login
differs. Re-verify with the four commands it was built from:
```bash
tailscale status
ssh <login>@<host> '$SHELL -ic "pm2 jlist"' # the officer entry's pm_cwd
ssh <login>@<host> 'git -C <cwd> remote get-url origin'
getent hosts <domain>
```
+149
View File
@@ -0,0 +1,149 @@
# Nightly visual regression, on a second server
**Plan, not built.** Written 2026-08-21.
Capture every route of a dashboard behind a JWT login, every night, on a machine nobody is sitting at —
and have an agent look only at what changed.
The target is **not** Officer and **not** this machine: a separate box running Officer with the `iris` and
`browser` plugins, pointed at a dashboard the owner runs elsewhere. Its login is a JWT, like Officer's.
## The shape
```
target server (Officer + iris + browser)
│
├─ cron, 03:00 ──▶ capture N routes with iris → runs/<date>/<route>.png
│ compare against runs/baseline/ → changed[]
│ if changed is empty → record and stop, wake nobody
│ else → open a chat turn with the changed pairs attached
│
└─ agent reads only the changed images, judges each: intended, or regression
→ notification with the verdict, and a link to the run
```
The expensive, fuzzy step happens only where pixels actually moved. On a quiet night the run costs a few
seconds of Chrome and nothing else.
## The hard part: a JWT lives in localStorage, and iris cannot put it there
This is the thing to solve first, because everything else is plumbing.
Cookie-authenticated sites work today: `iris-browser` copies `Cookies`, `Cookies-wal`, `Login Data` and
`Local State` out of a signed-in profile, so a capture rides the same session.
**A JWT dashboard is not cookie-authenticated.** The token sits in `localStorage`, which Chrome keeps in
`Default/Local Storage/leveldb/` — a directory `iris-browser` does not copy. So a capture of a
token-authenticated app renders the login screen, and does it silently: the screenshot is real, the page
is real, and it is the wrong page.
Three ways out, worth choosing between deliberately.
### A. Copy `Local Storage/leveldb` too — smallest change, biggest blast radius
One more entry in `seed_sessions`. It works, and it copies **every** site's localStorage into the
capture profile, not just the target's. That is someone's whole browser state in a temp directory, and
the directory is a LevelDB — copying it under a live browser can catch a compaction mid-write, which
Cookies-the-file mostly cannot.
Viable, and the least attractive of the three.
### B. Log in during the capture — no profile at all
Most JWT dashboards accept a token in the URL, or can be given one by a first-run page. If the target
does, then:
```
iris 'https://dash.example.com/?token=<jwt>' -o run/home.png
```
and there is no profile, no desktop, no signed-in browser anywhere. The token comes from a service
account minted for this, stored in Officer's secret store, and never appears in a screenshot.
**This is the recommended path** when the target supports it. It removes desktop from the picture
entirely, which removes the only reason that plugin was in this plan.
### C. Seed via the relay, capture via iris
Pair a browser once, use the relay to `evaluate` the token into `localStorage`, and let iris capture from
that profile. Works without changing either plugin, and needs a browser paired at 3am — which is exactly
the constraint iris exists to avoid.
Keep as the fallback for a target that supports neither a token URL nor a clean profile copy.
## What to capture
**Routes come from a list the repo owns**, not from crawling. A crawler finds what exists; a list says
what is supposed to exist, and a route disappearing is the regression you most want caught.
```jsonc
// routes.json, in the dashboard's own repository
[
{ "path": "/", "name": "home" },
{ "path": "/reports", "name": "reports", "waitFor": "[data-ready]" },
{ "path": "/reports/42", "name": "report-detail" },
]
```
`waitFor` maps to iris's `--wait-for`, which is what stops a spinner being the thing under test.
Two viewports — one desktop, one phone — doubles the run and catches the majority of layout breakage.
`--jobs` renders them in parallel.
## Diffing, before any agent is involved
Compare each capture with the same route's baseline. A pixel count, a threshold, nothing clever:
- **identical** → nothing happens
- **differs under threshold** (antialiasing, a clock, a random avatar) → nothing happens, but log it
- **differs over threshold** → into `changed[]`
The threshold exists because something on the page always moves. **Mask what is known to move** rather
than raising the threshold until it passes — a timestamp region is a rectangle to blank before comparing,
not a reason to accept 3% drift everywhere.
Officer has no image diff today. `sharp` is already a plugin dependency (atelier) and can do it; a raw
pixel loop over two decoded buffers is about thirty lines and needs nothing.
**Baselines are promoted, never automatic.** A run does not overwrite the baseline because it succeeded —
it does so when someone says "yes, that is the new correct". Otherwise a regression becomes the baseline
on night two and disappears.
## What reaches the agent
Only changed pairs, and each one as **two images plus the route name**: baseline and current. The
question asked is narrow, because a broad one gets a broad answer:
> This is `/reports` before and after. Describe what changed, then say whether it is a layout break, a
> content change, or noise. If you cannot tell, say so.
"If you cannot tell, say so" earns its place — a model asked to judge will judge, and a confident wrong
verdict on night four is how the whole thing stops being trusted.
The verdicts land in a notification (`officer-notify` is already core) with a count and a link, not the
images. Nobody wants twenty pictures on a phone at 3am.
## What does not exist yet
In rough order of how much they block:
1. **`--wait-for` against a JWT app** — solve auth first (§B), everything else is downstream.
2. **An image diff.** Thirty lines, or `sharp`.
3. **A scheduler.** Officer's queue has retries, not schedules — `scheduleRetry` is the only timed thing
in it. So this is system cron calling a script, or a `setInterval` in a plugin sidecar the way the
nudger does it. Cron is the honest answer for something that runs once a night.
4. **A place for runs and baselines.** `$OFFICER_ROOT/data/visual-regression/{baseline,runs}/` — outside
any plugin directory, because a plugin update replaces that.
5. **Whatever holds this.** A plugin of its own on that server, with `routes.json` as its config and a
panel showing the last run. It is not iris and it is not the relay; it is the thing that uses them.
## What this deliberately is not
**Not a test runner.** It proves a page renders and looks like it did yesterday. It cannot tell you a
button is wired to the wrong handler, and it should not be stretched to try — that is a real test suite,
and the two answer different questions.
**Not a crawler.** See routes, above.
**Not the browser relay.** The relay drives a live browser someone is signed into; this runs when nobody
is. They overlap only in that both produce screenshots. If §B holds, the relay has no role in this at all.
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "0.1.0",
"version": "2026.08.21.04",
"private": true,
"type": "module",
"workspaces": [
@@ -11,6 +11,10 @@
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
"gen:index": "bun run ./scripts/gen-index.ts",
"fix:env": "bun run ./scripts/fix-env.ts",
"todo:index": "bun run ./scripts/todo-index.ts",
"plugins:update": "bun run ./scripts/update-plugins.ts",
"nudge": "bun run --env-file=.env ./scripts/nudge-agent.ts",
"doctor": "bun run --env-file=.env ./scripts/doctor.ts",
"secret:set": "bun run --env-file=.env ./scripts/secret-set.ts",
"notify:test": "bun run --env-file=.env ./scripts/notify-test.ts",
"gen:ecosystem": "bun run --env-file=.env ./scripts/gen-ecosystem.ts",
+181
View File
@@ -0,0 +1,181 @@
# Kickoff prompt: extract Atelier into a plugin
Written 2026-08-20 for an **unattended overnight run**. That changes two things from
`EXTRACT-A-PLUGIN-PROMPT.md`, which assumes the owner is awake:
- **It does not say "ask and wait".** Nobody is there. Decisions that would normally be questions are
pre-made below, with reasoning, so the run does not stall on them.
- **It assumes you may be restarted.** Commit after every task, so whoever picks this up — including you,
an hour later, with no memory of it — finds a clean tree and a readable history.
Paste everything below the line into a fresh agent.
---
You are extracting **Atelier** out of `~/officer-dev/dockers/officer-atelier` and into an Officer plugin,
the way `music`, `offscale` and `gitea` were extracted before it.
**Work in a git worktree, not the main checkout.** Another agent is watching
`/home/pastilhas/officer-dev/platform` and pulling into it as you push. Two processes in one working tree
is how work gets lost — on 2026-08-20 it nearly did.
```
git -C /home/pastilhas/officer-dev/platform worktree add /home/pastilhas/officer-dev/atelier-work -b atelier-plugin
```
Everything below happens in `~/officer-dev/atelier-work`.
**Push every commit straight to master**, from the worktree:
```
git push origin HEAD:master
```
The branch is only there to keep your files out of the other checkout. Master is what is being reviewed.
Push after every task — a reviewer reading five small commits can tell you what went wrong on the second
one; nobody can do that with one commit at the end.
**Your final commit message must contain `ATELIER-COMPLETE` on its own line**, and no earlier commit may
contain that string. It is the signal that the run is finished and the review can start. Do not use it to
mean "this part is done".
## Read first, in this order
1. **`plugins/EXTRACTING-A-PLUGIN.md`** — the runbook and the authority. Read "What changed on
2026-08-16" carefully; it contradicts an earlier instruction in the same file, deliberately.
2. **`plugins/example/README.md`** — what a plugin is, as conventions.
3. **`src/servers/plugins/`** — `discover.ts`, `manifest.ts`, `mount.ts`, `install.ts`, `ecosystem.ts`.
Where this prompt and that code disagree, **the code is right**.
4. **`CLAUDE.md`** — house rules, the permission model, and why `assertPermissionTotality` refuses to boot.
5. **`docs/secret-store.md`** — Atelier holds an OpenAI key.
6. **`src/workspaces/officerdev/APP_CONVENTIONS.md`** and `HOOK_CONVENTIONS.md` — you are writing panels.
## What Atelier is
Image generation as a service: a provider-shaped API over OpenAI's image endpoints, storing what it makes
so every image has a permanent URL. Bun · Hono · Postgres/Drizzle · htmx with Hono JSX · a React SPA.
```
84 ts/tsx files, ~6,400 lines
tables collections, provider_settings (its own `atelier` database)
routes /generations, /generations/:id, /edits, /adapters,
/:id/items, /:id/items/:generationId, mask
env BUN_PORT, PUBLIC_URL, POSTGRES_URL, ATELIER_DATA_DIR,
OPENAI_API_KEY, ATELIER_MAX_CONCURRENCY
runs as pm2 `officer-atelier`, cwd ~/officer-dev/dockers/officer-atelier
```
It is a **sidecar** plugin for one reason that survives the UI moving into Officer: generation is
long-running, capped by `ATELIER_MAX_CONCURRENCY`, and holds a credential the platform must not. The UI
is Officer's; the work is the sidecar's.
## Decisions already made — do not re-open these
Each of these would normally be a question. They are answered so you can keep moving. If you find
evidence that one is wrong, **write it in `BLOCKERS.md`, follow the decision anyway, and carry on** — a
half-applied decision is worse than a wrong one consistently applied.
**1. The UI is PORTED into Officer. No proxy, no iframe, no sidecar-served UI.** Atelier was a separate
server because that was the quick answer at the time; this extraction is what undoes that. It becomes part
of Officer, not something Officer frames.
It is smaller than the repository suggests:
```
src/apps/frontend 1,736 lines / 25 files
6 of those components/ui/* — shadcn Officer ALREADY HAS. Delete, import from @/components/ui.
real work Generate · Gallery (+ Lightbox) · Collections · Providers ≈1,200 lines
src/apps/web the htmx half — 8 files, DELETED, not ported
```
So: three panel apps — **Generate**, **Gallery**, **Collections** — under
`plugins/atelier/web/`, each exporting `appRegistryMetas`, with a `layout.ts` composing them. Follow the
frontend route conventions in `CLAUDE.md`: selection lives in the URL, rows are real links, no
`usePanelChannel` for "which thing is open".
The fourth feature, **Providers**, is not a panel — see the next decision.
**2. Providers become Officer settings, with more than one.** `features/Providers/` is a settings screen,
and Officer has a settings surface already. It moves there rather than becoming a panel.
`src/servers/providers/` is already adapter-shaped — `types.ts`, `openai.ts`, `credentials.ts`, an
`index.ts` that dispatches. Keep that shape and keep the seam: today OpenAI is the only adapter, and the
point of moving the settings is that a second one is a new file rather than a new screen. **Do not add a
second provider in this run** — leave the shape able to take one and say so in `PLUGIN.md`.
**3. The database: check the rows, then decide.** Atelier uses a separate `atelier` Postgres database,
deliberately — `src/databases/atelier_db/drizzle.config.ts` says why. Plugin tables normally live in
`officer_db` with a prefix.
- Count the rows in `collections` and `provider_settings` first.
- **If both are empty**, fold them into `officer_db` as `atelier_collections` and
`atelier_provider_settings`, following `plugins/*/db/schema.ts`. Two tables do not earn a second
database.
- **If either has rows**, keep the separate database for now and record the migration as a follow-up in
`BLOCKERS.md`. Losing someone's generated images to a schema decision at 4am is not a trade worth making.
**4. Artefacts: `$OFFICER_ROOT/data/atelier/`.** Generated images are files, and `ATELIER_DATA_DIR`
currently points wherever its `.env` says. A plugin directory is a git clone that an update replaces, so
artefacts kept there vanish on upgrade with nothing logging it. `plugins/kiosk/artefacts.ts` has the same
problem solved and the comment explaining it.
**5. `OPENAI_API_KEY` goes in the secret store**, not `.env`. `putSecret`/`getSecret` landed 2026-08-19;
`bun run secret:set atelier-openai-key` puts it there. Read with `getSecret`, **never `getKey`** — `getKey`
mints a random value for an absent purpose, which for a foreign credential means silently authenticating
with a fake. Fall back to env when the store has nothing, so an install that predates this keeps working.
**6. One permission, `atelier`, `kind: 'app'`.** Image generation is not `execution` — it does not run as
the owner's OS user in the owner's home.
## How to work
**Map before you move.** The first task is not a file move. Find every line in the platform that names
atelier, and every import pointing INTO it. A plugin may import platform code (`@@/…`); the reverse cannot
happen. Report what you find before moving anything.
**Commit after every task, and push.** You may be restarted at any point by a scheduler that has no idea
what you were doing. A clean tree and a commit message are how the next attempt — possibly you — knows
where it is. Commit messages: simple lowercase, no prefixes.
**Do not restart the platform.** `pm2 restart officer` breaks the owner's chat session. Write the command
down instead; they will run it.
**Do not `git add -A`.** Add the paths you touched. This has caused two commits to swallow another
agent's work in one day.
**Format only what you touched** — `bunx prettier --write <paths>`, never `bun format`.
**`bunx tsgo` must be clean** before every commit. Not `tsc`.
## When you get stuck
Write it in `BLOCKERS.md` at the worktree root — what you were doing, what stopped you, what you did
instead, and whether it is reversible. Then **move to the next task**. Do not wait, do not invent a
workaround that changes behaviour without saying so, and do not stop the run.
If invented content ends up anywhere durable — copy, defaults, seed data — mark it `[placeholder]` so it
can be found later with a grep.
## Done means
- `plugins/atelier/` with `manifest.ts`, `api/router.ts`, `sidecar/index.ts`, `web/panels.ts`,
`web/layout.ts`, `db/`, `assets/icon.png`, `assets/splash.png`, and its own git repository.
- It installs from the Marketplace onto a machine that has never had it, and the panel renders.
- `bunx tsgo` clean, `bun test` passing.
- A `PLUGIN.md` in the plugin recording what moved, what did not, and why — the way `music` and `offscale`
did. This is the part that is worth most in three months.
- `BLOCKERS.md` listing every decision you made without being able to ask.
## What you may NOT do
- No unauthenticated routes. A plugin cannot serve one; `UNPROTECTED_API_PREFIXES` is hardcoded in the
platform. If Atelier needs one, that is a blocker, not something to work around. (`plugins/kiosk`
is stuck on exactly this — read its README before concluding you have found a way.)
- **Generated images are served through an authenticated route**, like every other plugin artefact. Every
image having "a permanent URL" was true of a standalone server with its own `PUBLIC_URL`; inside Officer
the URL is behind the session. If something needs an image without one — an `<img>` in an email, a
share link — that is a blocker to write down, not a route to open. See the image-rendering affordance
in `capabilities/affordances/officer/core/` for why an `<img>` cannot carry a token.
- No changes to `src/servers/plugins/` to make Atelier fit. If the contract does not accommodate it, that
is a finding worth more than the workaround.
- No second agent, no second worktree, no background job that writes to this tree.
+22 -14
View File
@@ -78,9 +78,11 @@ Music added the field; see its PLUGIN.md for what it is guarding against.
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
anywhere else means two systems, and only one gets tested.
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
on uninstall. Only deleting a plugin's source can lose its data.
**Only an explicit tick destroys data.** The generated schema barrel follows plugin **directories**, not
the install table — `db:push` drops what it cannot see, so following installs would delete a plugin's
tables the moment it was stopped. Deleting a plugin removes its directory, and the confirmation's
"also delete all its data" checkbox is the one thing that drops its tables. Unticked, the rows outlive
the plugin and show up in the orphan list on the same screen.
---
@@ -125,9 +127,9 @@ Then through `/plugins`, watching PM2 and the browser at each step:
| permissions page | its permission is listed, read/write/none |
| disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** |
| `bun db:push` while uninstalled | **do not run this — see "What changed on 2026-08-16"** |
| install again | identical to the first install |
| delete, checkbox unticked | route gone, `pm2 list` loses it, directory gone, **data still there** |
| install again | fresh clone, and it finds those rows |
| delete, checkbox ticked | tables dropped too — a machine that never had it |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
@@ -220,15 +222,21 @@ no terminal it applies NOTHING and exits 0, so an install reports success and th
query with 42P01. `--force` would be worse if it worked: the destructive statements are `drop table` for
every plugin whose directory is absent.
So: the row in the verification table that says to run `bun db:push` while uninstalled is struck out. The
property it was checking — uninstall does not destroy data — is now checked by reinstalling and finding
your rows still there.
So: the row in the verification table that said to run `bun db:push` while stopped is gone. The property
it was checking — stopping a plugin does not destroy data — is now checked by deleting it with the
checkbox unticked, installing again, and finding your rows still there.
**Use `purge` to test the install twice.** Uninstall deliberately keeps the directory and the tables, so a
second install is not a fresh one: the marketplace hides a plugin whose directory exists, and the tables
are already there. The Marketplace screen has "Delete <plugin> and all its data" — it drops the tables,
deletes the directory and regenerates the barrel, and afterwards the plugin installs from the catalogue
exactly as a stranger's machine would. **That is the only honest test of an install.**
**Delete WITH the checkbox to test the install twice.** Anything less is not a fresh install: the
marketplace hides a plugin whose directory exists, and tables that are already there make `db:push` a
no-op, so a broken schema stays broken and passes. Ticked, it drops the tables, deletes the directory and
regenerates the barrel — and afterwards the plugin installs from the catalogue exactly as a stranger's
machine would. **That is the only honest test of an install.**
**Uninstall was removed on 2026-08-21.** It sat between disable and delete, keeping the directory and the
data, and the screen could not show the difference: an uninstalled plugin stayed in the list under a
heading that said "Installed" while its directory silently decided what the next install would run. That
is not hypothetical — it is how a jellyfin install on alpha re-applied a checkout from two days earlier,
reporting success at every step. The verbs are now enable, disable and delete.
**The install now verifies its own tables.** After the push it asks Postgres whether each declared table
exists and fails the install naming what is missing. If your extraction reports "schema push reported
+240
View File
@@ -0,0 +1,240 @@
# Provisioning a user in a plugin's upstream service
> **Handing this to a fresh agent?** Everything below was verified against a live instance on
> 2026-08-19 — every status code, every error body. Trust it over the upstream's own docs, which
> lag. Re-verify with the probe sequence at the end if the upstream has been upgraded since.
Officer creates the account; the upstream service owns it. This is the contract for doing that
safely, written from the **`photos` plugin** (Immich) as the first worked example. The shape
generalises: authenticate as an admin, check-then-create, verify the result, hand over a one-time
password.
**This directory holds documentation, not plugins.** The plugin itself lives in
[`plugins/photos`](https://gitea.officer.dev/plugins/photos).
---
## The four rules
1. **Never hardcode the admin credential.** Read it from the environment or the platform's
credential store, and never log it. Docs and repos get shared; credentials must not travel
with them.
2. **Check before you create.** None of these APIs upsert. A second call with the same email is
an error, not a no-op — so provisioning must be re-runnable by listing first.
3. **Force a password change.** Generate a strong random password, mark it as
must-change, and treat it as a one-time handover value. It stops being a secret the moment
the user logs in.
4. **Verify by logging in.** A `201` means the row was written, not that the account works. Do a
real login before reporting success, so failures surface at provisioning time rather than when
the person first tries to use it.
---
## Worked example — Immich (`photos` plugin)
Verified against **Immich 3.1.0**.
| | |
|---|---|
| API root | `<base>/api` — e.g. `http://127.0.0.1:2283/api` |
| Auth | header `x-api-key: <ADMIN_API_KEY>` |
| Admin routes | `/api/admin/*` — reject non-admin keys with `401` |
Immich stores API keys **SHA-256 hashed**, so a lost key can only be replaced, never recovered.
Mint one in the UI → Account Settings → API Keys.
### 1. Check (idempotency guard)
```http
GET /api/admin/users
x-api-key: <ADMIN_API_KEY>
```
→ `200`, JSON array. If the target email is present, stop — already provisioned.
### 2. Create
```http
POST /api/admin/users
x-api-key: <ADMIN_API_KEY>
Content-Type: application/json
{
"email": "person@example.com",
"password": "<generated>",
"name": "Full Name",
"shouldChangePassword": true
}
```
**Required:** `email`, `password`, `name`.
| Optional field | Type | Notes |
|---|---|---|
| `shouldChangePassword` | bool | `true` ⇒ forced reset at first login. Always send it. |
| `quotaSizeInBytes` | int | e.g. `1073741824` = 1 GiB. Omit for unlimited. |
| `storageLabel` | string | Folder name used by the storage template. |
| `notify` | bool | Emails the new user. Requires SMTP configured upstream. |
→ **`201 Created`**, body carries the new `id`:
```json
{ "id": "70ee4cd2-…", "email": "person@example.com", "name": "Full Name",
"isAdmin": false, "shouldChangePassword": true, "quotaSizeInBytes": null }
```
`isAdmin` cannot be set at creation — promote afterwards via the admin update endpoint.
Names are full Unicode (`Bruno Conceição`); send UTF-8, no escaping.
### 3. Verify
```http
POST /api/auth/login
Content-Type: application/json
{ "email": "person@example.com", "password": "<generated>" }
```
→ `200` with `{"name":…,"userEmail":…,"isAdmin":false,"shouldChangePassword":true}`.
No API key — this is the ordinary user login path, which is exactly why it proves the account works.
### Errors (observed, not guessed)
| Condition | Status | Body |
|---|---|---|
| Email already exists | `400` | `{"message":"Email is not available"}` |
| Missing `password` / `name` | `400` | `{"message":"Validation failed","errors":[{"path":["password"],…}]}` |
| Bad or missing API key | `401` | — |
### Deprovisioning
```http
DELETE /api/admin/users/{id}
Content-Type: application/json
{ "force": true }
```
→ `200` with `{"status":"removing","deletedAt":"…"}`. Soft-delete; `force` skips the grace period.
---
## Worked example — Jellyfin (`jellyfin` plugin)
Same four rules, **different shape**. Read this section as: the route surface and auth model are
**verified** against Jellyfin **10.11.11**; the request bodies are from working knowledge and
marked as such — confirm them with the probe recipe below before relying on them in code.
### How it differs from Immich — read this first
| | Immich | Jellyfin |
|---|---|---|
| Auth header | `x-api-key: <key>` | `Authorization: MediaBrowser Token="<key>"` |
| Create + configure | one call | **two or three** — create, then policy, sometimes password |
| Force password change | `shouldChangePassword: true` | **no equivalent** — see below |
| Scope of an account | one server | **per instance** — see below |
Two consequences that bite:
1. **There is no "must change password" flag.** Rule 3 cannot be enforced by the server.
**Decision for this deployment (2026-08-20): set a generated password at creation and tell the
user to change it.** Do *not* use the blank-password alternative — Jellyfin permits it, but the
account is then open to anyone who can reach the port until the user sets one.
So for Jellyfin, rule 3 becomes: still generate a strong unique password, still treat it as a
handover value, but the change is advisory rather than forced — which means **never reuse a
generated password across users or instances**, since it may stay valid indefinitely.
2. **Users are per-instance, not global.** This deployment runs **four** independent Jellyfin
servers, each with its own user database and its own API keys:
| Instance | Port | Server name |
|---|---|---|
| `jellyfin` | 9301 | Jelly |
| `jellyfin_music` | 9302 | Albums |
| `jellyfin_dj_sets` | 9303 | DJ_Sets |
| `jellyfin_diogo` | 9399 | JellyDiogo |
Provisioning "a Jellyfin user" therefore means looping over whichever instances that person
should reach, with a **separate API key per instance**. There is no cross-server account.
### Getting an API key
Unlike Immich, Jellyfin has **no admin-token bootstrap from nothing** — creating a key requires
an already-authenticated session. Two routes:
- **UI:** Dashboard → API Keys → `+`. Simplest, and the one to use for first setup.
- **API:** `POST /Auth/Keys?App=<name>` (verified: route exists, `401` unauthenticated), using a
token obtained from `POST /Users/AuthenticateByName` with an admin username/password
(verified: route exists; returns `415` if you omit the JSON content type).
`GET /Auth/Keys` lists them (verified: exists, `401` unauthenticated). Keys are stored in
`config/data/jellyfin.db`, table `ApiKeys`. **This deployment currently has zero API keys** — one
must be created before any of the flow below will work.
### The flow
```http
GET /Users
Authorization: MediaBrowser Token="<API_KEY>"
```
→ verified: exists, `401` without auth. Use it as the **check** step (rule 2) — match on `Name`.
```http
POST /Users/New
Authorization: MediaBrowser Token="<API_KEY>"
Content-Type: application/json
{ "Name": "Full Name", "Password": "<generated>" }
```
→ verified: route exists (`401` unauthenticated). Body shape from working knowledge. Returns the
created user object including its `Id` (a GUID), which the next calls need.
Then, as needed:
| Purpose | Endpoint | Notes |
|---|---|---|
| Library access, admin rights, parental rating | `POST /Users/{id}/Policy` | Full policy object; **send the whole thing**, it replaces rather than merges. This is where `IsAdministrator` and `EnabledFolders` live. |
| Set/replace password | `POST /Users/{id}/Password` | Needed if you created the user without one. |
| Delete | `DELETE /Users/{id}` | Hard delete — no soft-delete/grace period, unlike Immich. |
**Verify (rule 4)** with a real login rather than trusting the create:
```http
POST /Users/AuthenticateByName
Content-Type: application/json
{ "Username": "Full Name", "Pw": "<generated>" }
```
→ returns an `AccessToken` and the `User` object on success. Note the field is `Pw`, not
`Password`, and Jellyfin authenticates by **username**, not email — Jellyfin has no concept of an
email login.
### A public endpoint worth knowing
`GET /Users/Public` needs **no authentication at all** (verified: `200`). It lists users flagged
as visible on the login screen. On this deployment it returns `[]` on all four instances, so
nothing is currently exposed — but if you provision users, be aware that leaving them
login-screen-visible publishes their names to anyone who can reach the port.
### Probe recipe (Jellyfin)
Same spirit as the Immich one, but you need a key first, and there is no soft-delete — so probe on
a **non-production instance** or be prepared to hard-delete:
1. `GET /Users` with the key → `200` and an array. Wrong key → `401`.
2. `POST /Users/New` with a throwaway name → expect the created object; keep the `Id`.
3. Repeat the identical call → observe the duplicate-name behaviour and **record the exact status
and body here**, since it is the guard rule 2 depends on.
4. `POST /Users/AuthenticateByName` with the throwaway credentials → expect an `AccessToken`.
5. `DELETE /Users/{id}` → then `GET /Users` to confirm it is gone.
---
## Re-verifying after an upstream upgrade
Cheap and non-destructive — create a probe user on an unroutable domain, exercise it, delete it:
1. `POST /api/admin/users` with `probe-delete-me@example.invalid` → expect `201`, keep the `id`.
2. Repeat the identical call → expect the duplicate-email `400`.
3. `POST` with `password`/`name` omitted → expect the validation `400`.
4. `POST` with a bogus key → expect `401`.
5. `DELETE /api/admin/users/{id}` with `{"force":true}` → expect `200`, then `GET` to confirm it's gone.
Use `.invalid` (RFC 2606 — guaranteed never to resolve) so a stray notification email can never
reach a real person.
+209
View File
@@ -0,0 +1,209 @@
# Deploy commander
Start a commander with this. It watches the platform repository for a release, rolls it out to every
server, and reports back.
**It never decides what a deploy needs.** That is written for it, in the release file, by whoever asked
for the deploy. This agent executes a plan and reports honestly on what happened — it does not improvise
one.
---
## What wakes you
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
there is a release to deploy that has not been deployed.
```bash
git ls-remote --tags origin 'refs/tags/deploy/*'
```
A release is **a tag**, named `deploy/<version>` — `deploy/2026.08.21.01`. Nothing else triggers you: not
a commit message, not a branch, not a push.
**Why a tag and not a code word in a commit.** A code word can be written by accident. Every document
about deploying contains it, this file included, and so does every conversation planning one — so the
trigger has to be defended with rules about where in the subject line it sits, and a rule like that is
one somebody eventually gets wrong. A tag cannot be typed into prose. It is created deliberately, it
names its own version, and `git ls-remote` answers "is there one" in a single call without fetching.
- **No untagged release** → say "no release pending" and stop. Do not pull, do not touch a server.
- **A `deploy/*` tag with no local record** → that is the release. Continue.
### One attempt per release, and you do not enforce that yourself
**Before you spawn or run anything for a version, claim it:**
```bash
scripts/deploy-claim.sh claim <version> # exit 0 = it is yours, exit 1 = do not proceed
```
Exit 1 means somebody already attempted this release. **Stop. Do not retry it, do not deploy it, and do
not decide the previous attempt does not count.** Say what the script said and move on.
On success, record it through the same script:
```bash
scripts/deploy-claim.sh done <version>
```
**Why this is a script and not a rule you follow.** A watcher loop is written fresh each time one arms, so
a rule that lives only in this file gets re-implemented from scratch every time — and the wrong version of
it looks right: test for the `.done` file, which exists only on SUCCESS. Write that and a failing release
spawns a new agent every poll, all night, each one costing money. That is not hypothetical. It is what the
first watcher did on 2026-08-21: a careful loop, honest logging, and an unbounded retry inside it.
So the rule is code. `mkdir` is the claim — atomic, one syscall that both tests and takes, no window
between checking and acting. The script refuses a second claim and keeps refusing, whatever happened to the
first attempt.
**A failed release is therefore not retried. It is looked at.** Clearing a claim is a deliberate human act
(`rm -rf …/<version>.claim`) or a new tag. A loop cannot undo its own claim, which is the property that
makes the storm impossible rather than unlikely.
### The record is LOCAL, not in git
```
$OFFICER_ROOT/data/deploys/<version>.done
```
That file's existence means deployed. Written after the rollout, before the report is pushed.
**This is deliberate and it is the important part.** The obvious design makes the pushed report the
marker — and then a failed `git push` means the release looks undeployed and the next tick **does it all
again**. Pushes here are genuinely unreliable: gitea's public SSH is rate-limited because every
connection arrives from one proxy container IP and internet bots get it penalised, which cost several
retries on 2026-08-21.
So the lock is local and cannot fail for a network reason. The report is an ARTEFACT — valuable, and not
load-bearing. A missing report is a missing record; a missing lock is a second deploy.
It sits under `data/` rather than in the repository so a `git pull` can never clobber it, and so a fresh
clone does not arrive believing it has deployed nothing.
## The release
The tag names the version. The commit it points at carries the rest:
- `package.json` — `version` matching the tag, `yyyy.mm.dd.NN` (`2026.08.21.01`), the mobile apps'
scheme. `NN` increments for a second deploy on the same day.
- `deploy/<version>.md` — **the plan**. What to run, in order, and on which hosts. Written by hand.
If the tag and `package.json` disagree, **stop and say so**. One of them is a mistake and guessing which
is how the wrong tree gets deployed.
If the plan file is missing, **stop and say so**. A release with no plan is not a release you can guess
at, and guessing is how a database gets a schema it was not ready for.
---
## The servers
**Read `docs/servers.md`.** Do not take a list of hosts from this file — there is deliberately none here,
because a copy rots the day a server moves and the copy is always the one somebody trusts.
That document holds every machine, its tailnet name, **its login user**, its checkout and whether a
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
Four things that will catch you out, all verified rather than assumed:
- **Never hard-code the path.** Take it from PM2: the `officer` process's `pm_cwd`. `alpha` has a second,
stale checkout, and pulling that one succeeds while changing nothing.
- **`pm2` is not on a non-interactive PATH.** It is installed under nvm, and nvm lives in `.zshrc`, which
a _login_ shell does not read. Use `ssh <host> '$SHELL -ic "pm2 jlist"'`. `-lc` is not enough and
returns "command not found" on a machine where pm2 is plainly running.
- **The git remotes differ.** `fofos` pulls over HTTPS; others over SSH. Never assume a URL — just
`git pull --ff-only` in place.
- **`macbookpro` is macOS and is a laptop.** It may be asleep. Unreachable is a SKIP with a note, not a
failure of the deploy.
---
## Rolling it out
Per host, in this order. Do them **one host at a time** and keep a record as you go — a report you
assemble at the end from memory is a report that will be wrong about the host that failed.
1. **Refuse a dirty tree — TRACKED changes only.** `git status --porcelain --untracked-files=no`. Plain
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
checkout.
4. **Run the plan's commands** for that host, in the order given. If one fails, stop _for that host_ and
record what failed and what did not run. Do not continue past a failed `bun install` or `db:push`.
5. **Restart what the plan names**, and nothing else. Never `pm2 restart ecosystem.config.cjs` — it
bounces every sidecar including the vault.
6. **Check it came back.** `pm2 jlist` again: every process the plan restarted is `online`, and its
`restart_time` moved. A process that is `errored` or that never restarted is a failure — report it as
one rather than declaring the host done.
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
one that stopped in the middle and said nothing about the four hosts it never reached.
### Skip the host you are running on
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
You are a session inside `officer-claude-code` on that machine. Restarting it ends you mid-rollout —
which is not a hypothetical: on 2026-08-21 a manual deploy reached its own host last and had to leave
that one sidecar alone to survive writing its own report.
Restarting only `officer` and leaving your own sidecar behind would work, and it is worse: it puts one
host in a state nobody chose, a step behind the others, discovered later. So skip the whole host and say
which one. The owner restarts it deliberately, as the last step, once they are happy with the rest.
Find it with `hostname` and match it against the list — do not assume you are on any particular one.
---
## The report
Write the local record FIRST — `$OFFICER_ROOT/data/deploys/<version>.done` — then commit
`deploy/<version>.report.md` and push it.
That order matters. The record is what stops a second deploy, and the push is the part that can fail for
reasons having nothing to do with the rollout. **If the push fails, say so and stop — do not retry into a
loop and do not treat it as a failed deploy.** The deploy succeeded; its paperwork is pending.
Never tag the report. A tag is the trigger, and a report that tags itself deploys itself forever.
Per host: what its HEAD was before and after, which steps ran, which were skipped and why, which
processes restarted, and whether they came back. Then one line at the top saying how many hosts
succeeded out of how many.
Say what you did not do. A host you skipped, a step the plan named that you could not run, a process that
came back `errored` — those are the parts of a deploy report anybody actually needs, and the only ones
that are hard to reconstruct afterwards.
---
## Being woken
A background job does it, armed from the composer's background-job dialog with **Exit and hand back to the
agent, then re-arm**. The wait happens in the shell, not in a model tick — a loop polling
`git ls-remote`, which is one network call and no tokens.
The loop cannot do a deploy: it cannot read a plan, judge a host or write a report. So on firing it spawns
a headless agent — `claude -p "<read this file and follow it>" --dangerously-skip-permissions
--output-format=stream-json --verbose` — and pipes the events through `jq` so the job panel shows progress
instead of a blank box for twenty minutes.
Two things that loop must get right, both learned by getting them wrong on 2026-08-21:
- **Claim the version first**, per the section above. This is the one that costs money when missed.
- **Set `OFFICER_ROOT` explicitly.** It is empty in the sidecar's environment, so the lock path resolves to
`/data/deploys` and the release looks permanently undeployed.
The deploy therefore runs in a **separate** agent, not in the commander's own conversation: no Command HQ
row, no cost on the commander's line, and its reasoning lives in that child's transcript. That is the price
of putting the wait in the shell, and it is worth it — a model-side tick that re-reads a whole conversation
to learn nothing is the thing being avoided.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+8 -7
View File
@@ -1,4 +1,4 @@
<svg width="1200" height="240" viewBox="-20 40 1200 260" xmlns="http://www.w3.org/2000/svg">
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="300" viewBox="60 40 900 300">
<defs>
<filter id="shadow3d-main-officer">
<feOffset dx="6" dy="6" in="SourceGraphic" result="offset1"/>
@@ -25,25 +25,26 @@
</pattern>
<mask id="textMaskOfficer">
<text x="580" y="220" text-anchor="middle" class="officer-text-main" fill="white">o<tspan dx="6" dy="30" letter-spacing="14">ff</tspan><tspan dx="-2" dy="-30">icer.dev</tspan></text>
<text x="510" y="220" text-anchor="middle" class="officer-text-main" fill="white">Of<tspan dx="16">f</tspan><tspan dx="10">i</tspan><tspan dx="10">c</tspan><tspan dx="10">e</tspan><tspan dx="10">r</tspan></text>
</mask>
<style>
.officer-text-main {
font-family: system-ui, -apple-system, sans-serif;
font-family: system-ui, -apple-system, "System Font", ".SF NS", "Helvetica Neue", sans-serif;
font-size: 140px;
font-weight: 900;
font-weight: 700;
fill: #F4C430;
stroke: #14532D;
stroke-width: 3;
paint-order: stroke fill;
letter-spacing: -2px;
text-transform: lowercase;
letter-spacing: 8px;
font-variant-ligatures: none;
font-feature-settings: "liga" 0, "clig" 0, "dlig" 0;
}
</style>
</defs>
<text x="580" y="220" text-anchor="middle" class="officer-text-main" filter="url(#shadow3d-main-officer)">o<tspan dx="6" dy="30" letter-spacing="14">ff</tspan><tspan dx="-2" dy="-30">icer.dev</tspan></text>
<text x="510" y="220" text-anchor="middle" class="officer-text-main" filter="url(#shadow3d-main-officer)">Of<tspan dx="16">f</tspan><tspan dx="10">i</tspan><tspan dx="10">c</tspan><tspan dx="10">e</tspan><tspan dx="10">r</tspan></text>
<rect x="0" y="0" width="1160" height="300" fill="url(#gridPatternOfficer)" mask="url(#textMaskOfficer)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# One attempt per release, enforced by the filesystem.
#
# A watcher loop asks this before it spawns anything. Exit 0 means "you have the claim, go". Exit 1 means
# "somebody already had it" — and that answer never changes back, whatever happened to the attempt that
# held it. A release that fails is not retried; it is looked at.
#
# WHY THIS IS A SCRIPT AND NOT A PARAGRAPH IN prompts/deploy-commander.md
#
# The watcher loop is written fresh by an agent each time it arms, so a rule that lives only in prose is
# re-implemented from scratch on every arming, and "skip a version already attempted" has a wrong version
# that looks right: test the .done file, which only exists on SUCCESS. Write that and a failing deploy
# spawns a fresh agent every poll, all night, each one costing money. That is exactly what happened on
# 2026-08-21 — the loop was careful, logged honestly, and still had an unbounded retry in it.
#
# WHY BASH AND NOT bun
#
# The thing that stops a retry storm must not depend on the checkout being healthy. A deploy that dies on
# `bun install` is precisely when this has to work.
#
# WHY mkdir
#
# It is atomic on every POSIX filesystem and it fails if the target exists — one syscall that both tests
# and claims. `[ -f x ] && ...` is two steps with a race between them, and two pollers a second apart is
# not a hypothetical when a watcher is restarted.
set -u
DEPLOYS_DIR="${DEPLOYS_DIR:-$HOME/officer-dev/data/deploys}"
usage() {
cat >&2 <<'USAGE'
usage: deploy-claim.sh claim <version> take the one attempt for this release (exit 0 = yours, 1 = taken)
deploy-claim.sh done <version> record that the attempt succeeded
deploy-claim.sh state <version> print unclaimed | claimed | done
deploy-claim.sh list every release this host knows about
To retry a release deliberately: rm -rf <deploys-dir>/<version>.claim
That is a human act on purpose. A loop cannot undo its own claim.
USAGE
exit 2
}
version_ok() {
# The version reaches here from a git tag, so it is attacker-adjacent input used to build a path.
case "$1" in
*[!0-9.]* | '' | *..*) return 1 ;;
*) return 0 ;;
esac
}
CMD="${1:-}"
[ -n "$CMD" ] || usage
case "$CMD" in
list)
mkdir -p "$DEPLOYS_DIR"
found=0
for d in "$DEPLOYS_DIR"/*.claim; do
[ -e "$d" ] || continue
found=1
v=$(basename "$d" .claim)
if [ -f "$DEPLOYS_DIR/$v.done" ]; then s="done"; else s="claimed, no success recorded"; fi
printf '%-16s %s\n' "$v" "$s"
done
[ "$found" = 1 ] || echo "no releases attempted on this host"
;;
claim | done | state)
V="${2:-}"
[ -n "$V" ] || usage
version_ok "$V" || { echo "deploy-claim: refusing a version that is not digits and dots: $V" >&2; exit 2; }
mkdir -p "$DEPLOYS_DIR" || exit 2
CLAIM="$DEPLOYS_DIR/$V.claim"
DONE="$DEPLOYS_DIR/$V.done"
case "$CMD" in
state)
if [ -f "$DONE" ]; then echo done
elif [ -d "$CLAIM" ]; then echo claimed
else echo unclaimed; fi
;;
claim)
# A pre-existing .done with no .claim is a release deployed before this script existed. Honour it.
if [ -f "$DONE" ]; then
echo "deploy-claim: $V is already deployed" >&2
exit 1
fi
if mkdir "$CLAIM" 2>/dev/null; then
printf '%s claimed on %s by pid %s\n' "$(date -Iseconds)" "$(hostname)" "$$" > "$CLAIM/by"
echo "deploy-claim: $V is yours"
exit 0
fi
printf 'deploy-claim: %s was already attempted — %s\n' "$V" "$(cat "$CLAIM/by" 2>/dev/null || echo 'claim exists')" >&2
echo "deploy-claim: NOT retrying. Look at what failed, then rm -rf $CLAIM to allow another attempt." >&2
exit 1
;;
done)
[ -d "$CLAIM" ] || echo "deploy-claim: warning — recording success for $V that was never claimed" >&2
printf '%s succeeded on %s\n' "$(date -Iseconds)" "$(hostname)" >> "$DONE"
echo "deploy-claim: $V recorded as deployed"
;;
esac
;;
*) usage ;;
esac
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const SCRIPT = join(import.meta.dir, 'deploy-claim.sh');
let dir: string;
const run = (...args: string[]) => {
const p = Bun.spawnSync([SCRIPT, ...args], { env: { ...process.env, DEPLOYS_DIR: dir } });
return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() };
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'deploy-claim-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
// The whole point of this script is that the SECOND caller is refused. Everything else is scaffolding.
describe('deploy-claim', () => {
it('grants once and refuses after, whatever the outcome was', () => {
expect(run('claim', '2026.09.01.01').code).toBe(0);
expect(run('claim', '2026.09.01.01').code).toBe(1);
// Still refused after a failure — no .done was ever written. This is the retry storm not happening.
expect(run('claim', '2026.09.01.01').code).toBe(1);
expect(run('state', '2026.09.01.01').out.trim()).toBe('claimed');
});
it('refuses a release already deployed, even with no claim beside it', () => {
// A release deployed by hand before this script existed. `deploy/2026.08.21.01` is exactly this case.
writeFileSync(join(dir, '2026.08.21.01.done'), 'deployed by hand\n');
expect(run('claim', '2026.08.21.01').code).toBe(1);
expect(run('claim', '2026.08.21.01').err).toContain('already deployed');
});
it('lets a human hand the claim back, and only a human', () => {
expect(run('claim', '2026.09.01.02').code).toBe(0);
rmSync(join(dir, '2026.09.01.02.claim'), { recursive: true });
expect(run('claim', '2026.09.01.02').code).toBe(0);
});
it('records success separately from the claim', () => {
run('claim', '2026.09.01.03');
run('done', '2026.09.01.03');
expect(run('state', '2026.09.01.03').out.trim()).toBe('done');
expect(existsSync(join(dir, '2026.09.01.03.done'))).toBe(true);
});
// The version comes off a git tag, and it is concatenated into a path.
it('refuses anything that is not digits and dots', () => {
for (const bad of ['../../etc/passwd', 'x; rm -rf /', '2026.08.21.01/..', '', 'v1']) {
expect(run('claim', bad).code).toBe(2);
}
expect(existsSync(join(dir, '..', 'etc'))).toBe(false);
});
it('says so plainly when nothing has been attempted', () => {
expect(run('list').out).toContain('no releases attempted');
});
});
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bun
/**
* `bun run doctor` — check the invariants that provisioning cannot check for itself.
*
* ── Why ──
*
* Every step in `provisionOsAccount` is deliberately non-fatal, so "nothing reported an error" is much
* weaker than it sounds. The 2026-08-19 case: a fresh server, an Admin granted files, chat and terminal,
* every named ACL entry correct, provisioning clean — and the account reached nothing, because a later
* `chmod 700` had recomputed the ACL mask to `---`. It was found by a person, three days and one
* confusing UI message later.
*
* This is the same posture as `assertPermissionTotality`: state the invariant, verify it, name the fix.
* The difference is that this reports rather than refusing to boot — a broken member must not stop the
* platform serving everyone else.
*
* Read-only. It changes nothing, so it is safe on a production box.
*
* Exit 1 if anything is wrong, so it can gate the end of a setup run.
*/
import { getUsers, getAllRoleGrants, OWNER_USER_ID } from 'officerdb';
import { checkOsAccount } from '../src/servers/os-user-verify';
import { PERMISSIONS } from '../src/servers/permissions/registry';
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
let problems = 0;
const fail = (what: string, fix: string) => {
problems++;
console.log(`${red('✗')} ${what}`);
console.log(` ${dim(fix)}`);
};
const pass = (what: string) => console.log(`${green('✓')} ${what}`);
console.log('\n── Accounts ──\n');
const users = await getUsers();
for (const user of users) {
if (user.id === OWNER_USER_ID) {
// The owner runs AS the service user, in the service user's own home. There is no Linux account to
// check and no ACL to get wrong — `osUser: null` is correct here, not a finding.
pass(`${user.email} ${dim('(owner — runs as the service account)')}`);
continue;
}
const check = await checkOsAccount(user.email, user.osUser ?? null);
if (check.ok) {
pass(`${user.email} ${dim(`(${user.role}, os_user=${check.osUser}, home readable)`)}`);
} else {
fail(`${user.email} (${user.role}) — ${check.problem}`, `home: ${check.home}`);
}
}
console.log('\n── Grants that cannot take effect ──\n');
// A `confined` grant is silently dropped for an account with no Linux user. That is correct behaviour and
// completely invisible: the grant is in the table, the UI shows it ticked, and it resolves to nothing.
const confinedKeys = new Set(PERMISSIONS.filter((p) => p.kind === 'confined').map((p) => p.key));
const grants = await getAllRoleGrants();
const unconfined = users.filter((u) => u.id !== OWNER_USER_ID && !u.osUser);
if (!unconfined.length) {
pass('every non-owner account has a Linux user');
} else {
for (const user of unconfined) {
const dropped = grants
.filter((g) => g.role === user.role && confinedKeys.has(g.permission))
.map((g) => g.permission);
if (!dropped.length) continue;
fail(
`${user.email} (${user.role}) holds ${dropped.join(', ')} — all dropped, because the account has no Linux user`,
'Fix: Settings → User management → provision, or POST /api/users/<id>/provision-linux',
);
}
}
console.log('');
if (problems === 0) {
console.log(green('No problems found.\n'));
process.exit(0);
}
console.log(red(`${problems} problem${problems === 1 ? '' : 's'} found.\n`));
process.exit(1);
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bun
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadPalette } from './themes/palette';
import { mapPalette } from './themes/map';
import { TEXT, UI, contrast } from './themes/contrast';
// Generate Officer themes from Omarchy's, and refuse to write an unreadable one.
//
// bun scripts/generate-themes.ts <path-to-omarchy-checkout> [--check]
//
// `--check` writes nothing and reports what would change, which is what a test runs.
//
// ── Why generated rather than authored ──
//
// Twenty-two themes hand-written against an 84-token contract will disagree, and the previous attempt
// proved it: eleven themes, 34 tokens each, every surface missing. One mapping means one place to be
// wrong and one place to fix — and a palette that Omarchy updates is a re-run rather than a rewrite.
//
// The Duck and Plain themes are NOT generated. They are Officer's own, authored, and this must never
// overwrite them.
const OMARCHY = process.argv[2];
const CHECK = process.argv.includes('--check');
const THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
if (!OMARCHY || !existsSync(join(OMARCHY, 'themes'))) {
console.error('usage: bun scripts/generate-themes.ts <path-to-omarchy-checkout> [--check]');
process.exit(1);
}
/** Officer's own. Generated themes may never take these names. */
const AUTHORED = new Set(['duck-light', 'duck-dark', 'plain']);
const title = (id: string): string =>
id
.split('-')
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
.join(' ');
/**
* The pairs that must be readable, checked after mapping rather than trusted.
*
* `ensureContrast` already guarantees most of these — this is the belt to that braces. A mapping change
* that quietly breaks one shows up here as a failure instead of as somebody squinting at a screenshot.
*/
const PAIRS: [string, string, number][] = [
['--foreground', '--background', TEXT],
['--card-foreground', '--card', TEXT],
['--popover-foreground', '--popover', TEXT],
['--muted-foreground', '--muted', TEXT],
['--accent-foreground', '--accent', TEXT],
['--primary-foreground', '--primary', TEXT],
['--secondary-foreground', '--secondary', TEXT],
['--cta-foreground', '--cta', TEXT],
['--success-foreground', '--success', TEXT],
['--warning-foreground', '--warning', TEXT],
['--destructive-foreground', '--destructive', TEXT],
['--info-foreground', '--info', TEXT],
['--busy-foreground', '--busy', TEXT],
['--busy-ink', '--background', TEXT],
// Not `--border` at UI: a panel edge is decoration and 3:1 would ring everything in a hard line. What
// must be visible is a form field's boundary and the focus ring — the two that carry information.
['--input', '--background', 1.35],
['--ring', '--background', UI],
];
let failures = 0;
let written = 0;
const generated: { id: string; name: string; dark: boolean }[] = [];
for (const id of readdirSync(join(OMARCHY, 'themes')).sort()) {
const toml = join(OMARCHY, 'themes', id, 'colors.toml');
if (!existsSync(toml)) continue;
if (AUTHORED.has(id)) {
console.log(` ${id}: SKIPPED — Officer's own theme, never generated`);
continue;
}
const palette = loadPalette(toml);
const tokens = mapPalette(palette);
for (const [fgKey, bgKey, min] of PAIRS) {
const fg = tokens[fgKey]!;
const bg = tokens[bgKey]!;
// Only flat hex pairs are checkable — a translucent surface depends on what is behind it.
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
const ratio = contrast(fg, bg);
if (ratio < min) {
console.error(` ${id}: ${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
failures++;
}
}
const body = Object.entries(tokens)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n');
const css = `/*
${title(id)} — generated from Omarchy's palette by scripts/generate-themes.ts.
DO NOT EDIT BY HAND. Change the mapping in scripts/themes/map.ts and re-run, so every theme moves
together — the previous port drifted precisely because each file was edited on its own.
Source: basecamp/omarchy themes/${id}/colors.toml (mode: ${palette.mode})
*/
[data-theme='${id}'] {
${body}
}
`;
const path = join(THEME_DIR, `${id}.css`);
const before = existsSync(path) ? readFileSync(path, 'utf8') : '';
if (before !== css) {
if (!CHECK) writeFileSync(path, css);
written++;
}
generated.push({ id, name: title(id), dark: palette.mode === 'dark' });
}
console.log(`\n ${generated.length} themes, ${written} ${CHECK ? 'would change' : 'written'}, ${failures} contrast failures`);
if (!CHECK) {
// The registry and the stylesheet index, rewritten from what was generated so a theme cannot exist as
// a file nobody imports — which is a theme that silently does not work.
const imports = generated.map((t) => `@import './themes/${t.id}.css';`).join('\n');
const indexPath = join(THEME_DIR, '..', 'index.css');
const index = readFileSync(indexPath, 'utf8');
const marker = '/* Palette themes';
const head = index.slice(0, index.indexOf(marker));
writeFileSync(
indexPath,
`${head}/* Palette themes — generated from Omarchy by scripts/generate-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
);
console.log(` index.css: ${generated.length} imports`);
// The REGISTRY, from the same list. A theme file nothing imports does not work; a theme imported but
// absent from here does not appear in the picker. Both were true of the previous port — catppuccin-mocha
// outlived its stylesheet — so both halves are written from one source rather than maintained by hand.
//
// Display names get a pass for the ones a slug cannot spell.
const NICER: Record<string, string> = {
'rose-pine': 'Rosé Pine',
'retro-82': 'Retro 82',
'tokyo-night': 'Tokyo Night',
'catppuccin': 'Catppuccin Mocha',
'catppuccin-latte': 'Catppuccin Latte',
};
const FAMILY: Record<string, string> = { catppuccin: 'Catppuccin', 'catppuccin-latte': 'Catppuccin' };
const rows = generated
.map((t) => {
const name = NICER[t.id] ?? t.name;
const family = FAMILY[t.id] ?? name;
return ` { id: '${t.id}', name: '${name}', family: '${family}', applyDarkClass: ${t.dark} },`;
})
.join('\n');
const registryPath = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
const registry = readFileSync(registryPath, 'utf8');
const START = ' /* GENERATED THEMES — start */';
const END = ' /* GENERATED THEMES — end */';
if (!registry.includes(START)) {
console.error(' themes.ts: missing the GENERATED THEMES markers — not touching it');
process.exit(1);
}
const registryHead = registry.slice(0, registry.indexOf(START) + START.length);
const registryTail = registry.slice(registry.indexOf(END));
writeFileSync(registryPath, `${registryHead}\n${rows}\n${registryTail}`);
console.log(` themes.ts: ${generated.length} entries`);
}
process.exit(failures > 0 ? 1 : 0);
+5 -1
View File
@@ -147,7 +147,11 @@ function rawUrl(repoUrl: string, path: string): string {
}
const dirs = existsSync(PLUGINS_DIR)
? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })].map((d) => join(PLUGINS_DIR, d))
? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })]
// Not a plugin: the shared install target for plugin npm packages, which lives here because bun
// resolves node_modules up the tree. `discoverPlugins` skips it for the same reason.
.filter((d) => d.replace(/\/$/, '') !== 'node_modules')
.map((d) => join(PLUGINS_DIR, d))
: [];
if (dirs.length === 0) {
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bun
/**
* Poke a chat session on a timer, but ONLY when it is idle.
*
* bun run nudge -- --session <id> --every 3600 --message "Continue. If blocked, write it down and move on."
* bun run nudge -- --list # which sessions exist, and which are working
*
* ── What this is for ──
*
* An agent left running overnight. It is told to record blockers and continue rather than wait for a human,
* and mostly it does — but a turn that ends leaves it idle until someone types, and at 3am nobody does.
* This is the someone.
*
* ── Why it is a script and not a queue job ──
*
* The platform has no recurring scheduler and no way to put a prompt into a live session: `pushTurn` has
* exactly one caller, the chat websocket. Adding both is real work in the queue engine and the chat API,
* and doing it tonight would mean editing a repository an agent is currently working in.
*
* So this talks to the same doors a browser does — `GET /api/chat/sessions` to see the state, then the chat
* websocket to speak. Nothing new is exposed and nothing in the platform changes. If the loop is ever worth
* making permanent, it belongs in the queue with a real trigger, and this script is what that would replace.
*
* ── The one rule ──
*
* NEVER interrupt work. A nudge sent mid-turn is at best noise and at worst a derail, so `isGenerating` is
* checked immediately before speaking and a working session is skipped silently. The point is to restart a
* stopped agent, not to hurry a busy one.
*/
import { sign } from '@@/jwt';
import { getOwnerUser } from 'officerdb';
const arg = (name: string): string | undefined => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? undefined : process.argv[i + 1];
};
const has = (name: string) => process.argv.includes(`--${name}`);
const PORT = process.env.OFFICERDEV_PORT ?? process.env.PORT ?? '9000';
const BASE = `http://127.0.0.1:${PORT}`;
const owner = await getOwnerUser();
if (!owner) {
console.error('[nudge] no owner account — nothing to sign as');
process.exit(1);
}
// Minted here rather than asked for: this runs on the box, as the owner, and a token pasted into a shell
// is a token in the shell history.
const token = await sign({ id: owner.id, email: owner.email, username: owner.username }, '2d');
type Session = { id: string; title?: string; isGenerating?: boolean; updatedAt?: string };
async function sessions(): Promise<Session[]> {
// `?all=1` — every working directory, not just the default one. Without it the list is scoped to the
// owner's home and an agent started in a project directory is simply absent, which reads as "the session
// is gone" and stops the loop. This is the list's own Latest tab, so the shape is identical.
const res = await fetch(`${BASE}/api/chat/sessions?all=1`, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`GET /api/chat/sessions → ${res.status}`);
const body = (await res.json()) as { sessions?: Session[] } | Session[];
return Array.isArray(body) ? body : (body.sessions ?? []);
}
if (has('list')) {
for (const s of await sessions()) {
console.log(`${s.isGenerating ? 'WORKING ' : 'idle '} ${s.id} ${s.title ?? ''}`);
}
process.exit(0);
}
const sessionId = arg('session');
const message =
arg('message') ?? 'Continue with the next task. If you are blocked, write the blocker down and move on.';
const every = Number(arg('every') ?? 3600);
/** A ceiling, because a loop nobody is watching should end on its own. Twelve hours by default. */
const max = Number(arg('max') ?? 12);
if (!sessionId) {
console.error('[nudge] --session <id> is required. Use --list to find one.');
process.exit(2);
}
/** Send one prompt, the way the browser does. Resolves once the server acknowledges the turn. */
function nudge(): Promise<void> {
return new Promise((resolve, reject) => {
const proto = 'ws';
const ws = new WebSocket(`${proto}://127.0.0.1:${PORT}/api/chat/ws?token=${token}&sessionId=${sessionId}`);
const done = (err?: Error) => {
try {
ws.close();
} catch {
/* already closing */
}
err ? reject(err) : resolve();
};
const timer = setTimeout(() => done(new Error('no acknowledgement within 30s')), 30_000);
ws.onopen = () => ws.send(JSON.stringify({ type: 'chat', prompt: message, sessionId }));
ws.onmessage = (ev) => {
// Anything that is not an error means the turn was taken. We do not wait for it to FINISH — that is
// the agent's night, not ours.
try {
const msg = JSON.parse(String(ev.data)) as { type?: string; message?: string };
if (msg.type === 'error') {
clearTimeout(timer);
return done(new Error(msg.message ?? 'server refused the prompt'));
}
if (msg.type === 'assistant:delta' || msg.type === 'assistant:text' || msg.type === 'tool:start') {
clearTimeout(timer);
return done();
}
} catch {
/* not json — ignore */
}
};
ws.onerror = () => {
clearTimeout(timer);
done(new Error('websocket error'));
};
});
}
const stamp = () => new Date().toISOString().slice(11, 19);
console.log(`[nudge] session ${sessionId} — every ${every}s, at most ${max} times`);
console.log(`[nudge] message: ${message}`);
for (let sent = 0, tick = 0; sent < max; tick++) {
if (tick > 0) await Bun.sleep(every * 1000);
let state: Session | undefined;
try {
state = (await sessions()).find((s) => s.id === sessionId);
} catch (err) {
console.error(`[nudge ${stamp()}] could not read sessions: ${err instanceof Error ? err.message : err}`);
continue; // a server restart is not a reason to give up on the night
}
if (!state) {
console.error(`[nudge ${stamp()}] session ${sessionId} is gone — stopping`);
break;
}
if (state.isGenerating) {
console.log(`[nudge ${stamp()}] working — skipped`);
continue;
}
try {
await nudge();
sent++;
console.log(`[nudge ${stamp()}] idle — nudged (${sent}/${max})`);
} catch (err) {
console.error(`[nudge ${stamp()}] failed: ${err instanceof Error ? err.message : err}`);
}
}
console.log(`[nudge ${stamp()}] finished`);
process.exit(0);
+96
View File
@@ -0,0 +1,96 @@
// WCAG contrast, so "is this readable" stops being a matter of taste.
//
// Every complaint about the current themes reduces to one of two failures: a highlight so strong it
// masks the content on it, or text so close to its background it disappears. Both are a contrast ratio,
// and a ratio is checkable. This file is what turns the whole exercise from opinion into a test.
export type Rgb = { r: number; g: number; b: number };
export function parseHex(hex: string): Rgb {
const h = hex.trim().replace('#', '');
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
return {
r: Number.parseInt(full.slice(0, 2), 16),
g: Number.parseInt(full.slice(2, 4), 16),
b: Number.parseInt(full.slice(4, 6), 16),
};
}
export const toHex = ({ r, g, b }: Rgb): string =>
`#${[r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`;
/** Relative luminance, per WCAG 2.1. */
export function luminance(c: Rgb): number {
const channel = (v: number) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
}
/** 1 (identical) to 21 (black on white). */
export function contrast(a: string, b: string): number {
const [x, y] = [luminance(parseHex(a)), luminance(parseHex(b))];
const [hi, lo] = x > y ? [x, y] : [y, x];
return (hi + 0.05) / (lo + 0.05);
}
/**
* The thresholds, and why these ones.
*
* `TEXT` is WCAG AA for body text. `UI` is AA for large text and non-text contrast — borders, icons, the
* edge of a control. A border at 4.5 would be a hard line around everything, which is a different design
* rather than a more accessible one.
*/
export const TEXT = 4.5;
export const UI = 3;
/**
* Push a foreground away from its background until it is readable.
*
* Moves toward black or white — whichever the background is further from — in small steps, so a colour
* that is nearly readable keeps almost all of its hue and only a hopeless one ends up monochrome. That
* matters: the point is a theme that still looks like Nord, not one that passes and looks like nothing.
*
* Returns the original when it already passes, so a well-chosen palette is untouched.
*/
export function ensureContrast(fg: string, bg: string, min = TEXT): string {
if (contrast(fg, bg) >= min) return fg;
// Whichever extreme is FURTHER from the background, not whichever side of mid-grey it falls on.
// Those differ for a mid-tone fill and the difference is the whole answer: rose-pine's rose is at
// luminance 0.25, so a `> 0.5` test sends this toward white and caps at 3.5:1, while black reaches
// 6:1 easily. Six of its tones were unfixable until this stopped guessing and measured.
const black = '#000000';
const white = '#ffffff';
const target = parseHex(contrast(black, bg) >= contrast(white, bg) ? black : white);
const start = parseHex(fg);
// 40 steps of 2.5% each. Fine enough that the first passing value is close to the original.
for (let i = 1; i <= 40; i++) {
const t = i / 40;
const moved = toHex({
r: start.r + (target.r - start.r) * t,
g: start.g + (target.g - start.g) * t,
b: start.b + (target.b - start.b) * t,
});
if (contrast(moved, bg) >= min) return moved;
}
return toHex(target);
}
/** Blend two colours. `amount` is how much of `b`. */
export function mix(a: string, b: string, amount: number): string {
const [x, y] = [parseHex(a), parseHex(b)];
return toHex({
r: x.r + (y.r - x.r) * amount,
g: x.g + (y.g - x.g) * amount,
b: x.b + (y.b - x.b) * amount,
});
}
/** `#rrggbb` → `r g b` for `rgb(var(--x) / 0.5)`, which is how the surfaces get their opacity. */
export const rgbTriplet = (hex: string): string => {
const { r, g, b } = parseHex(hex);
return `${r} ${g} ${b}`;
};
+193
View File
@@ -0,0 +1,193 @@
import type { Palette } from './palette';
import { UI, contrast, ensureContrast, mix, rgbTriplet } from './contrast';
// Omarchy palette → Officer's 84 declarations.
//
// This file is the whole port. It exists once so that twenty-two themes cannot disagree about what
// "muted" means, and so that improving the mapping improves all of them at once.
//
// ── What went wrong before, and what this fixes ──
//
// The previous port produced 34 declarations per theme against a contract of 84. The missing fifty were
// every surface (`--panel-bg`, `--card-bg`, `--glass-bg`), every state colour (`--success`, `--warning`,
// `--busy`, `--info`), the header, the scrollbars and the grid — so they fell back to whatever the base
// stylesheet or the previous theme left behind. That is why highlights masked content and text faded
// out: not taste, absence. It also shipped thirteen tokens nothing reads (`--chart-1..5`, `--sidebar-*`),
// which is the tell that it came from a shadcn template rather than from this codebase's contract.
//
// ── The rule that makes them readable ──
//
// Every foreground is run through `ensureContrast` against the surface it actually sits on. A palette
// whose colours are already readable is untouched; one that is not gets nudged toward black or white
// until it passes, keeping as much hue as it can. So a theme cannot ship with invisible text, no matter
// what its author chose.
export type Tokens = Record<string, string>;
/** Depth ordering differs by mode: a light theme's "darker" is its raised surface, not its page. */
function surfaces(p: Palette) {
const dark = p.mode === 'dark';
return {
/** The page. The deepest surface in dark, the plainest in light. */
page: dark ? p.darkerBackground : p.background,
/** Panels and cards sit one step up from the page, so a tile reads as a tile. */
raised: dark ? p.background : p.lighterBackground,
/** Hovers, secondary fills, the header. One step up again. */
high: dark ? p.lighterBackground : p.darkBackground,
dark,
};
}
export function mapPalette(p: Palette): Tokens {
const s = surfaces(p);
// Text, checked against the surface each weight actually appears on.
const fg = ensureContrast(p.foreground, s.page);
const fgRaised = ensureContrast(p.foreground, s.raised);
// Checked against `s.high`, not `s.raised`: `--muted-foreground` is the text ON `--muted`, and
// `--muted` is the high surface. Deriving it against the wrong one left everforest at 4.44:1 and
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
// person eyeballing a screenshot would have shipped.
const fgMuted = ensureContrast(p.lightForeground, s.high);
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
/**
* Text for a solid fill of `bg`.
*
* Tries the palette's own extremes first, so a button's label still belongs to the theme, and only
* falls through to `ensureContrast` when neither is readable. Both steps are needed: picking the
* better of two candidates does NOT guarantee either passes — rose-pine's fills are all mid-tone and
* every one came out between 2.2:1 and 4.2:1 until this drove it the rest of the way.
*/
const on = (bg: string): string => {
const light = p.brightForeground;
const dark = p.darkerBackground;
const better = contrast(light, bg) >= contrast(dark, bg) ? light : dark;
return ensureContrast(better, bg);
};
const accent = p.accent;
// Borders are DECORATION, not information, so they are not held to a text ratio — a 3:1 line drawn
// around every panel is a different design, not a more accessible one. What they must be is
// discernible, which `--input` below is checked for: a form field whose boundary you cannot see is a
// real accessibility failure, unlike a subtle panel edge.
const border = mix(s.raised, s.dark ? p.lightForeground : p.darkForeground, 0.28);
return {
// ── Ground ──
'--background': s.page,
'--foreground': fg,
// ── Surfaces ──
'--card': s.raised,
'--card-foreground': fgRaised,
'--popover': s.raised,
'--popover-foreground': fgRaised,
// `muted` is a SURFACE here, not the palette's `muted` colour — that one is a mid-grey meant for
// dimmed text, and using it as a fill is precisely how a highlight ends up masking its content.
'--muted': s.high,
'--muted-foreground': fgMuted,
'--accent': s.high,
'--accent-foreground': fgRaised,
// ── Actions ──
'--primary': accent,
'--primary-foreground': on(accent),
'--secondary': s.high,
'--secondary-foreground': fgRaised,
'--cta': accent,
'--cta-foreground': on(accent),
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
// ── State — five tones, each readable on its own fill ──
'--success': p.green,
'--success-foreground': on(p.green),
'--warning': p.yellow,
'--warning-foreground': on(p.yellow),
'--destructive': p.red,
'--destructive-foreground': on(p.red),
'--info': p.blue,
'--info-foreground': on(p.blue),
'--busy': p.orange,
'--busy-foreground': on(p.orange),
// The odd one, per the contract: a fill bright enough for black text is too light to BE text. So ink
// is the same hue pushed until it reads on the page.
'--busy-ink': ensureContrast(p.orange, s.page),
// ── Lines ──
'--border': border,
'--input': border,
'--ring': accent,
'--radius': '0.625rem',
// ── Data ──
'--chart-primary': accent,
'--chart-secondary': p.magenta,
'--status-success': p.green,
'--status-warning': p.yellow,
'--status-in-progress': p.blue,
// ── Surfaces, as complete CSS values ──
'--page-bg': s.page,
'--page-bg-src': "''",
'--panel-bg': `rgb(${rgbTriplet(s.raised)} / 0.72)`,
'--panel-border': `rgb(${rgbTriplet(border)} / 0.9)`,
'--panel-border-bare': `rgb(${rgbTriplet(border)} / 0.55)`,
// DENSER, not darker — panel chrome is dark text and a heavy fill hides every icon. The contract
// says so; this is the one place a value is chosen to obey a sentence rather than a ratio.
'--panel-raised-bg': `rgb(${rgbTriplet(s.raised)} / 0.95)`,
'--panel-raised-border': `rgb(${rgbTriplet(border)} / 1)`,
'--panel-accent': accent,
'--panel-header-bg': `rgb(${rgbTriplet(s.high)} / 0.6)`,
'--panel-header-fg': fgRaised,
'--panel-radius': '0.75rem',
'--panel-blur': '12px',
'--card-bg': s.raised,
'--glass-bg': `rgb(${rgbTriplet(s.raised)} / 0.55)`,
'--glass-border': `rgb(${rgbTriplet(border)} / 0.7)`,
'--dock-bg': `rgb(${rgbTriplet(s.raised)} / 0.7)`,
'--dock-border': `rgb(${rgbTriplet(border)} / 0.8)`,
'--dock-tooltip-bg': s.high,
'--header-bg': `rgb(${rgbTriplet(s.raised)} / 0.8)`,
'--header-border': `rgb(${rgbTriplet(border)} / 0.9)`,
'--header-fg': fgRaised,
'--header-blur': '12px',
// Grid lines are a texture, not a colour anyone should notice — kept just above the surface.
'--pixel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.07)`,
'--card-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.05)`,
'--panel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.06)`,
'--scrollbar-thumb': `rgb(${rgbTriplet(p.muted)} / 0.6)`,
'--scrollbar-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.85)`,
'--scrollbar-thin-thumb': `rgb(${rgbTriplet(p.muted)} / 0.45)`,
'--scrollbar-thin-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.7)`,
// ── Brand slots ──
//
// A ported theme has no brand of its own, so these follow its accent rather than importing Duck's
// green into somebody else's palette. `--ink` is the app's text colour and is used ~950 times.
'--brand': accent,
'--brand-foreground': on(accent),
'--brand-primary': accent,
'--brand-accent': p.cyan,
'--brand-warm': p.orange,
'--brand-deep': s.dark ? p.darkerBackground : p.darkBackground,
'--brand-muted': p.muted,
'--ink': fg,
'--surface-warm': s.raised,
'--naturegreen': p.green,
'--naturegreen-foreground': on(p.green),
// Legacy aliases the contract keeps until the utility rename lands — `prose.css` and some plugins
// read them directly, so a theme without them has unstyled prose.
'--duck-dark': fg,
'--duck-teal': p.cyan,
'--duck-yellow': p.yellow,
'--duck-orange': p.orange,
'--duck-forest': p.green,
'--duck-beige': mix(s.raised, fg, 0.1),
'--logo-src': "'/officer-logo.svg'",
};
}
+86
View File
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs';
// Reading an Omarchy theme's `colors.toml`.
//
// Omarchy 4 ships a genuinely semantic palette rather than sixteen ANSI slots: three background depths,
// four foreground weights, an accent, a selection and a muted, plus the named hues. That is why this port
// can be a mapping at all — the previous one had to guess, and the guessing is what produced themes with
// no surfaces and unreadable highlights.
//
// Verified across all 22 themes in basecamp/omarchy@master: every one declares the 24 keys below.
export type Palette = {
mode: 'dark' | 'light';
accent: string;
selection: string;
muted: string;
background: string;
darkBackground: string;
darkerBackground: string;
lighterBackground: string;
foreground: string;
darkForeground: string;
lightForeground: string;
brightForeground: string;
red: string;
yellow: string;
green: string;
cyan: string;
blue: string;
magenta: string;
/** Not in every theme — 19 of 22. Falls back to yellow-red mid, which is what orange is. */
orange: string;
brightRed: string;
brightYellow: string;
brightGreen: string;
brightCyan: string;
brightBlue: string;
brightMagenta: string;
};
/** A deliberately small TOML reader: this file is flat `key = "value"` and nothing else. */
function readToml(path: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of readFileSync(path, 'utf8').split('\n')) {
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
if (m) out[m[1]!] = m[2]!;
}
return out;
}
export function loadPalette(tomlPath: string): Palette {
const t = readToml(tomlPath);
const need = (key: string): string => {
const v = t[key];
if (!v) throw new Error(`${tomlPath}: missing "${key}"`);
return v;
};
return {
mode: t.mode === 'light' ? 'light' : 'dark',
accent: need('accent'),
selection: need('selection'),
muted: need('muted'),
background: need('background'),
darkBackground: need('dark_background'),
darkerBackground: need('darker_background'),
lighterBackground: need('lighter_background'),
foreground: need('foreground'),
darkForeground: need('dark_foreground'),
lightForeground: need('light_foreground'),
brightForeground: need('bright_foreground'),
red: need('red'),
yellow: need('yellow'),
green: need('green'),
cyan: need('cyan'),
blue: need('blue'),
magenta: need('magenta'),
orange: t.orange ?? need('yellow'),
brightRed: need('bright_red'),
brightYellow: need('bright_yellow'),
brightGreen: need('bright_green'),
brightCyan: need('bright_cyan'),
brightBlue: need('bright_blue'),
brightMagenta: need('bright_magenta'),
};
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Regenerate `TODO.md` — the index over `todo/`.
*
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
* it describes.
*/
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
const OUT = join(ROOT, 'todo');
export type Item = {
id: string;
title: string;
theme: string;
status: string;
created: string;
/** `urgent` or empty — see todo/README.md for why there is no scale. */
priority: string;
due: string;
order: number;
file: string;
};
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
export function frontmatter(text: string): Record<string, string> {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return {};
const out: Record<string, string> = {};
for (const line of text.slice(4, end).split('\n')) {
const m = line.match(/^([a-z_]+):\s*(.*)$/);
if (!m || !m[1]) continue;
const raw = (m[2] ?? '').trim();
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
}
return out;
}
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
const themes = readdirSync(OUT)
.filter((d) => statSync(join(OUT, d)).isDirectory())
.sort()
.map((theme) => {
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
const items = files
.filter((f) => f !== '_index.md')
.map((f) => {
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
return {
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
title: fm.title ?? f,
theme,
status: fm.status ?? 'open',
created: fm.created ?? '',
priority: fm.priority ?? '',
due: fm.due ?? '',
order: Number(fm.order ?? 0),
file: `todo/${theme}/${f}`,
};
})
.sort((a, b) => a.order - b.order);
return { theme, heading: meta.heading ?? theme, items };
});
return { themes };
}
const HEADER = `# TODO — the index
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
Format and the two-way-sync contract: \`todo/README.md\`.
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
`;
function render(): string {
const { themes } = readTree();
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
const rows = themes
.filter((t) => t.items.length)
.map((t) => {
const o = t.items.filter((i) => i.status !== 'done').length;
const d = t.items.length - o;
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
});
const recent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done')
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, 10)
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
// whole split risks. List them explicitly rather than letting them age out of view.
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
// by theme, is an item nobody sees on the day it matters.
const urgent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
return [
HEADER,
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
`## Themes\n`,
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
`| Theme | Open | Closed |`,
`| --- | --- | --- |`,
...rows,
``,
`## Records — no items, kept for the reasoning\n`,
...notes,
``,
`## Newest ten\n`,
...recent,
``,
].join('\n');
}
if (import.meta.main) {
writeFileSync(join(ROOT, 'TODO.md'), render());
console.log('TODO.md regenerated');
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bun
import { snapshotPlugins } from '../src/servers/plugins/mount';
import { updatePlugin, syncCapabilities } from '../src/servers/plugins/update';
// Update every installed plugin, from a terminal.
//
// bun run plugins:update
//
// ── Why this exists ──
//
// The only way to do this was `POST /api/plugins/update-all`, which is authenticated — so a deploy had to
// mint an API key to update the plugins on the machine it was already logged into. That is a credential
// created, used and hopefully deleted, on every host, for a local operation.
//
// This is the same work through the same functions, with the HTTP layer removed rather than a second
// implementation beside it. `prompts/deploy-commander.md` calls it.
//
// ── It runs OUTSIDE the server process ──
//
// Which matters, and is the one thing to know before reading the output. `updatePlugin` pulls, re-applies
// and rebuilds the frontend — all of which touch disk and are picked up by the running server. But
// remounting happens in the process that calls it, and this is not that process, so the routes the
// server is serving do not change until it restarts.
//
// So: the files, the schema, the assets and the SPA bundle are all current when this finishes. The route
// table is not. A deploy restarts `officer` afterwards, which is what makes the difference invisible —
// and the reason this says so rather than leaving somebody to find out.
const started = Date.now();
const { states } = await snapshotPlugins();
const installed = states.filter((st) => st.install).map((st) => st.plugin.appName);
if (installed.length === 0) {
console.log('no plugins installed — nothing to update');
process.exit(0);
}
console.log(`updating ${installed.length} plugin(s): ${installed.join(', ')}\n`);
let failed = 0;
// SEQUENTIAL, like the route: each update pushes a schema, republishes assets, rewrites the ecosystem
// file and rebuilds the frontend. Two at once is a race over one file each.
for (const appName of installed) {
process.stdout.write(`→ ${appName}\n`);
// One plugin failing must not stop the rest. A dirty checkout is the EXPECTED failure — every plugin
// directory on a developer machine is a live one — and it should not hold back the other eleven.
const result = await updatePlugin(appName, async (step) => console.log(` ${step}`));
if (!result.ok) {
failed++;
console.error(` FAILED: ${result.error}`);
}
}
// The capabilities repository rides along, the same as it does through the button: it is one question —
// "get me what has been published since I installed" — and a second gesture for it is one nobody remembers.
const capabilities = await syncCapabilities();
console.log(`\ncapabilities: ${capabilities.action}${capabilities.error ? ` — ${capabilities.error}` : ''}`);
const ok = installed.length - failed;
console.log(`${ok}/${installed.length} updated in ${Math.round((Date.now() - started) / 1000)}s`);
if (failed) console.log("a failure above is usually an uncommitted change in that plugin's checkout");
// Non-zero on failure, so a deploy script can stop rather than reporting a success it did not have.
process.exit(failed > 0 ? 1 : 0);
-3
View File
@@ -120,10 +120,7 @@ export function App() {
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/browser/:tabId" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
<Route path="/desktop" element={<Dashboard.DesktopScreen />} />
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -102,7 +102,7 @@ export const ActivityScreen = () => {
title={t.cwd}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`}
/>
<span className="truncate font-mono text-xs">{t.id}</span>
</Link>
@@ -1,48 +0,0 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { useIsMobile } from 'hooks/useIsMobile';
import { defaultLayout } from './defaultLayout';
import { TabList } from './TabList';
import { TabPreview } from './TabPreview';
import { useSelectedTabId } from './shared';
const PROMPT_PREFIX = `You are a browser assistant. The user has connected Chrome tabs via the Officer Browser Relay. You have a 'browser' tool — use it to list tabs, take screenshots, navigate, evaluate JavaScript, and get page info. When asked about a page, take a screenshot first.`;
// This screen's prompt, so this screen's chat panel. See the note in `EmailScreen`.
const BrowserChatPanel = () => <ChatPanelWrapper panelId="browser-chat" promptPrefix={PROMPT_PREFIX} />;
export const BrowserScreen = () => {
const isMobile = useIsMobile();
const navigate = useNavigate();
// No redirect guard: like /email, the bare /browser is the list with nothing open, and a tab id that
// no longer resolves gets the preview's "no longer attached" state rather than a rewritten address.
const selectedId = useSelectedTabId();
const workspace = useDashboardState<LayoutNode>('screens/browser', defaultLayout);
const components: PanelComponents = useMemo(
() => ({
'browser-tabs': TabList,
'browser-preview': TabPreview,
'browser-chat': BrowserChatPanel,
}),
[],
);
const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined;
const onMobileBack = useCallback(() => navigate('/browser'), [navigate]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
components={components}
mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/>
</div>
);
};
@@ -1,117 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router';
import { Globe, Loader2, X, Eye } from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { browserTabPath, useBrowserTargets, useSelectedTabId, type BrowserTarget } from './shared';
export const TabList = () => {
const client = useClient();
const selectedId = useSelectedTabId();
const { data: status } = useQuery({
queryKey: ['browser-status'],
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
refetchInterval: 5000,
});
const { data: targets, isLoading } = useBrowserTargets(!!status?.extensionConnected);
const targetList: BrowserTarget[] = targets ?? [];
const handleClose = async (targetId: string) => {
try {
await client.post(`/browser/targets/${targetId}/close`);
} catch {
toast.error('Failed to close tab');
}
};
const handleActivate = async (targetId: string) => {
try {
await client.post(`/browser/targets/${targetId}/activate`);
} catch {
toast.error('Failed to activate tab');
}
};
const isConnected = status?.extensionConnected ?? false;
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Globe className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Browser</span>
<div
className={`ml-auto h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
title={isConnected ? 'Extension connected' : 'Extension not connected'}
/>
</div>
{!isConnected && (
<div className="flex flex-col items-center justify-center gap-3 p-4 text-sm flex-1">
<Globe className="h-8 w-8 opacity-30" />
<p className="opacity-60 text-center">Extension not connected</p>
{/* A <Link>, not an <a href>: a raw href to an in-app route reloads the whole SPA. */}
<Link to="/settings/integrations" className="text-xs text-duck-teal underline">
Set up in Integrations
</Link>
</div>
)}
{isConnected && isLoading && (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin opacity-50" />
</div>
)}
{isConnected && !isLoading && targetList.length === 0 && (
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-6 w-6" />
<span>No tabs attached</span>
<span className="text-xs">Click the extension icon on a tab</span>
</div>
)}
{isConnected && targetList.length > 0 && (
<div className="flex flex-1 flex-col divide-y divide-white/10">
{/* Focus and Close are siblings of the anchor, not children of it: an <a> may not contain a
<button>, and the old row was a <button> containing two more — invalid either way, and it
was only the stopPropagation calls holding it together. */}
{targetList.map((target) => (
<div
key={target.id}
className={`group relative flex transition-colors ${
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<Link
to={browserTabPath(target.id)}
className="flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2.5 text-left"
>
<span className="truncate pr-14 text-sm font-medium">{target.title || 'Untitled'}</span>
<span className="truncate text-xs opacity-50">{target.url}</span>
</Link>
<div className="absolute right-3 top-2.5 flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => handleActivate(target.id)}
className="rounded p-0.5 hover:bg-black/10 cursor-pointer"
title="Focus tab"
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleClose(target.id)}
className="rounded p-0.5 hover:bg-black/10 cursor-pointer"
title="Close tab"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
@@ -1,174 +0,0 @@
import { useState, useCallback } from 'react';
import { Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Globe, RefreshCw, Send, Terminal, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useBrowserTargets, useSelectedTabId } from './shared';
export const TabPreview = () => {
const client = useClient();
// Reads the URL itself rather than being told by the list.
const selectedId = useSelectedTabId();
const { data: targets } = useBrowserTargets(!!selectedId);
// A target id dies with the browser session, so a link or a reload can name a tab that is no longer
// attached. Screenshotting it would 404 every ten seconds; better to say so once.
const isKnownTab = !!selectedId && !!targets?.some((t) => t.id === selectedId);
const [navUrl, setNavUrl] = useState('');
const [evalExpr, setEvalExpr] = useState('');
const [evalResult, setEvalResult] = useState<string | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const [isEvaluating, setIsEvaluating] = useState(false);
const {
data: screenshot,
isLoading,
refetch,
isFetching,
} = useQuery({
queryKey: ['browser-screenshot', selectedId],
queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`),
enabled: isKnownTab,
refetchInterval: 10000,
});
const handleNavigate = useCallback(async () => {
if (!selectedId || !navUrl.trim()) return;
setIsNavigating(true);
try {
await client.post(`/browser/targets/${selectedId}/navigate`, { url: navUrl.trim() });
setTimeout(() => void refetch(), 1000);
} catch {
toast.error('Navigation failed');
} finally {
setIsNavigating(false);
}
}, [client, selectedId, navUrl, refetch]);
const handleEvaluate = useCallback(async () => {
if (!selectedId || !evalExpr.trim()) return;
setIsEvaluating(true);
try {
const res = await client.post<{ result: unknown }>(`/browser/targets/${selectedId}/evaluate`, {
expression: evalExpr.trim(),
});
setEvalResult(JSON.stringify(res.result, null, 2));
} catch (err) {
setEvalResult(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsEvaluating(false);
}
}, [client, selectedId, evalExpr]);
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-8 w-8" />
<span>Select a tab to preview</span>
</div>
);
}
// Waiting on the list is not the same as knowing the tab is gone, so only say so once it has arrived.
if (targets && !isKnownTab) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-8 w-8" />
<span>That tab is no longer attached</span>
<Link to="/browser" className="text-xs underline">
Back to the tab list
</Link>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* URL bar */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => void refetch()}
disabled={isFetching}
>
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
<form
className="flex flex-1 items-center gap-2"
onSubmit={(ev) => {
ev.preventDefault();
void handleNavigate();
}}
>
<input
type="text"
value={navUrl}
onChange={(ev) => setNavUrl(ev.target.value)}
placeholder="Navigate to URL..."
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
type="submit"
disabled={isNavigating || !navUrl.trim()}
>
<Send className="h-3.5 w-3.5" />
</Button>
</form>
</div>
{/* Screenshot */}
<div className="flex-1 overflow-auto p-2">
{isLoading && !screenshot && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin opacity-50" />
</div>
)}
{screenshot && (
<img
src={`data:image/${screenshot.format};base64,${screenshot.data}`}
alt="Tab screenshot"
className="w-full rounded-md border"
/>
)}
</div>
{/* JS Console */}
<div className="border-t">
<form
className="flex items-center gap-2 px-3 py-2"
onSubmit={(ev) => {
ev.preventDefault();
void handleEvaluate();
}}
>
<Terminal className="h-3.5 w-3.5 shrink-0 opacity-50" />
<input
type="text"
value={evalExpr}
onChange={(ev) => setEvalExpr(ev.target.value)}
placeholder="Evaluate JavaScript..."
className="flex-1 bg-transparent text-sm outline-none font-mono"
/>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
type="submit"
disabled={isEvaluating || !evalExpr.trim()}
>
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
</Button>
</form>
{evalResult !== null && (
<pre className="max-h-32 overflow-auto border-t px-3 py-2 text-xs font-mono opacity-70">{evalResult}</pre>
)}
</div>
</div>
);
};
@@ -1,22 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'browser-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 },
{
node: {
type: 'group',
id: 'browser-right',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'browser-preview', appType: null }, size: 60 },
{ node: { type: 'panel', id: 'browser-chat', appType: 'officerdev/chat' }, size: 40 },
],
},
size: 75,
},
],
};
@@ -1 +0,0 @@
export { BrowserScreen } from './BrowserScreen';
@@ -1,34 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router';
import { useClient } from 'hooks/useClient';
// Which attached tab is being previewed is `/browser/:tabId`. It used to be a `BROWSER_SELECTED_TAB`
// global read by three components, which meant the address bar said `/browser` no matter what you were
// looking at, and a tab row was a `<button>` with the id in its onClick closure.
export type BrowserTarget = {
id: string;
sessionId: string;
type: string;
title: string;
url: string;
};
export const browserTabPath = (id: string) => `/browser/${encodeURIComponent(id)}`;
/** The previewed tab, or null on the bare `/browser` route — the list with nothing open. */
export const useSelectedTabId = (): string | null => useParams<{ tabId?: string }>().tabId ?? null;
// Both panels want this list and React Query serves both observers from one key, so the preview being
// able to tell "no tab open" from "that tab is gone" costs no extra request. It needs to: a CDP target
// id dies with the browser session, and now that the id is in the URL a reload can carry a stale one in
// — which the old global, living in memory, could never do.
export const useBrowserTargets = (enabled: boolean) => {
const client = useClient();
return useQuery({
queryKey: ['browser-targets'],
queryFn: () => client.get<BrowserTarget[]>('/browser/targets'),
refetchInterval: 3000,
enabled,
});
};
@@ -1,18 +0,0 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
export const DesktopScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/desktop', defaultLayout);
return (
<div className="h-full w-full">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['officerdev/desktop'], fallback: 'officerdev/desktop' }}
/>
</div>
);
};
@@ -1,7 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'desktop-screen',
appType: 'officerdev/desktop',
};
@@ -1 +0,0 @@
export * from './DesktopScreen';
@@ -142,7 +142,7 @@ const StatusIcon = ({ status, className }: { status: string; className?: string
switch (status) {
case 'completed':
case 'complete':
return <CheckCircle2 className={`${base} text-green-500`} />;
return <CheckCircle2 className={`${base} text-success`} />;
case 'failed':
case 'error':
return <AlertCircle className={`${base} text-red-500`} />;
@@ -44,7 +44,7 @@ const formatDate = (iso: string) =>
const StatusIcon = ({ status }: { status: JobSummary['status'] }) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
return <CheckCircle2 className="h-4 w-4 text-success shrink-0" />;
case 'failed':
return <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />;
case 'running':
@@ -7,6 +7,7 @@ import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
@@ -47,6 +48,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
<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. */}
<Spotlight />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
@@ -81,7 +81,10 @@ export function Header({ dockItems, hidden }: HeaderProps) {
role="img"
aria-label="Officer"
className="h-8 md:h-12 w-24 md:w-36 bg-contain bg-left bg-no-repeat"
style={{ backgroundImage: logo ? `url(${logo})` : 'none', transform: 'skew(-15deg, -2deg)' }}
/* No skew. The wordmark is drawn, kerned pair by pair, and shipped as the finished mark —
shearing it in CSS was how the OLD logo got its slant, and doing it to this one distorts
letterforms that were adjusted by hand. See landing/assets/brand/WORDMARK.md. */
style={{ backgroundImage: logo ? `url(${logo})` : 'none' }}
/>
</Link>
</div>
@@ -33,7 +33,28 @@ export function RescanButton() {
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' })));
const c = res.counts ?? {};
toast.success(
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes`,
`Rescanned items — ${c.tasks ?? 0} tasks, ${c.agents ?? 0} agents, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes, ${c.affordances ?? 0} affordances`,
{
// Two things said here rather than left to be discovered.
//
// A rescan cannot reach a chat that is already open — its system prompt was assembled when it
// started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an
// agent, because the loader skips it: a line that does not say when to reach for something
// costs context in every conversation and earns nothing. So a skill can be present, listed and
// editable while being invisible to every agent, and the only symptom is two numbers
// disagreeing where nobody is comparing them.
description:
[
c.affordances || c.skillsOffered
? 'Applies to new chats — an open one keeps what it started with.'
: null,
typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills
? `${c.skills - c.skillsOffered} skill(s) have no description, so no agent is offered them.`
: null,
]
.filter(Boolean)
.join(' ') || undefined,
},
);
} catch {
toast.error('Failed to rescan items');
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { Search } from 'lucide-react';
import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// Spotlight: type a few letters, go somewhere.
//
// The dock is full and cannot grow, which is what this is for — once navigation lives here, the dock is
// free to be a short list of what you actually use rather than a complete one.
//
// ── Keyboard first, and the trigger ──
//
// `shift-space`. The two obvious chords were both taken: `ctrl-space` focuses the address bar in Brave,
// and `alt-space` is load-bearing for the owner elsewhere. `cmd-k` belongs to the browser on some
// platforms and to text fields on others.
//
// **Shift-space is a real key combination inside a text field** — it types a space, and people hold shift
// through one more often than they think while typing capitals. So the trigger is IGNORED when focus is
// in an input, a textarea or anything contenteditable, and the space is typed as normal.
//
// The cost of that is worth stating: focus usually IS in the chat composer, so opening Spotlight from
// there means clicking out first. If that turns out to be the common case, a double-tap of shift is the
// escape hatch — it conflicts with nothing anywhere.
//
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
/*
No cap, deliberately, and it used to be 8.
Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock
carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the
list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to
type it. A launcher you can only use for things you can already name is not doing the job.
The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a
layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a
cap becomes indistinguishable from a missing feature.
*/
/**
* Is the user typing into something?
*
* `shift-space` types a space in any text field, so the trigger has to stand down there or it eats a
* keystroke people make by accident all day. `isContentEditable` covers the editor and any rich input a
* panel brings with it.
*/
function isTyping(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el || !el.tagName) return false;
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable === true;
}
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
if (!hits.length) return <>{label}</>;
const set = new Set(hits);
return (
<>
{label.split('').map((char, i) =>
set.has(i) ? (
<span key={i} className="text-duck-teal">
{char}
</span>
) : (
<span key={i}>{char}</span>
),
)}
</>
);
};
const Row = ({
item,
hits,
active,
onPick,
}: {
item: SpotlightItem;
hits: number[];
active: boolean;
onPick: () => void;
}) => {
const Icon = item.icon;
// Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the
// row is actually out of sight, so arrowing through the visible middle of the list does not jump it.
const ref = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
}, [active]);
return (
<button
ref={ref}
type="button"
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
// overlay on the blur handler before the navigation ever ran.
onMouseDown={(ev) => {
ev.preventDefault();
onPick();
}}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
active ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
{/*
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
these are the SAME things and a second visual language for them is what made this list read as
somebody else's menu:
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
invisible, which is why the fill is not decoration
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
mistake, and every plugin shipping its own art is this case
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
color: item.color ? '#fff' : undefined,
}}
>
{item.image ? (
<img
src={item.image}
alt=""
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
/>
) : Icon ? (
<Icon className={`h-4 w-4 ${item.color ? '' : 'text-muted-foreground'}`} />
) : (
<Search className="h-4 w-4 text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">
<Highlighted label={item.label} hits={hits} />
</span>
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null}
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">{item.to}</span>
</button>
);
};
export const Spotlight = () => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const items = useSpotlightItems();
const results = useMemo(() => rank(items, query), [items, query]);
const close = useCallback(() => {
setOpen(false);
setQuery('');
setActive(0);
}, []);
const pick = useCallback(
(item: SpotlightItem | undefined) => {
if (!item) return;
close();
navigate(item.to);
},
[close, navigate],
);
// The trigger. Bound to the window rather than to a container, because it has to work while focus is
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (ev.shiftKey && ev.code === 'Space' && !isTyping(ev.target)) {
ev.preventDefault();
setOpen((wasOpen) => !wasOpen);
return;
}
if (ev.key === 'Escape' && open) close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// Reset the cursor whenever the result set changes, so it never points past the end.
useEffect(() => setActive(0), [query]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[12vh] backdrop-blur-sm"
onMouseDown={close}
>
<div
className="w-[min(92vw,560px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(ev) => setQuery(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'ArrowDown') {
ev.preventDefault();
setActive((i) => Math.min(i + 1, results.length - 1));
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (ev.key === 'Enter') {
ev.preventDefault();
pick(results[active]?.item);
}
}}
placeholder="Go to…"
className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[52vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
) : (
results.map((result, i) => (
<Row
key={result.item.id}
item={result.item}
hits={result.hits}
active={i === active}
onPick={() => pick(result.item)}
/>
))
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test';
import { rank } from './match';
import type { SpotlightItem } from './useSpotlightItems';
// The behaviours a launcher is judged on, which are all about ORDER rather than about matching. Anyone
// can return the right set; returning it with the right thing first is the whole product.
const item = (label: string, to: string, hint?: string): SpotlightItem => ({ id: to, label, to, hint });
const ITEMS: SpotlightItem[] = [
item('Home', '/'),
item('Files', '/files'),
item('Chat', '/chat'),
item('Command HQ', '/command-hq'),
item('Editor', '/code-editor'),
item('Profile', '/settings/profile', 'avatar, name, email'),
item('API keys', '/settings/profile/api-keys', 'per-app keys'),
item('App store', '/app-store'),
item('Dashboards', '/dashboards'),
];
const top = (query: string, n = 1) =>
rank(ITEMS, query)
.slice(0, n)
.map((s) => s.item.label);
describe('spotlight ranking', () => {
test('an exact label wins', () => {
expect(top('chat')).toEqual(['Chat']);
});
test('a prefix beats a scattered match — "fi" is Files, not Profile', () => {
expect(top('fi')).toEqual(['Files']);
});
test('initials find a two-word label — "chq" is Command HQ', () => {
expect(top('chq')).toEqual(['Command HQ']);
});
test('a subsequence works, so you can type the shape of a word', () => {
expect(top('apik')).toEqual(['API keys']);
});
test('a hint matches, but never outranks a label', () => {
// "keys" is API keys' own label AND its hint. It must not be beaten by anything mentioning keys.
expect(top('keys')).toEqual(['API keys']);
});
test('a path fragment finds the item without outranking labels', () => {
expect(rank(ITEMS, 'settings').map((s) => s.item.label)).toContain('Profile');
// "app" is a prefix of "App store" and appears in API keys' hint ("per-app keys"). Label wins.
expect(top('app')).toEqual(['App store']);
});
test('nothing matches means nothing returned, not everything', () => {
expect(rank(ITEMS, 'zzzzz')).toHaveLength(0);
});
test('an empty query keeps the given order rather than sorting it', () => {
expect(rank(ITEMS, '').map((s) => s.item.label)).toEqual(ITEMS.map((i) => i.label));
});
test('shorter labels break ties — "Chat" over "Chat history"', () => {
const withLonger = [...ITEMS, item('Chat history', '/chat-history')];
expect(rank(withLonger, 'chat')[0]!.item.label).toBe('Chat');
});
});
@@ -0,0 +1,93 @@
import type { SpotlightItem } from './useSpotlightItems';
// Ranking, kept apart from React so it can be tested with strings.
//
// Subsequence matching, not substring: "cmdhq" should find "Command HQ" and "apik" should find
// "API keys". Anyone who has used a launcher expects to type the shape of a word rather than a prefix
// of it, and a substring match makes them type more to get less.
export type Scored = { item: SpotlightItem; score: number; hits: number[] };
/**
* Where each character of `query` lands in `text`, or null if it does not fit.
*
* Greedy left-to-right, which is what makes it cheap and predictable. It can miss a better alignment —
* "ao" against "app store" takes the first `o` in the first word rather than the one in "store" — and
* that costs a slightly worse score, never a wrong answer, because the item still matches.
*/
function subsequence(text: string, query: string): number[] | null {
const hits: number[] = [];
let at = 0;
for (const char of query) {
const found = text.indexOf(char, at);
if (found === -1) return null;
hits.push(found);
at = found + 1;
}
return hits;
}
/** Start of the string, or the character after a space, dash or slash. */
const isBoundary = (text: string, i: number): boolean => i === 0 || /[\s\-/]/.test(text[i - 1]!);
/**
* Higher is better. The weights encode what a person means by "the right result":
*
* - **an exact label** is what they typed, so nothing beats it
* - **a prefix** beats a scattered match — typing "fi" should rank Files above Profile
* - **word boundaries** are why "chq" finds Command HQ at all, and should rank it above an item where
* those letters happen to fall mid-word
* - **adjacency** rewards a run of letters, which is what a substring match would have found
* - **shortness** breaks ties toward the more specific label: "Chat" over "Chat history"
*
* The label is worth roughly three times the hint, so typing a word that IS a label beats an item that
* merely mentions it in passing.
*/
function scoreOne(text: string, query: string, weight: number): { score: number; hits: number[] } | null {
const hits = subsequence(text, query);
if (!hits) return null;
let score = 0;
if (text === query) score += 1000;
else if (text.startsWith(query)) score += 400;
for (let i = 0; i < hits.length; i++) {
const at = hits[i]!;
if (isBoundary(text, at)) score += 30;
if (i > 0 && at === hits[i - 1]! + 1) score += 15;
}
// Shorter labels win ties. Capped so a very long one is not driven negative.
score += Math.max(0, 40 - text.length);
return { score: score * weight, hits };
}
/**
* Rank items against a query.
*
* An empty query returns everything in its given order — the index is already in a sensible one (dock
* first, settings after), and imposing an alphabetical sort on an empty box would move Home away from
* the top for no reason.
*/
export function rank(items: SpotlightItem[], query: string): Scored[] {
const q = query.trim().toLowerCase();
if (!q) return items.map((item) => ({ item, score: 0, hits: [] }));
const scored: Scored[] = [];
for (const item of items) {
const label = scoreOne(item.label.toLowerCase(), q, 1);
const hint = item.hint ? scoreOne(item.hint.toLowerCase(), q, 0.3) : null;
// The PATH is matched too, unweighted almost to nothing: it is how "/settings/profile" finds Profile
// when somebody types a URL fragment, without letting a path ever outrank a real label.
const path = scoreOne(item.to.toLowerCase(), q, 0.15);
const best = [label, hint, path]
.filter((s): s is { score: number; hits: number[] } => !!s)
.sort((a, b) => b.score - a.score)[0];
if (!best) continue;
// `hits` is only meaningful for the label, since that is the only string rendered with highlighting.
scored.push({ item, score: best.score, hits: label?.hits ?? [] });
}
return scored.sort((a, b) => b.score - a.score || a.item.label.length - b.item.label.length);
}
@@ -0,0 +1,156 @@
import { useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
Bot,
CalendarDays,
Cpu,
Globe,
KeyRound,
LayoutGrid,
Lock,
Mail,
Mic,
ScanText,
ShieldCheck,
Sparkles,
User,
Users,
Volume2,
Wrench,
} from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
// What Spotlight can take you to.
//
// ── It indexes the DOCK, rather than a list of its own ──
//
// The dock is already the composed, permission-filtered answer to "what can this account open here":
// the shell's own screens, plus whatever installed plugins contribute, minus anything `canVisit` refuses.
// Building a second list would mean two answers to one question, and the second would be wrong the day
// somebody installs a plugin — which is the whole failure the dock's own comment warns about, arrived at
// from a third direction.
//
// So a new plugin appears in Spotlight for the same reason it appears in the dock: because it is
// installed. Nothing here is edited.
//
// Settings sections are added because they are the things people hunt for and the dock deliberately does
// not carry. They are static: a settings screen is shell, not a plugin.
export type SpotlightItem = {
/** Stable across renders — used as the React key and for the recency store. */
id: string;
label: string;
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
to: string;
/** Shown small beside the label, so two "Profile"s are distinguishable. */
hint?: string;
icon?: LucideIcon;
/** Plugin artwork, when the tile has some. */
image?: string;
color?: string;
/**
* Copied from the dock tile, and it has to be.
*
* `bare` means the artwork brings its own frame — calendar, contacts, and every plugin shipping a logo.
* Dropping it here painted a coloured swatch behind those logos, which the dock's own comment calls out
* as reading like a mistake. It was dropped, so Spotlight looked like a different app's list of the same
* things.
*/
tile?: 'badge' | 'bare';
};
/**
* The settings sections, which the dock does not carry.
*
* Hand-listed rather than derived, and that is a real cost: a section added to `IntegrationsSettings`
* will not appear here until someone adds it. The alternative is exporting every settings screen's
* section array up into the shell, which couples five screens to this one to save five lines.
*
* IT DRIFTED, and within two days. The first version listed nine rows against a settings area that has
* twenty-one, missed Languages and Voice entirely, pointed `/settings/users` at a route that does not
* exist (it is `/settings/user-management/users`), and gave four different rows the same User glyph —
* which is most of why the whole panel read as a generic list rather than as the app's own.
*
* So the cost is now measured rather than predicted, and deriving these from the screens is the fix if it
* happens a third time. Each group below names the file it is copied from, so checking is one grep.
*
* `hint` is what makes them findable — typing "keys" should reach the API keys screen, and the label alone
* does not say where it lives.
*/
const SETTINGS_ITEMS: SpotlightItem[] = [
// Profile — ProfileSettings/index.tsx
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: Lock },
{
id: 'set:api-keys',
label: 'API keys',
to: '/settings/profile/api-keys',
hint: 'keys that sign in as you',
icon: KeyRound,
},
{
id: 'set:languages',
label: 'Languages',
to: '/settings/profile/languages',
hint: 'spoken and translation',
icon: Globe,
},
{ id: 'set:voice', label: 'Voice', to: '/settings/profile/voice', hint: 'text-to-speech', icon: Volume2 },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: LayoutGrid },
// AI — AISettings.tsx
{ id: 'set:ai-my-models', label: 'My Models', to: '/settings/ai/my-models', icon: Sparkles },
{ id: 'set:ai-channel-models', label: 'Channel Models', to: '/settings/ai/member-models', icon: Sparkles },
{ id: 'set:ai-default-models', label: 'Default Models', to: '/settings/ai/default-models', icon: Sparkles },
{ id: 'set:ai-harnesses', label: 'Providers', to: '/settings/ai/ai-harnesses', hint: 'harnesses', icon: Bot },
// System — SystemSettings.tsx
{ id: 'set:tts', label: 'Text to Speech', to: '/settings/system/tts', icon: Volume2 },
{ id: 'set:stt', label: 'Speech to Text', to: '/settings/system/stt', icon: Mic },
{ id: 'set:ocr', label: 'OCR', to: '/settings/system/ocr', icon: ScanText },
{
id: 'set:maintenance',
label: 'Rebuild & restart',
to: '/settings/system/maintenance',
hint: 'maintenance',
icon: Cpu,
},
{ id: 'set:smtp', label: 'SMTP', to: '/settings/system/smtp', hint: 'outgoing mail', icon: Mail },
// Integrations — IntegrationsSettings
{ id: 'set:google-oauth', label: 'Google OAuth', to: '/settings/integrations/google-oauth', icon: KeyRound },
{ id: 'set:apify', label: 'Apify', to: '/settings/integrations/apify', icon: Wrench },
{
id: 'set:dav',
label: 'Calendar & Contacts sync',
to: '/settings/integrations/dav-app-passwords',
hint: 'app passwords',
icon: CalendarDays,
},
// Users — UserManagement
{ id: 'set:users', label: 'Accounts', to: '/settings/user-management/users', hint: 'users', icon: Users },
{ id: 'set:permissions', label: 'Permissions', to: '/settings/user-management/permissions', icon: ShieldCheck },
];
/** Everything Spotlight can offer this account, in a stable order. */
export function useSpotlightItems(): SpotlightItem[] {
const { canVisit, plugins } = usePermissions();
return useMemo(() => {
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
.filter((item) => canVisit(item.to))
.map((item) => ({
id: `nav:${item.to}`,
label: item.label,
to: item.to,
icon: item.icon,
image: item.image,
color: item.color,
tile: item.tile,
}));
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
return [...fromDock, ...settings];
}, [canVisit, plugins]);
}
@@ -54,7 +54,7 @@ export const ApifyConfig = () => {
{status && (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-success' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
/>
<span className="text-sm text-duck-dark dark:text-foreground">
{status.configured ? 'API token configured' : 'Not configured'}
@@ -1,199 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
type RelayToken = {
token: string;
port: number;
};
export const BrowserRelay = () => {
const client = useClient();
const queryClient = useQueryClient();
const [copiedField, setCopiedField] = useState<string | null>(null);
const { data: status } = useQuery({
queryKey: ['browser-status'],
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
refetchInterval: 5000,
});
const { data: tokenData } = useQuery({
queryKey: ['browser-relay-token'],
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
});
const regenerate = useMutation({
mutationFn: () => client.post<RelayToken>('/browser/relay-token/regenerate'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
toast.success('Token regenerated — update the extension with the new token');
},
onError: () => toast.error('Failed to regenerate token'),
});
const revoke = useMutation({
mutationFn: () => client.delete('/browser/relay-token'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
toast.success('Token revoked');
},
onError: () => toast.error('Failed to revoke token'),
});
const handleCopy = async (value: string, field: string) => {
try {
await copyToClipboard(value);
setCopiedField(field);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedField(null), 2000);
} catch {
toast.error('Failed to copy');
}
};
const isConnected = status?.extensionConnected ?? false;
const serverAddress = window.location.hostname;
return (
<div className="grid gap-4">
{isConnected ? (
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
{' — '}
<a href="/browser" className="text-duck-teal underline">
view tabs
</a>
</p>
</div>
</div>
) : (
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Connect your Chrome browser to Officer so AI agents can view and interact with your tabs.
</p>
)}
{/* Step 1: Install */}
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">1. Install the extension</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Download and unzip the extension, then load it in Chrome:
</p>
<a
href="/browser-relay-extension.zip"
download
className="flex items-center justify-center gap-2 w-full h-11 rounded-md border border-duck-dark/15 dark:border-foreground/15 text-sm font-medium hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
>
<Download className="h-4 w-4" />
Download Extension
</a>
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<li>
Open{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
chrome://extensions
</code>{' '}
in Chrome
</li>
<li>
Enable <strong>Developer mode</strong> (top-right toggle)
</li>
<li>
Click <strong>Load unpacked</strong> and select the unzipped folder
</li>
</ol>
</div>
{/* Step 2: Configure */}
{tokenData && (
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">2. Configure the extension</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Open the extension options (right-click icon → Options) and enter these values:
</p>
<div className="grid gap-2">
<CredentialRow
label="Server address"
value={serverAddress}
copied={copiedField === 'host'}
onCopy={() => handleCopy(serverAddress, 'host')}
/>
<CredentialRow
label="Port"
value={String(tokenData.port)}
copied={copiedField === 'port'}
onCopy={() => handleCopy(String(tokenData.port), 'port')}
/>
<CredentialRow
label="Relay token"
value={tokenData.token}
masked
copied={copiedField === 'token'}
onCopy={() => handleCopy(tokenData.token, 'token')}
/>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Regenerate
</Button>
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
className="text-red-500 hover:text-red-600"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Revoke
</Button>
</div>
</div>
)}
{/* Step 3: Attach */}
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
means the tab is connected. Then go to{' '}
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
/browser <ExternalLink className="h-3 w-3" />
</a>{' '}
to view your tabs.
</p>
</div>
</div>
);
};
type CredentialRowProps = {
label: string;
value: string;
masked?: boolean;
copied: boolean;
onCopy: () => void;
};
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
<button
onClick={onCopy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
</button>
</div>
);
@@ -71,7 +71,7 @@ export const GoogleAccount = () => {
{status.connected ? (
<>
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
<div className="h-2.5 w-2.5 rounded-full bg-success shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
@@ -221,7 +221,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-success' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
</span>
@@ -8,10 +8,8 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { BrowserRelay } from './BrowserRelay';
import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords';
import { ApiKeys } from './ApiKeys';
const BASE_PATH = '/settings/integrations';
@@ -51,20 +49,6 @@ const personalSections: SettingsSection[] = [
description: 'Per-device passwords for CalDAV/CardDAV clients',
content: <DavAppPasswords />,
},
{
key: 'api-keys',
icon: KeyRound,
title: 'API keys',
description: 'Per-app keys that sign in as you, revocable one at a time',
content: <ApiKeys />,
},
{
key: 'browser-relay',
icon: Globe,
title: 'Browser Relay',
description: 'Connect your Chrome browser',
content: <BrowserRelay />,
},
];
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
@@ -2,7 +2,7 @@ import type { FormEvent } from 'react';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Copy, Ban, Plus, KeyRound, Loader2 } from 'lucide-react';
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -53,6 +53,7 @@ export const ApiKeys = () => {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [showRevoked, setShowRevoked] = useState(false);
const [minted, setMinted] = useState<Minted | null>(null);
const { data, isLoading } = useQuery<{ keys: ApiKey[] }>({
@@ -79,6 +80,15 @@ export const ApiKeys = () => {
}
};
const forget = async (id: number) => {
try {
await client.delete(`/api-keys/${id}/record`);
await refresh();
} catch {
toast.error('Could not remove it');
}
};
const revoke = async (id: number) => {
try {
await client.delete(`/api-keys/${id}`);
@@ -89,7 +99,19 @@ export const ApiKeys = () => {
}
};
const keys = data?.keys ?? [];
const allKeys = data?.keys ?? [];
// Dead means revoked OR past its expiry — the same test the row rendering uses, lifted here so the
// count in the toggle and the rows it reveals can never disagree.
const isDead = (entry: ApiKey) =>
!!entry.revokedAt || (!!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now());
// Dead keys are HIDDEN by default rather than merely dimmed. Revoking is the normal end of a key's
// life, so on any account that has been used for a while the dead ones outnumber the live ones and the
// screen stops answering the question it exists for — which of my keys still work. The rows are kept
// and reachable; they are just not the first thing shown.
const deadKeys = allKeys.filter(isDead);
const keys = showRevoked ? allKeys : allKeys.filter((entry) => !isDead(entry));
if (isLoading) {
return (
@@ -102,8 +124,8 @@ export const ApiKeys = () => {
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
An API key signs in as you, without a password and without expiring. Give each app or device its own, so you can
cut one off without touching the rest. Send it as{' '}
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
</p>
@@ -150,7 +172,9 @@ export const ApiKeys = () => {
{keys.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">
No keys yet.
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
that would send somebody looking for a bug in the list. */}
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'}
</div>
) : (
<div className="grid gap-2">
@@ -181,11 +205,23 @@ export const ApiKeys = () => {
{lastUsed ? `last used ${lastUsed}` : 'never used'}
</div>
</div>
{!dead && (
{dead ? (
// Only offered once the key is already dead — the server refuses it otherwise, and the
// button matching that rule is what keeps the two from drifting.
<Button
variant="ghost"
size="sm"
onClick={() => revoke(entry.id)}
onClick={() => void forget(entry.id)}
title="Remove this record"
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => void revoke(entry.id)}
title="Revoke"
className="hover:text-red-500"
>
@@ -197,6 +233,18 @@ export const ApiKeys = () => {
})}
</div>
)}
{deadKeys.length > 0 && (
<button
type="button"
onClick={() => setShowRevoked((v) => !v)}
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
>
{showRevoked
? 'Hide revoked and expired'
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`}
</button>
)}
</div>
);
};
@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { User, Lock, Globe, LayoutGrid, Volume2 } from 'lucide-react';
import { User, Lock, Globe, KeyRound, LayoutGrid, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { ApiKeys } from './ApiKeys';
import { Languages } from './Languages';
import { DockSettings } from './DockSettings';
import { VoicePreference } from './VoicePreference';
@@ -21,6 +22,17 @@ const sections: SettingsSection[] = [
description: 'Update your password',
content: <ChangePassword />,
},
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
// integration with anything — it is a second way to present the authority this account already has, and
// the permission registry says so: `/api-keys` is `core`, sitting with `/user` and `/dock`. Somebody
// looking for it went to user management and then to profile, which is where it should have been.
{
key: 'api-keys',
icon: KeyRound,
title: 'API keys',
description: 'Keys that sign in as you, revocable one at a time',
content: <ApiKeys />,
},
{
key: 'languages',
icon: Globe,
@@ -290,7 +290,7 @@ export const AIHarnessesSection = () => {
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-green-500' : 'bg-red-500') : 'bg-duck-dark/20'}`}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-success' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
@@ -439,7 +439,7 @@ export const AIHarnessesSection = () => {
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
remoteHealth[provider.providerId] === true
? 'bg-green-500'
? 'bg-success'
: remoteHealth[provider.providerId] === false
? 'bg-red-500'
: 'bg-duck-dark/20'
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Hammer, Loader2, RotateCw } from 'lucide-react';
import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
@@ -31,10 +31,45 @@ export function errorMessage(err: unknown, fallback: string): string {
export const MaintenanceSection = () => {
const client = useClient();
const [pulling, setPulling] = useState(false);
const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null);
const [rebuilding, setRebuilding] = useState(false);
const [restarting, setRestarting] = useState(false);
const [lastBuild, setLastBuild] = useState<string | null>(null);
/**
* Pull, and then say what the pull implies rather than acting on it.
*
* Deliberately does not chain into a rebuild or a restart. A pull that touched `bun.lock` means an
* install, which the frozen-lockfile rule exists to keep deliberate; one that touched a schema means
* `db:push` against a live database. Neither is a thing a button should decide, so `needs` is reported
* and the buttons below stay where they are.
*/
const pull = async () => {
setPulling(true);
try {
const res = await client.post<{
ok: boolean;
updated: boolean;
from?: string;
to?: string;
commits: string[];
needs: string[];
}>('/server-settings/pull', {});
if (!res.updated) {
setPulled(null);
toast.success('Already up to date');
return;
}
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`);
} catch (err) {
toast.error(errorMessage(err, 'Pull failed'));
} finally {
setPulling(false);
}
};
const rebuild = async () => {
setRebuilding(true);
try {
@@ -90,6 +125,43 @@ export const MaintenanceSection = () => {
return (
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Update the platform</div>
<p className="mt-1 text-xs text-muted-foreground">
Pulls this server&rsquo;s repository. Refuses if anything is uncommitted or the checkout has diverged — it
will never discard local work.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
</p>
{pulled && (
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
<p className="font-mono text-[11px] text-muted-foreground">
{pulled.from} → {pulled.to}
</p>
<ul className="mt-1 space-y-0.5">
{pulled.commits.slice(0, 5).map((line) => (
<li key={line} className="truncate text-[11px] text-foreground/80">
{line}
</li>
))}
{pulled.commits.length > 5 && (
<li className="text-[11px] text-muted-foreground">+{pulled.commits.length - 5} more</li>
)}
</ul>
{pulled.needs.length > 0 && (
<p className="mt-1.5 text-[11px] text-warning">Needs: {pulled.needs.join(', ')}</p>
)}
</div>
)}
</div>
<Button disabled={pulling || rebuilding || restarting} onClick={() => void pull()} className="shrink-0">
{pulling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{pulling ? 'Pulling…' : 'Update'}
</Button>
</div>
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
<p className="mt-1 text-xs text-muted-foreground">
@@ -98,7 +170,7 @@ export const MaintenanceSection = () => {
</p>
{lastBuild && <p className="mt-1 text-xs text-muted-foreground/70">Last build: {lastBuild}</p>}
</div>
<Button disabled={rebuilding || restarting} onClick={() => void rebuild()} className="shrink-0">
<Button disabled={pulling || rebuilding || restarting} onClick={() => void rebuild()} className="shrink-0">
{rebuilding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Hammer className="mr-2 h-4 w-4" />}
{rebuilding ? 'Building…' : 'Rebuild'}
</Button>
@@ -118,7 +190,7 @@ export const MaintenanceSection = () => {
</div>
<Button
variant="destructive"
disabled={rebuilding || restarting}
disabled={pulling || rebuilding || restarting}
onClick={() => void restart()}
className="shrink-0"
>
@@ -20,7 +20,5 @@ export * from './CodeEditor';
export * from './ChatHistory';
export * from './Dashboards';
export * from './Terminal';
export * from './Browser';
export * from './Desktop';
export * from './Jobs';
export * from './QrTransfer';
+10 -1
View File
@@ -169,8 +169,11 @@
.code-surface pre {
margin: 0;
padding: 0.75em 1em;
/* Room for the two copy buttons, so a long first line scrolls up to them rather than under them. */
/* Room for the copy controls, which sit absolutely at the bottom-right of the block. Horizontal so a
long line scrolls past them rather than under them; vertical so the LAST line — the one they now
overlap — keeps its own space. */
padding-right: 5.5em;
padding-bottom: 2em;
border-radius: 0.5rem;
background: #0d1117;
/* Scroll, never wrap: wrapping folds `ls -la`, `git log --oneline` and any other columnar output into
@@ -186,6 +189,12 @@
padding-right: 10em;
}
/* The positioning context for those controls. Without it they escape to the nearest positioned ancestor,
which is how a copy button ends up floating below the block it belongs to. */
.code-surface {
position: relative;
}
.code-surface pre code {
padding: 0;
border-radius: 0;
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,147 @@
import { and, desc, eq, lt } from 'drizzle-orm';
import { db } from '../db';
import { decryptSecret, encryptSecret } from '../crypto';
import { anthropicAccounts } from './schema';
/*
A stored Anthropic login, and the two shapes it comes in.
`StoredAccount` is what an API may return: who it is, how stale, whether it can still be used. It carries
no secret. `revealAccount` is the only way to the credential itself and exists for exactly one caller —
the switch, which writes it back to disk.
*/
/** The encryption purpose for one account. Per-account by design; see the schema note. */
const purposeFor = (accountUuid: string) => `anthropic-account:${accountUuid}`;
export type StoredAccount = {
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
capturedAt: string;
lastUsedAt: string | null;
refreshExpiresAt: string | null;
/** False once the refresh token is past its expiry: switching would fail, so the UI must offer /login. */
usable: boolean;
};
type SaveParams = {
userId: number;
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
/** The `claudeAiOauth` object, verbatim, as JSON. */
credentialsJson: string;
/** The `oauthAccount` object, verbatim, as JSON. */
oauthAccountJson: string;
refreshExpiresAt: Date | null;
};
const publish = (row: typeof anthropicAccounts.$inferSelect): StoredAccount => ({
accountUuid: row.accountUuid,
emailAccount: row.emailAccount,
orgName: row.orgName,
plan: row.plan,
capturedAt: row.capturedAt.toISOString(),
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
refreshExpiresAt: row.refreshExpiresAt?.toISOString() ?? null,
usable: !row.refreshExpiresAt || row.refreshExpiresAt.getTime() > Date.now(),
});
/**
* Record (or re-record) one account's credential.
*
* Upsert on `(userId, accountUuid)` rather than insert, because capturing happens every time the current
* account is noticed to have changed — so the common case is refreshing a row that already exists with a
* newer token. `capturedAt` moves; `lastUsedAt` deliberately does not, since capturing is not using.
*/
export async function saveAnthropicAccount(p: SaveParams): Promise<void> {
const purpose = purposeFor(p.accountUuid);
const values = {
userId: p.userId,
accountUuid: p.accountUuid,
emailAccount: p.emailAccount,
orgName: p.orgName,
plan: p.plan,
credentials: encryptSecret(purpose, p.credentialsJson),
oauthAccount: encryptSecret(purpose, p.oauthAccountJson),
refreshExpiresAt: p.refreshExpiresAt,
capturedAt: new Date(),
};
await db
.insert(anthropicAccounts)
.values(values)
.onConflictDoUpdate({
target: [anthropicAccounts.userId, anthropicAccounts.accountUuid],
set: {
emailAccount: values.emailAccount,
orgName: values.orgName,
plan: values.plan,
credentials: values.credentials,
oauthAccount: values.oauthAccount,
refreshExpiresAt: values.refreshExpiresAt,
capturedAt: values.capturedAt,
},
});
}
/** Every account this Officer user can switch to, most recently used first. Carries no secret. */
export async function listAnthropicAccounts(userId: number): Promise<StoredAccount[]> {
const rows = await db
.select()
.from(anthropicAccounts)
.where(eq(anthropicAccounts.userId, userId))
.orderBy(desc(anthropicAccounts.lastUsedAt), desc(anthropicAccounts.capturedAt));
return rows.map(publish);
}
/**
* The credential itself, for the switch. Decrypts, so every call is a secret leaving the database.
*
* Returns null for an unknown account AND for one whose refresh token has expired: a caller that wrote an
* expired credential to disk would have signed the owner out with no message, which is the worst of the
* available failures.
*/
export async function revealAnthropicAccount(
userId: number,
accountUuid: string,
): Promise<{ credentialsJson: string; oauthAccountJson: string; emailAccount: string } | null> {
const [row] = await db
.select()
.from(anthropicAccounts)
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)))
.limit(1);
if (!row) return null;
if (row.refreshExpiresAt && row.refreshExpiresAt.getTime() <= Date.now()) return null;
const purpose = purposeFor(row.accountUuid);
return {
credentialsJson: decryptSecret(purpose, row.credentials),
oauthAccountJson: decryptSecret(purpose, row.oauthAccount),
emailAccount: row.emailAccount,
};
}
/** Marks the switch. Separate from reveal so a failed write does not claim the account is in use. */
export async function markAnthropicAccountUsed(userId: number, accountUuid: string): Promise<void> {
await db
.update(anthropicAccounts)
.set({ lastUsedAt: new Date() })
.where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid)));
}
/**
* Drop credentials that can no longer be used.
*
* A dead refresh token is not a credential, it is a liability with a thirty-day head start already spent —
* so it goes rather than sitting in the table for ever. Called opportunistically on list, not scheduled: a
* cron for one DELETE is a moving part that can silently stop.
*/
export async function purgeExpiredAnthropicAccounts(userId: number): Promise<number> {
const gone = await db
.delete(anthropicAccounts)
.where(and(eq(anthropicAccounts.userId, userId), lt(anthropicAccounts.refreshExpiresAt, new Date())))
.returning({ id: anthropicAccounts.id });
return gone.length;
}
@@ -0,0 +1,72 @@
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* The OAuth credential for each Anthropic account the owner signs into, so they can be switched between
* without a browser round trip.
*
* ── Why keep them at all ──
*
* The owner has a personal account and a company one and moves between them during a day — by what they are
* working on, and sometimes because one has hit a limit and the work has to continue. `claude auth login`
* handles the first switch and every switch after it, at the cost of a browser round trip each time, and it
* cannot switch BACK to an account whose credential the CLI has already overwritten.
*
* `ai_provider_usage` already answers "which of my accounts has room". This answers the question that
* immediately follows it: "then use that one".
*
* ── One key per account, not one key for the table ──
*
* `credentials` and `oauthAccount` are encrypted with `encryptSecret('anthropic-account:<uuid>', …)`, so the
* secret store mints a separate key per Anthropic account. One leaked key is one account rather than all of
* them, which is the same reasoning that split `VAULT_STORE_KEY` into purposes in the first place. The uuid
* comes from Anthropic (`oauthAccount.accountUuid`) so it is stable across a re-login, where the email is
* also stable but is user-editable at the provider.
*
* ── What is stored, and what that is worth to somebody who steals it ──
*
* The whole `claudeAiOauth` object, which includes a REFRESH token — deliberately, because the access token
* lives hours and the refresh token is what makes a switch work a week later. So a row plus the key is
* thirty days of acting as that account. That is a real escalation over holding one credential on disk, it
* was accepted knowingly, and two things reduce it: the per-account key above, and `refreshExpiresAt` being
* a column so a dead credential can be purged rather than sitting here for ever.
*
* ── Owner only, and per Officer user regardless ──
*
* Only the owner's `~/.claude` is switched today; a member's `claude` reads their own home and is untouched.
* The rows are keyed by Officer user anyway, because the same mechanism per home is the obvious extension
* and a table that assumed one user would have to be migrated to get there.
*/
export const anthropicAccounts = pgTable(
'anthropic_accounts',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Anthropic's own id for the account. The encryption purpose is derived from this. */
accountUuid: text('account_uuid').notNull(),
/** What the pill reads, and what `ai_provider_usage.email_account` joins on in practice. */
emailAccount: text('email_account').notNull(),
/** `Pertento`, or null on a personal account with no organisation. Display only. */
orgName: text('org_name'),
/** `Team`, `Max 20x`, `Pro`. Display only. */
plan: text('plan'),
/** `claudeAiOauth` from `.credentials.json`, encrypted. Never logged, never returned by an API. */
credentials: text('credentials').notNull(),
/** `oauthAccount` from `.claude.json`, encrypted. Identity rather than credential, but it travels with it. */
oauthAccount: text('oauth_account').notNull(),
/**
* When the refresh token dies. Past this, a switch cannot succeed and the UI must say so instead of
* failing at spawn time — which would look like the agent being broken rather than a sign-in expiring.
*/
refreshExpiresAt: timestamp('refresh_expires_at', { withTimezone: true }),
/** When this credential was captured — how stale the thing you are switching to is. */
capturedAt: timestamp('captured_at', { withTimezone: true }).notNull().defaultNow(),
/** Last time it was switched TO. Orders the pills by what the owner actually uses. */
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
},
// uniqueIndex, never unique().on() — drizzle-kit mis-diffs named composite unique CONSTRAINTS and
// re-creates them on every push. See src/databases/CLAUDE.md → "Composite keys".
(table) => [uniqueIndex('uq_anthropic_accounts_account').on(table.userId, table.accountUuid)],
);
@@ -3,6 +3,7 @@ export {
createApiKey,
listApiKeys,
revokeApiKey,
deleteApiKey,
touchApiKey,
type ApiKeyIdentity,
} from './queries';
@@ -1,4 +1,4 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import { eq, and, isNull, isNotNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { apiKeys } from './schema';
import { users } from '../auth/schema';
@@ -99,6 +99,32 @@ export async function revokeApiKey(id: number, userId: number): Promise<boolean>
return rows.length > 0;
}
/**
* Delete a revoked key's row for good.
*
* Separate from revoking, and only reachable AFTER it — the `revokedAt is not null` clause is in the
* statement rather than checked before it, so there is no window where a live key is deleted by a request
* that raced a revoke.
*
* ── Why this is a second step and not what revoke does ──
*
* The row IS the record that the key existed. `lastUsedAt` is the only evidence of whether a leaked key
* was used before it was killed, and on what date; deleting on revoke would throw that away at exactly
* the moment somebody most wants it. So revoke keeps everything and this exists for afterwards, when the
* question has been answered and the row is only clutter.
*
* Deleting is safe in a way it would not be for a password: the key can never be honoured again either
* way, because `authenticate` matches on a hash that no longer has a row. Losing the row loses the
* history, not the protection.
*/
export async function deleteApiKey(id: number, userId: number): Promise<boolean> {
const rows = await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId), isNotNull(apiKeys.revokedAt)))
.returning({ id: apiKeys.id });
return rows.length > 0;
}
/** How stale last_used_at is allowed to get. A write per request is not worth this column's value. */
const TOUCH_DEBOUNCE_MS = 60_000;
@@ -25,3 +25,5 @@ export {
export { USER_ROLES, OWNER_USER_ID } from './schema';
export type { UserRole } from './schema';
export { listProvisionableUsers } from './queries';
export type { ProvisionableUser } from './queries';
+51 -2
View File
@@ -1,4 +1,4 @@
import { eq, and, lt, sql } from 'drizzle-orm';
import { eq, ne, and, lt, sql } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from './schema';
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
@@ -9,6 +9,40 @@ export async function getUsers(): Promise<UserSelect[]> {
return db.select().from(users);
}
/** What a plugin is told about an account. Deliberately not a `UserSelect`. */
export type ProvisionableUser = {
id: number;
email: string;
username: string | null;
name: string | null;
role: string;
};
/**
* The accounts a plugin may provision in its upstream service.
*
* A hand-written projection, NOT `select *`. `users` holds `password` (the hash) and `os_user`, and
* `getUsers()` returns both — so a plugin reaching for the obvious function gets every account's
* password hash. The columns are named here so that cannot happen by accident: adding a secret to the
* table does not silently add it to what plugins receive.
*
* The OWNER is excluded. They are user 1 by CHECK constraint, they bypass every permission check, and
* they already have whatever account they want upstream — provisioning them is at best a no-op and at
* worst a second admin nobody asked for.
*/
export async function listProvisionableUsers(): Promise<ProvisionableUser[]> {
return db
.select({
id: users.id,
email: users.email,
username: users.username,
name: users.name,
role: users.role,
})
.from(users)
.where(ne(users.id, OWNER_USER_ID));
}
export async function getUserById(id: number): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user;
@@ -22,8 +56,23 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
// `username` is unique in the schema, so this exists to turn a would-be constraint violation into a
// sentence. Creating an account is a form someone fills in, and "duplicate key value violates unique
// constraint users_username_unique" is not an answer to give them.
/**
* Case-INSENSITIVE, deliberately.
*
* This compared with `eq` until 2026-08-20, which made `diogo` and `DIOGO` two different accounts. That
* is a problem on its own — a person typing their own name with the wrong shift key is not a different
* person — and it becomes a real one downstream: Jellyfin enforces uniqueness on an uppercased
* `NormalizedUsername`, so two Officer accounts that Officer thinks are distinct collide there and the
* second cannot be provisioned at all.
*
* The unique index in `schema.ts` is on `lower(username)` for the same reason, so this cannot be the only
* thing standing between two colliding names.
*/
export async function getUserByUsername(username: string): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.username, username));
const [user] = await db
.select()
.from(users)
.where(sql`lower(${users.username}) = lower(${username})`);
return user;
}
+17 -1
View File
@@ -34,7 +34,14 @@ export const users = pgTable(
// authorisation cannot accidentally mint an admin.
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
name: text('name'),
username: text('username').unique(),
/**
* Required, and unique case-INSENSITIVELY — see the index below.
*
* `validateUsername` has always demanded one, but the column allowed null, so a code path that
* skipped validation could write a user nobody could name. Anything downstream that identifies a
* person by username — a Linux account, a Jellyfin login — then has nothing to work with.
*/
username: text('username').notNull(),
/**
* The Linux account this platform account runs as, when per-user OS accounts are enabled.
*
@@ -69,6 +76,15 @@ export const users = pgTable(
// Two accounts must not share one Linux user — that would make "whose uid is this" ambiguous for a
// shell. An index rather than a constraint: see the note on the column.
uniqueIndex('uq_users_os_user').on(table.osUser),
// Usernames are unique CASE-INSENSITIVELY. `diogo` and `DIOGO` are one name.
//
// On `lower(username)` rather than the column, because a plain unique index would let both exist —
// which was the state until 2026-08-20. Two consequences, and the second is why it is a constraint
// rather than a check in `create-user.ts`: a person cannot accidentally register a shadow of their
// own account, and anything identifying them downstream by name gets one answer. Jellyfin enforces
// uniqueness on an uppercased `NormalizedUsername`, so two Officer accounts Officer thought were
// distinct collide there, and the second is unprovisionable with no way to see why from this side.
uniqueIndex('uq_users_username_lower').on(sql`lower(${table.username})`),
// The owner cannot be demoted. Enforced here rather than in application code because the whole
// point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql
// prompt. Postgres rejects the write; there is no path around it.
+2
View File
@@ -35,7 +35,9 @@ export * from './permissions';
export * from './chat-events';
export * from './chat-session-models';
export * from './chat-notification-prefs';
export * from './nudges';
export * from './ai-provider-usage';
export * from './anthropic-accounts';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
@@ -0,0 +1 @@
export { setNudge, getNudge, cancelNudge, dueNudges, recordNudgeSent } from './queries';
@@ -0,0 +1,14 @@
import { describe, expect, test } from 'bun:test';
import { dueNudges } from './queries';
describe('dueNudges', () => {
// It failed on EVERY tick and the only symptom was a log line nobody was reading: the row sat there
// enabled, with sent_count 0, looking perfectly armed. A JS Date bound as a parameter on the left of an
// interval subtraction is a type Postgres will not infer, so it refused the whole statement.
//
// Asserting only that it EXECUTES. The due-ness logic is Postgres arithmetic; what broke was the SQL
// being invalid, which no amount of reasoning about intervals would have caught.
test('is valid SQL against the real database', async () => {
expect(Array.isArray(await dueNudges())).toBe(true);
});
});
@@ -0,0 +1,83 @@
import { and, eq, lt, or, isNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { nudges } from './schema';
import type { NudgeSelect } from '../types';
/** Create or replace the schedule for one session. Re-arming resets the count — it is a new night. */
export async function setNudge(params: {
userId: number;
sessionId: string;
message: string;
everySeconds: number;
maxCount: number;
}): Promise<NudgeSelect> {
const [row] = await db
.insert(nudges)
.values({ ...params, enabled: true, sentCount: 0, lastFiredAt: null })
.onConflictDoUpdate({
target: [nudges.userId, nudges.sessionId],
set: {
message: params.message,
everySeconds: params.everySeconds,
maxCount: params.maxCount,
enabled: true,
sentCount: 0,
lastFiredAt: null,
updatedAt: new Date(),
},
})
.returning();
return row!;
}
export async function getNudge(userId: number, sessionId: string): Promise<NudgeSelect | null> {
const [row] = await db
.select()
.from(nudges)
.where(and(eq(nudges.userId, userId), eq(nudges.sessionId, sessionId)))
.limit(1);
return row ?? null;
}
export async function cancelNudge(userId: number, sessionId: string): Promise<void> {
await db.delete(nudges).where(and(eq(nudges.userId, userId), eq(nudges.sessionId, sessionId)));
}
/**
* Everything the scheduler should consider this tick.
*
* Due means: enabled, under its ceiling, and either never fired or last fired at least `every_seconds`
* ago. Whether the session is actually IDLE is not asked here — that lives in the sidecar, not the
* database, and the scheduler checks it immediately before speaking.
*/
export async function dueNudges(): Promise<NudgeSelect[]> {
return db
.select()
.from(nudges)
.where(
and(
eq(nudges.enabled, true),
lt(nudges.sentCount, nudges.maxCount),
or(
isNull(nudges.lastFiredAt),
// `now()` in SQL rather than a JS Date bound as a parameter. Postgres cannot infer the type of
// a parameter on the left of an interval subtraction and refuses the whole statement — which
// failed EVERY tick, silently, with the row sitting there looking armed.
lt(nudges.lastFiredAt, sql`now() - (${nudges.everySeconds} * interval '1 second')`),
),
),
);
}
/** One nudge delivered. Disables itself on reaching the ceiling rather than leaving a spent row armed. */
export async function recordNudgeSent(id: number): Promise<void> {
await db
.update(nudges)
.set({
sentCount: sql`${nudges.sentCount} + 1`,
lastFiredAt: new Date(),
updatedAt: new Date(),
enabled: sql`${nudges.sentCount} + 1 < ${nudges.maxCount}`,
})
.where(eq(nudges.id, id));
}
@@ -0,0 +1,42 @@
import { pgTable, serial, integer, text, boolean, timestamp, check, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// Nudger: keep a chat moving while nobody is watching it.
//
// An agent told to record blockers and continue will still stop at the end of a turn, and at 3am nobody
// types the next message. This is the schedule that does.
//
// One nudge per (user, session). A second schedule for the same conversation is not a feature — it is two
// alarms for one sleeper, and the only thing it reliably produces is a doubled prompt.
export const nudges = pgTable(
'nudges',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The chat session's transcript uuid — the same key `chat_session_models` uses. */
sessionId: text('session_id').notNull(),
message: text('message').notNull(),
everySeconds: integer('every_seconds').notNull().default(3600),
/** A loop nobody is watching should end on its own. */
maxCount: integer('max_count').notNull().default(12),
sentCount: integer('sent_count').notNull().default(0),
enabled: boolean('enabled').notNull().default(true),
/** Null until the first send. The scheduler treats null as "due now". */
lastFiredAt: timestamp('last_fired_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// uniqueIndex, not unique().on() — drizzle-kit re-creates named composite CONSTRAINTS on every push.
// See src/databases/CLAUDE.md → "Composite keys".
uniqueIndex('uq_nudges_user_session').on(table.userId, table.sessionId),
// A floor, because the scheduler ticks once a minute and anything under that is a lie about cadence
// rather than a faster nudge.
check('ck_nudges_every_seconds', sql`${table.everySeconds} >= 60`),
check('ck_nudges_max_count', sql`${table.maxCount} > 0`),
],
);
+2
View File
@@ -34,6 +34,7 @@ export * from './chat-events/schema'; // chat_session_events
export * from './chat-session-models/schema'; // chat_session_models
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './agent-panels/schema'; // agent_panels
@@ -45,6 +46,7 @@ export * from './app-store/schema'; // sidecar_installs
export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own
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 ─────────────────────────────────────────────
+1
View File
@@ -82,6 +82,7 @@ export type PipelineJobSelect = typeof Schema.pipelineJobs.$inferSelect;
export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
// Notifications
export type NudgeSelect = typeof Schema.nudges.$inferSelect;
export type PushDeviceSelect = typeof NotifySchema.pushDevices.$inferSelect;
export type PushDeviceInsert = typeof NotifySchema.pushDevices.$inferInsert;
-45
View File
@@ -1,45 +0,0 @@
# Officer Browser Relay — Setup Guide
## Prerequisites
- Officer server running (`bun dev`)
- Google Chrome or Chromium-based browser on your machine
- Both machines on the same Tailscale network (or localhost for local dev)
## Steps
### 1. Verify relay is running
Check the server logs for `[browser-relay] listening on port 18792`.
### 2. Get your relay credentials
Open Officer dashboard → **Browser** screen (or `/browser`).
The setup card shows your **server address**, **port**, and **relay token**.
### 3. Load the extension in Chrome
1. Open `chrome://extensions`
2. Enable **Developer mode** (top-right toggle)
3. Click **Load unpacked**
4. Select `src/extensions/browser-relay/`
### 4. Configure the extension
1. The options page opens automatically after install (or right-click extension → Options)
2. Enter the **Server address** (Officer's IP — e.g. your Tailscale IP, or `127.0.0.1` for local)
3. Enter the **Port** (default `18792`)
4. Paste your **Relay token** from step 2
5. Click **Save** — status should show green "Relay reachable and authenticated"
### 5. Attach a tab
1. Navigate to any webpage in Chrome
2. Click the Officer Browser Relay extension icon in the toolbar
3. Badge shows **ON** (cyan) = attached
### 6. Use from the dashboard
1. Open `/browser` in Officer
2. Your attached tab appears in the left panel
3. Click it to see a live screenshot in the right panel
4. Use the URL bar to navigate, or the JS console to evaluate expressions
## Troubleshooting
- **Red `!` badge**: Relay not reachable — check server address/port/token
- **`…` badge**: Reconnecting — relay was temporarily unavailable
- **Tab not appearing**: Make sure the extension badge shows ON, and refresh the dashboard
- **Screenshot blank**: The tab may be on a `chrome://` page (not supported by CDP)
@@ -1,51 +0,0 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
export function reconnectDelayMs(
attempt,
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
) {
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
const random = typeof opts.random === "function" ? opts.random : Math.random;
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
return backoff + Math.max(0, jitterMs) * random();
}
export async function deriveRelayToken(gatewayToken, port) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(gatewayToken),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`officer-browser-relay-v1:${port}`),
);
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function buildRelayWsUrl(host, port, gatewayToken) {
const token = String(gatewayToken || "").trim();
if (!token) {
throw new Error(
"Missing relay token in extension settings (chrome.storage.local.relayToken)",
);
}
// Token is already derived server-side — use it directly
return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
}
export function isRetryableReconnectError(err) {
const message = err instanceof Error ? err.message : String(err || "");
if (message.includes("Missing relay token")) {
return false;
}
return true;
}
-978
View File
@@ -1,978 +0,0 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
const BADGE = {
on: { text: 'ON', color: '#06b6d4' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let relayGatewayToken = ''
/** @type {string|null} */
let relayConnectRequestId = null
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
/** @type {Set<number>} */
const tabOperationLocks = new Set()
/** @type {Set<number>} */
const reattachPending = new Set()
/** @type {Set<number>} */
const reattachingTabs = new Set()
let reconnectAttempt = 0
let reconnectTimer = null
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
async function getRelayHost() {
const stored = await chrome.storage.local.get(['relayHost'])
const host = String(stored.relayHost || '').trim()
return host || DEFAULT_HOST
}
async function getRelayToken() {
const stored = await chrome.storage.local.get(['relayToken'])
const token = String(stored.relayToken || '').trim()
return token || ''
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function persistState() {
try {
const tabEntries = []
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder })
}
}
await chrome.storage.session.set({
persistedTabs: tabEntries,
nextSession,
})
} catch {
// chrome.storage.session may not be available in all contexts.
}
}
async function rehydrateState() {
try {
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
if (stored.nextSession) {
nextSession = Math.max(nextSession, stored.nextSession)
}
const entries = stored.persistedTabs || []
for (const entry of entries) {
tabs.set(entry.tabId, {
state: 'connected',
sessionId: entry.sessionId,
targetId: entry.targetId,
attachOrder: entry.attachOrder,
})
tabBySession.set(entry.sessionId, entry.tabId)
setBadge(entry.tabId, 'on')
}
for (const entry of entries) {
try {
await chrome.tabs.get(entry.tabId)
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(entry.tabId)
tabBySession.delete(entry.sessionId)
setBadge(entry.tabId, 'off')
}
}
} catch {
// Ignore rehydration errors.
}
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const host = await getRelayHost()
const token = await getRelayToken()
const httpBase = `http://${host}:${port}`
const wsUrl = await buildRelayWsUrl(host, port, token)
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
relayGatewayToken = token
ws.onmessage = (event) => {
if (ws !== relayWs) return
void whenReady(() => onRelayMessage(String(event.data || '')))
}
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onclose = (ev) => {
if (ws !== relayWs) return
if (ev.code === 4000) {
onRelayReplaced()
return
}
onRelayClosed('closed')
}
ws.onerror = () => {
if (ws !== relayWs) return
onRelayClosed('error')
}
})()
try {
await relayConnectPromise
reconnectAttempt = 0
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
relayGatewayToken = ''
relayConnectRequestId = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
reattachPending.clear()
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay reconnecting…',
})
}
}
scheduleReconnect()
}
function onRelayReplaced() {
relayWs = null
relayGatewayToken = ''
relayConnectRequestId = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error('Replaced by another browser session'))
}
reattachPending.clear()
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: replaced by another browser — click to reconnect',
})
}
}
// Do NOT schedule reconnect — user must click to take over again
}
function scheduleReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const delay = reconnectDelayMs(reconnectAttempt)
reconnectAttempt++
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
try {
await ensureRelayConnection()
reconnectAttempt = 0
console.log('Reconnected successfully')
await reannounceAttachedTabs()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
if (!isRetryableReconnectError(err)) {
return
}
scheduleReconnect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
reconnectAttempt = 0
}
async function reannounceAttachedTabs() {
for (const [tabId, tab] of tabs.entries()) {
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(tabId)
if (tab.sessionId) tabBySession.delete(tab.sessionId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
continue
}
try {
const info = /** @type {any} */ (
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
)
const targetInfo = info?.targetInfo
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId: tab.sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
setBadge(tabId, 'on')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: attached (click to detach)',
})
} catch {
setBadge(tabId, 'on')
}
}
await persistState()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
function ensureGatewayHandshakeStarted(payload) {
if (relayConnectRequestId) return
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
sendToRelay({
type: 'req',
id: relayConnectRequestId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'officer-browser-relay-extension',
version: '1.0.0',
platform: 'chrome-extension',
mode: 'webchat',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
nonce: nonce || undefined,
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
},
})
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error('Relay request timeout (30s)'))
}, 30000)
pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) },
})
try {
sendToRelay(command)
} catch (err) {
clearTimeout(timer)
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.type === 'event' && msg.event === 'connect.challenge') {
try {
ensureGatewayHandshakeStarted(msg.payload)
} catch (err) {
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
relayConnectRequestId = null
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
relayConnectRequestId = null
if (!msg.ok) {
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
console.warn('gateway connect handshake rejected', String(detail))
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sid = nextSession++
const sessionId = `cb-tab-${sid}`
const attachOrder = sid
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
await persistState()
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: childSessionId, reason: 'parent_detached' },
},
})
} catch {
// Relay may be down.
}
childSessionToTab.delete(childSessionId)
}
}
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// Relay may be down.
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
try {
await chrome.debugger.detach({ tabId })
} catch {
// May already be detached.
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
await persistState()
}
function describeError(err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('not reachable')) return 'relay not reachable — check server address in options'
if (msg.includes('Missing relay token')) return 'no token configured — open options to set up'
if (msg.includes('401') || msg.includes('Unauthorized')) return 'token rejected — check token in options'
if (msg.toLowerCase().includes('timeout')) return 'connection timed out'
return msg.length > 80 ? msg.slice(0, 80) + '…' : msg
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
if (tabOperationLocks.has(tabId)) return
tabOperationLocks.add(tabId)
try {
if (reattachPending.has(tabId)) {
reattachPending.delete(tabId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
return
}
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
cancelReconnect()
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: connecting to relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: `Officer Browser Relay: ${describeError(err)}`,
})
void maybeOpenHelpOnce()
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message)
}
} finally {
tabOperationLocks.delete(tabId)
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
try {
return await chrome.debugger.sendCommand(debuggerSession, method, params)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('Session with given id not found')) throw err
// Chrome's internal debugger session became stale — re-attach and retry once
console.warn(`Stale session for tab ${tabId}, re-attaching debugger`)
reattachingTabs.add(tabId)
try {
await chrome.debugger.detach(debuggee).catch(() => {})
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
return await chrome.debugger.sendCommand(debuggee, method, params)
} finally {
reattachingTabs.delete(tabId)
}
}
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// Relay may be down.
}
}
async function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
if (reattachingTabs.has(tabId)) return
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
void detachTab(tabId, reason)
return
}
let tabInfo
try {
tabInfo = await chrome.tabs.get(tabId)
} catch {
void detachTab(tabId, reason)
return
}
if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) {
void detachTab(tabId, reason)
return
}
if (reattachPending.has(tabId)) return
const oldTab = tabs.get(tabId)
const oldSessionId = oldTab?.sessionId
const oldTargetId = oldTab?.targetId
if (oldSessionId) tabBySession.delete(oldSessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
if (oldSessionId && oldTargetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' },
},
})
} catch {
// Relay may be down.
}
}
reattachPending.add(tabId)
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: re-attaching after navigation…',
})
const delays = [300, 700, 1500]
for (let attempt = 0; attempt < delays.length; attempt++) {
await new Promise((r) => setTimeout(r, delays[attempt]))
if (!reattachPending.has(tabId)) return
try {
await chrome.tabs.get(tabId)
} catch {
reattachPending.delete(tabId)
setBadge(tabId, 'off')
return
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
reattachPending.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay disconnected during re-attach',
})
return
}
try {
await attachTab(tabId)
reattachPending.delete(tabId)
return
} catch {
// continue retries
}
}
reattachPending.delete(tabId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: re-attach failed (click to retry)',
})
}
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
reattachPending.delete(tabId)
if (!tabs.has(tabId)) return
const tab = tabs.get(tabId)
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' },
},
})
} catch {
// Relay may be down.
}
}
void persistState()
}))
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
const tab = tabs.get(removedTabId)
if (!tab) return
tabs.delete(removedTabId)
tabs.set(addedTabId, tab)
if (tab.sessionId) {
tabBySession.set(tab.sessionId, addedTabId)
}
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === removedTabId) {
childSessionToTab.set(childSessionId, addedTabId)
}
}
setBadge(addedTabId, 'on')
void persistState()
}))
chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab()))
chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
if (frameId !== 0) return
const tab = tabs.get(tabId)
if (tab?.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}))
chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
const tab = tabs.get(tabId)
if (tab?.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}))
chrome.runtime.onInstalled.addListener(() => {
void chrome.runtime.openOptionsPage()
})
chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== 'relay-keepalive') return
await initPromise
if (tabs.size === 0) return
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
if (!relayConnectPromise && !reconnectTimer) {
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
await ensureRelayConnection().catch(() => {
if (!reconnectTimer) {
scheduleReconnect()
}
})
}
}
})
const initPromise = rehydrateState()
initPromise.then(() => {
if (tabs.size > 0) {
ensureRelayConnection().then(() => {
reconnectAttempt = 0
return reannounceAttachedTabs()
}).catch(() => {
scheduleReconnect()
})
}
})
async function whenReady(fn) {
await initPromise
return fn()
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'relayCheck') return false
const { url, token } = msg
const headers = token ? { 'x-officer-relay-token': token } : {}
fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) })
.then(async (res) => {
const contentType = String(res.headers.get('content-type') || '')
let json = null
if (contentType.includes('application/json')) {
try {
json = await res.json()
} catch {
json = null
}
}
sendResponse({ status: res.status, ok: res.ok, contentType, json })
})
.catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
return true
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 B

@@ -1,25 +0,0 @@
{
"manifest_version": 3,
"name": "Officer Browser Relay",
"version": "0.1.0",
"description": "Connect your Chrome browser to Officer",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Officer Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}
@@ -1,66 +0,0 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
function hasCdpVersionShape(data) {
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
}
export function classifyRelayCheckResponse(res, host, port) {
if (!res) {
return { action: 'throw', error: 'No response from service worker' }
}
if (res.status === 401) {
return { action: 'status', kind: 'error', message: 'Relay token rejected. Check token and save again.' }
}
if (res.error) {
return { action: 'throw', error: res.error }
}
if (!res.ok) {
return { action: 'throw', error: `HTTP ${res.status}` }
}
const contentType = String(res.contentType || '')
if (!contentType.includes('application/json')) {
return {
action: 'status',
kind: 'error',
message: `Wrong endpoint: expected Officer browser relay at this address.`,
}
}
if (!hasCdpVersionShape(res.json)) {
const browser = res.json?.Browser ?? ''
if (browser && !browser.startsWith('Officer/')) {
return {
action: 'status',
kind: 'error',
message: `Wrong relay: found "${browser}" instead of Officer/browser-relay.`,
}
}
return {
action: 'status',
kind: 'error',
message: `Wrong endpoint: expected relay /json/version response.`,
}
}
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` }
}
export function classifyRelayCheckException(err, host, port) {
const message = String(err || '').toLowerCase()
if (message.includes('json') || message.includes('syntax')) {
return {
kind: 'error',
message: `Wrong endpoint: this is not a relay endpoint.`,
}
}
return {
kind: 'error',
message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`,
}
}
-209
View File
@@ -1,209 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Officer Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #06b6d4;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
font-size: 24px;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
input.host-input {
width: 240px;
}
input.port-input {
width: 100px;
}
input.token-input {
width: min(520px, 100%);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">&#x1f310;</div>
<div>
<h1>Officer Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Make sure Officer is running and enter the correct server address, port, and relay token below.
</p>
<p>
You can find your relay token in the Officer dashboard under <strong>Browser</strong>.
</p>
</div>
<div class="card">
<h2>Relay connection</h2>
<label for="host">Server address</label>
<div class="row">
<input id="host" class="host-input" placeholder="e.g. 100.64.0.1" />
</div>
<label for="port" style="margin-top: 10px">Port</label>
<div class="row">
<input id="port" class="port-input" inputmode="numeric" pattern="[0-9]*" />
</div>
<label for="token" style="margin-top: 10px">Relay token</label>
<div class="row">
<input id="token" class="token-input" type="password" autocomplete="off" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://&lt;host&gt;:&lt;port&gt;/</code>.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>
-77
View File
@@ -1,77 +0,0 @@
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(host, port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://${host}:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(host, port, token) {
const url = `http://${host}:${port}/json/version`
const trimmedToken = String(token || '').trim()
if (!trimmedToken) {
setStatus('error', 'Relay token required. Save your token to connect.')
return
}
try {
// Token is already derived server-side — use it directly
const res = await chrome.runtime.sendMessage({
type: 'relayCheck',
url,
token: trimmedToken,
})
const result = classifyRelayCheckResponse(res, host, port)
if (result.action === 'throw') throw new Error(result.error)
setStatus(result.kind, result.message)
} catch (err) {
const result = classifyRelayCheckException(err, host, port)
setStatus(result.kind, result.message)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken'])
const port = clampPort(stored.relayPort)
const host = String(stored.relayHost || '').trim() || DEFAULT_HOST
const token = String(stored.relayToken || '').trim()
document.getElementById('host').value = host
document.getElementById('port').value = String(port)
document.getElementById('token').value = token
updateRelayUrl(host, port)
await checkRelayReachable(host, port, token)
}
async function save() {
const hostInput = document.getElementById('host')
const portInput = document.getElementById('port')
const tokenInput = document.getElementById('token')
const host = String(hostInput.value || '').trim() || DEFAULT_HOST
const port = clampPort(portInput.value)
const token = String(tokenInput.value || '').trim()
await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token })
hostInput.value = host
portInput.value = String(port)
tokenInput.value = token
updateRelayUrl(host, port)
await checkRelayReachable(host, port, token)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
+27 -30
View File
@@ -19,12 +19,12 @@ import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
import { startNudger } from './servers/api/chat/nudger';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
// import { desktopWebsocket } from './servers/api/desktop/websocket';
import officerWeb from './apps/officer-web/index.gen.html';
// import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
import { sendProvisionableUsers } from './servers/plugins/user-provisioning';
import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
import { toShellUsername } from './servers/data-path';
@@ -55,7 +55,7 @@ type WSData = {
userId: number;
email: string;
username: string;
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'desktop' | 'sidecar';
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'sidecar';
sessionId?: string;
cwd?: string;
command?: string;
@@ -77,9 +77,21 @@ const sidecarWebsocket = {
const msg = JSON.parse(data);
if (msg.type === 'register') {
const id = registerSidecar(ws, msg as SidecarRegistration);
const registration = msg as SidecarRegistration;
const id = registerSidecar(ws, registration);
sidecarConnections.set(ws, id);
ws.send(JSON.stringify({ type: 'registered', id }));
// The account list, for a plugin whose MANIFEST declares `userProvisioning`.
//
// Read from the manifest and never from this message: `name` and `handles` are self-declared and
// believed, so a capability asserted here would be a plugin granting itself one. The manifest was
// reviewed when the owner installed it.
//
// After the ack, not inside it — registration must not wait on a database query. A slow or
// unreachable Postgres would otherwise stall every sidecar's startup, and none of them need the
// list to start.
void sendProvisionableUsers(ws, registration.name);
return;
}
@@ -147,7 +159,6 @@ const handlers: Record<string, any> = {
'chat-notify': chatNotifyWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
// desktop: desktopWebsocket,
sidecar: sidecarWebsocket,
};
@@ -166,6 +177,10 @@ assertPermissionTotality({
// module whose only job is a side effect is one an import sweep quietly deletes.
registerChatPushNotifications();
// Nudger's tick. Here rather than on first use, because a schedule armed before a restart has to survive
// one — the row is in Postgres and nothing else would ever look at it again.
startNudger();
// That the working directory is the repo, because every path derives from its parent. First of the three,
// since a wrong answer here makes the other two check the wrong files.
assertInstallLayout();
@@ -300,7 +315,7 @@ console.log(
async function upgradeWs(
req: Request,
server: any,
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'desktop',
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
) {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 });
@@ -431,7 +446,6 @@ const server = serve({
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
// '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
@@ -497,30 +511,13 @@ console.log(`🚀 Server running at ${server.url}`);
//
// The Chrome extension, its CDP bridge (api/browser/) and the /browser screen all remain on disk: this
// is going to be extracted into a plugin, and deleting it would mean writing it again. What is switched
// off is the second listener and the /api/browser mount (see hono.ts) — the platform serves one port.
// The browser relay left for `plugins/browser` on 2026-08-21. It ran a SECOND listener from this process,
// which is why it was switched off here on 2026-08-13 — the platform serves one port. As a plugin it is a
// sidecar, which is a process allowed its own listeners, so the objection no longer applies.
//
// It used to read BROWSER_RELAY_PORT, default 18792. The name is recorded here because there is nothing
// left to grep for, and whoever does the extraction needs to know what it was called.
//
// ── Read this before turning it back on ──
//
// The port is not only configuration. relay-auth.ts derives each extension's token as
// HMAC(JWT_SECRET, `officer-browser-relay-v1:${port}:${userId}:${salt}`), so the port is an INPUT TO A
// CREDENTIAL. Bringing the relay back on a different number silently invalidates every paired browser,
// and the extension reports it as "Relay not reachable" — which SETUP.md attributes to a wrong address,
// port or token. Re-pairing means re-copying from Settings → Browser Relay.
//
// Whatever it comes back as, it cannot be a kernel-assigned port:0 the way the other sidecars are. The
// extension is configured by hand and stores the value, so a port that changes each restart breaks the
// pairing every restart. It has to be predictable — PORT + 2 is the shape the Anthropic proxy already
// uses for the same reason.
//
// try {
// await startBrowserRelay(BROWSER_RELAY_PORT);
// console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
// } catch (err) {
// console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
// }
// The port and everything that depended on it moved with it, including the reason it cannot be
// kernel-assigned: the extension stores the number by hand AND the number is an input to the token HMAC.
// See plugins/browser/sidecar/protocol.ts.
// Initialize queue engine in API server process
import {
+24 -1
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { createApiKey, listApiKeys, revokeApiKey } from 'officerdb';
import { createApiKey, listApiKeys, revokeApiKey, deleteApiKey } from 'officerdb';
import { mintApiKey } from '../../auth-token';
import * as errors from '../../custom-errors';
@@ -60,3 +60,26 @@ apiKeysRouter.delete('/:id', async (ctx) => {
if (!(await revokeApiKey(id, user.id))) throw errors.NOT_FOUND('no such api key');
return ctx.json({ ok: true });
});
// ── Forgetting a key, which is not the same as revoking it ──
//
// DELETE /:id revokes and keeps the row, because the row is the record that the key existed and
// `lastUsedAt` is the only evidence of whether a leaked one was used before it was killed. This removes
// that record, and it is deliberately a SECOND request after the first: `deleteApiKey` refuses a key that
// is not already revoked, in the statement rather than before it, so there is no ordering in which a live
// key is deleted by something that raced a revoke.
//
// Nothing is weakened by allowing it. The key cannot be honoured again either way — authentication
// matches a hash, and a deleted row has none. What goes is the history, not the protection.
apiKeysRouter.delete('/:id/record', 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 deleteApiKey(id, user.id))) {
// One message for both "not yours / never existed" and "still live", on purpose: the first must not
// be distinguishable from the second by someone probing ids that are not theirs.
throw errors.NOT_FOUND('no such revoked api key');
}
return ctx.json({ ok: true });
});
+384
View File
@@ -0,0 +1,384 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
listAnthropicAccounts,
markAnthropicAccountUsed,
purgeExpiredAnthropicAccounts,
revealAnthropicAccount,
saveAnthropicAccount,
} from 'officerdb';
import { createRouter } from '../../create-router';
import { getHomeDir, getOwnerHomeDir } from '../../data-path';
import { claudeBinIn } from '../../os-user-claude';
import { runAsArgv } from '../../os-user';
/*
`/login` in the chat composer, doing what it does in a terminal.
Officer's members and its owner both authenticate `claude` with a Claude SUBSCRIPTION over OAuth, not with
an Anthropic API key — see `sidecar/claude/state.ts`. That flow is a browser round trip: the CLI prints a
URL, you authorise in claude.com, and you paste a code back. It is the one part of running an agent that
a browser-only user could not do from the browser: they had to open a terminal panel and drive the CLI by
hand, which works and is exactly the thing this removes.
It needs no pty. `claude auth login` behaves identically through plain pipes — verified 2026-08-21 by
capturing its raw bytes — so this is an ordinary subprocess with two reads and one write:
spawn ─► read stdout until the URL ─► [person authorises] ─► write the code to stdin ─► exit
The one non-obvious piece is the parsing. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
again as visible text, so a naive `https://\S+` match returns two URLs concatenated with no separator.
Stripping the escape wrappers first leaves exactly one.
*/
/** OSC-8 hyperlink: `ESC ] 8 ; ; <uri> BEL` … `ESC ] 8 ; ; BEL`. Both halves go. */
const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
const SGR = /\x1b\[[0-9;]*m/g;
const visibleText = (raw: string): string => raw.replace(OSC8, '').replace(SGR, '');
/** The URL the person has to open, or null while the CLI has not printed it yet. */
export function extractLoginUrl(raw: string): string | null {
const m = visibleText(raw).match(/https:\/\/\S+/);
return m ? m[0] : null;
}
/** The CLI is waiting on stdin. Distinct from "printed a URL" — it prints the URL first. */
export const awaitingCode = (raw: string): boolean => /Paste code here/i.test(visibleText(raw));
type Pending = {
userId: number;
child: ChildProcessWithoutNullStreams;
url: string;
out: string;
reaper: ReturnType<typeof setTimeout>;
};
/*
In memory, and deliberately not in Postgres.
A pending login is worth nothing after the process holding its PKCE verifier dies, so persisting it would
store a row that can only ever be stale. A restart losing them is correct: the code in the person's
clipboard is already useless, and the honest recovery is to type `/login` again.
*/
const pending = new Map<string, Pending>();
/*
The authorisation code expires and so does the person's patience. Ten minutes is long enough to find a
password manager and short enough that a forgotten tab does not leave a `claude` process parked on a
read of stdin for the rest of the week.
*/
const TTL_MS = 10 * 60 * 1000;
function abandon(id: string): void {
const p = pending.get(id);
if (!p) return;
clearTimeout(p.reaper);
pending.delete(id);
try {
p.child.kill('SIGTERM');
} catch {
// Already gone. Nothing to do and nothing worth logging.
}
}
type Who = { id: number; isOwner: boolean; osUser: string | null; email: string };
/**
* The login process for one account.
*
* The owner runs it as themselves, in their real home, so it refreshes the very credentials
* `officer-anthropic-proxy` reads. A member runs through `runAsArgv` — `setpriv --reset-env` — as their own
* Linux user, writing into their own home, which is the whole point: their turns bill their subscription
* rather than the owner's, and that only holds if the credential is theirs.
*
* Nothing Anthropic-shaped is inherited in either case. For the owner those variables point at the local
* proxy, and the proxy exists to swap a shared secret for the owner's bearer token — handing them to a
* login is how you get a "success" that authenticates nothing.
*/
function spawnLogin(who: Who): ChildProcessWithoutNullStreams {
const stripped = { ...process.env };
for (const name of [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_FOUNDRY_API_KEY',
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
'CLAUDE_CODE_OAUTH_TOKEN',
])
delete stripped[name];
if (who.isOwner) {
return spawn('claude', ['auth', 'login', '--claudeai'], {
env: stripped,
stdio: ['pipe', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
}
if (!who.osUser) throw new Error('no Linux account: an agent for this member cannot hold its own credentials');
const home = getHomeDir(who.email);
// `env` inside the argv, because `--reset-env` discards anything handed to `setpriv` itself. Only PATH,
// and only so `claude` can find `node`; nothing here carries a credential.
const [launcher, ...args] = runAsArgv(who.osUser, [claudeBinIn(home), 'auth', 'login', '--claudeai']);
if (!launcher) throw new Error('runAsArgv returned an empty argv');
return spawn(launcher, args, { cwd: home, stdio: ['pipe', 'pipe', 'pipe'] }) as ChildProcessWithoutNullStreams;
}
/** Reads until the URL appears, or the process dies, or we run out of patience waiting for it. */
function urlFrom(child: ChildProcessWithoutNullStreams): Promise<{ url: string; out: string }> {
return new Promise((resolve, reject) => {
let out = '';
// Separate from TTL_MS: this is the CLI failing to start, which shows up as silence rather than an error.
const giveUp = setTimeout(
() => reject(new Error(`no login URL after 25s. Output so far: ${visibleText(out)}`)),
25_000,
);
const settle = (fn: () => void) => {
clearTimeout(giveUp);
fn();
};
const onChunk = (b: Buffer) => {
out += b.toString();
const url = extractLoginUrl(out);
if (url) settle(() => resolve({ url, out }));
};
child.stdout.on('data', onChunk);
// The CLI prints the URL to stdout, but its warnings go to stderr and one of them explains a failure.
child.stderr.on('data', (b: Buffer) => {
out += b.toString();
});
child.on('error', (err) => settle(() => reject(err)));
child.on('exit', (code) =>
settle(() => reject(new Error(`claude auth login exited ${code} before printing a URL: ${visibleText(out)}`))),
);
});
}
export const claudeLoginRouter = createRouter();
// POST /auth/claude-login — start one, and hand back the URL to open.
claudeLoginRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const who: Who = { id: user.id, isOwner: user.id === 1, osUser: user.osUser ?? null, email: user.email };
// One at a time per account. A second `/login` while one is open is a person retrying, not a person
// wanting two, and two processes racing to write one credentials file is a corrupt file.
for (const [id, p] of pending) if (p.userId === who.id) abandon(id);
let child: ChildProcessWithoutNullStreams;
try {
child = spawnLogin(who);
} catch (err) {
return ctx.json({ error: err instanceof Error ? err.message : 'could not start the login' }, 400);
}
try {
const { url, out } = await urlFrom(child);
const id = crypto.randomUUID();
pending.set(id, { userId: who.id, child, url, out, reaper: setTimeout(() => abandon(id), TTL_MS) });
// Keep reading, or the pipe fills and the child blocks — and we want its later output for the result.
child.stdout.on('data', (b: Buffer) => {
const p = pending.get(id);
if (p) p.out += b.toString();
});
return ctx.json({ id, url, expiresInMs: TTL_MS });
} catch (err) {
try {
child.kill('SIGTERM');
} catch {
// Nothing to clean up.
}
return ctx.json({ error: err instanceof Error ? err.message : 'the login did not start' }, 502);
}
});
// POST /auth/claude-login/:id/code — hand over what claude.com gave them.
claudeLoginRouter.post('/:id/code', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const p = pending.get(id);
// Same answer for "expired", "never existed" and "somebody else's": all three are equally not-yours.
if (!p || p.userId !== user.id) return ctx.json({ error: 'that login is no longer open — run /login again' }, 404);
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
const code = typeof body.code === 'string' ? body.code.trim() : '';
if (!code) return ctx.json({ error: 'no code' }, 400);
const finished = new Promise<number | null>((resolve) => p.child.once('exit', (c) => resolve(c)));
p.child.stdin.write(`${code}\n`);
// The exchange is a network round trip on the CLI's side.
const exit = await Promise.race([finished, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 45_000))]);
const tail = visibleText(p.out).slice(-600);
abandon(id);
if (exit === 'timeout') return ctx.json({ ok: false, error: 'the CLI did not finish in 45s', detail: tail }, 504);
if (exit !== 0) return ctx.json({ ok: false, error: `claude auth login exited ${exit}`, detail: tail }, 400);
// Record what was just signed into, so it can be switched BACK to later without another round trip. Only
// for the owner: a member's credential is in their own home and this module only ever reads the owner's.
if (user.id === 1) await captureCurrentAnthropicAccount(user.id);
const now = user.id === 1 ? await readCurrentAccount() : null;
// The account is the answer the person opened this for. Two accounts on one machine means "signed in" on
// its own is not enough to act on.
return ctx.json({ ok: true, detail: tail, email: now?.emailAccount ?? null, orgName: now?.orgName ?? null });
});
// DELETE /auth/claude-login/:id — they closed the dialog.
claudeLoginRouter.delete('/:id', (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const p = pending.get(id);
if (p && p.userId === user.id) abandon(id);
return ctx.json({ ok: true });
});
/* ────────────────────────────────────────────────────────────────────────────
Switching between accounts that have already been signed into.
The owner keeps a personal account and a company one and moves between them during a day. `/login` above
handles arriving at an account; this handles going back to one, without a browser round trip, using the
credential kept from last time.
Owner only, and structurally: `getOwnerHomeDir` ignores the email it is passed, so every path below is
the owner's whatever the caller claims. A member's `claude` reads their own home and is untouched by any
of this — the isolation is the same as it was, and this feature does not widen it.
──────────────────────────────────────────────────────────────────────────── */
/** `.credentials.json` holds one key. `.claude.json` holds a whole config and must be PATCHED, not written. */
const credentialsPath = () => join(getOwnerHomeDir(''), '.claude', '.credentials.json');
const configPath = () => join(getOwnerHomeDir(''), '.claude.json');
async function readJson(path: string): Promise<Record<string, unknown> | null> {
try {
return JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* What the CLI is signed in as right now, read from its own two files.
*
* Both are needed and neither is sufficient: the credential lives in `.claude/.credentials.json` and the
* identity — email, org, account uuid — lives in `.claude.json` beside it.
*/
async function readCurrentAccount(): Promise<{
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
credentialsJson: string;
oauthAccountJson: string;
refreshExpiresAt: Date | null;
} | null> {
const creds = await readJson(credentialsPath());
const config = await readJson(configPath());
const oauth = creds?.claudeAiOauth as Record<string, unknown> | undefined;
const account = config?.oauthAccount as Record<string, unknown> | undefined;
if (!oauth || !account) return null;
const accountUuid = typeof account.accountUuid === 'string' ? account.accountUuid : '';
const emailAccount = typeof account.emailAddress === 'string' ? account.emailAddress : '';
if (!accountUuid || !emailAccount) return null;
const sub = typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : '';
const refreshMs = typeof oauth.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null;
return {
accountUuid,
emailAccount,
orgName: typeof account.organizationName === 'string' ? account.organizationName : null,
plan: sub ? sub[0]!.toUpperCase() + sub.slice(1) : null,
credentialsJson: JSON.stringify(oauth),
oauthAccountJson: JSON.stringify(account),
refreshExpiresAt: refreshMs ? new Date(refreshMs) : null,
};
}
/**
* Keep whatever the CLI is currently signed in as.
*
* Called after a `/login` here AND whenever the account list is read, which is what makes an account signed
* into from a TERMINAL switchable too. Without that second call this would only know about logins that
* happened to go through this dialog — a switcher that silently omits half your accounts.
*
* Never throws into a caller: failing to record a credential must not fail the request that noticed it.
*/
export async function captureCurrentAnthropicAccount(userId: number): Promise<void> {
try {
const now = await readCurrentAccount();
if (!now) return;
await saveAnthropicAccount({ userId, ...now });
} catch (err) {
console.warn('[claude-auth] could not record the current account:', err instanceof Error ? err.message : err);
}
}
// GET /claude-auth/accounts — who can be switched to, and who is current. No secrets.
claudeLoginRouter.get('/accounts', async (ctx) => {
const user = ctx.get('user');
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
// Record before listing, so an account signed into elsewhere appears the first time the panel is opened
// rather than only after the next login through this dialog.
await captureCurrentAnthropicAccount(user.id);
await purgeExpiredAnthropicAccounts(user.id).catch(() => 0);
const current = await readCurrentAccount();
const accounts = (await listAnthropicAccounts(user.id)).map((a) => ({
...a,
current: a.accountUuid === current?.accountUuid,
}));
return ctx.json({ accounts });
});
// POST /claude-auth/switch — become one of them.
claudeLoginRouter.post('/switch', async (ctx) => {
const user = ctx.get('user');
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
const accountUuid = typeof body.accountUuid === 'string' ? body.accountUuid : '';
if (!accountUuid) return ctx.json({ error: 'no account' }, 400);
// Keep the outgoing credential before overwriting it, or switching away from an account signed into
// outside this UI loses it — the one-way trip this feature exists to remove.
await captureCurrentAnthropicAccount(user.id);
const target = await revealAnthropicAccount(user.id, accountUuid);
if (!target) return ctx.json({ error: 'that sign-in has expired — run /login for it again' }, 409);
try {
// `.credentials.json` is replaced wholesale: it holds this one key and nothing else of ours.
// `.claude.json` is PATCHED — it is the CLI's whole config, and overwriting it would take out every
// project entry, tip counter and preference in it.
const existingCreds = (await readJson(credentialsPath())) ?? {};
await writeFile(
credentialsPath(),
JSON.stringify({ ...existingCreds, claudeAiOauth: JSON.parse(target.credentialsJson) }),
{
mode: 0o600,
},
);
const config = (await readJson(configPath())) ?? {};
await writeFile(
configPath(),
JSON.stringify({ ...config, oauthAccount: JSON.parse(target.oauthAccountJson) }, null, 2),
{
mode: 0o600,
},
);
} catch (err) {
return ctx.json({ error: `could not write the credential: ${err instanceof Error ? err.message : err}` }, 500);
}
await markAnthropicAccountUsed(user.id, accountUuid);
// A turn already running keeps the credential it was spawned with. Said here rather than guessed at in
// the UI, because "it did not take effect" and "it takes effect next turn" look identical from outside.
return ctx.json({ ok: true, email: target.emailAccount, appliesTo: 'the next turn, and every new claude' });
});

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