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
34 changed files with 1846 additions and 141 deletions
+34 -34
View File
@@ -20,38 +20,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Themes
**78 open, 20 closed**, across 28 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/) | 1 | 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/) | 3 | 0 |
| [Headscale](todo/headscale/) | 0 | 5 |
| [Infra (alpha)](todo/infra/) | 6 | 0 |
| [Known bugs](todo/known-bugs/) | 2 | 1 |
| [Mobile apps](todo/mobile-apps/) | 2 | 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 |
| 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
@@ -65,10 +65,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
- `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 way to mark a commit "this one ships", so a release is not just `master`'s tip** — [fleet/a-way-to-mark-a-commit-this-one-ships-so-a](todo/fleet/a-way-to-mark-a-commit-this-one-ships-so-a.md)
- `2026-08-21` **OPM — Officer's own process manager, or at least its own PM2** — [infra/opm-officer-s-own-process-manager](todo/infra/opm-officer-s-own-process-manager.md)
- `2026-08-21` **iPhone Action Button → run an Officer task from a locked phone** — [mobile-apps/iphone-action-button-run-an-officer-task-from](todo/mobile-apps/iphone-action-button-run-an-officer-task-from.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)
+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`.
+16 -4
View File
@@ -77,10 +77,22 @@ All verified, none cosmetic.
- **`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` cannot sync its capabilities**, and could not publish gitea affordances on 2026-08-21:
`dubious ownership` from git and `EACCES … mkdir '…/capabilities/affordances/officerdev'`. Both point at
that capabilities checkout being owned by somebody other than `pastilhas`. Deliberately not papered over
with a `safe.directory` entry until somebody has run one `stat` on it. **Unexplained.**
- **`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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.21.03",
"version": "2026.08.21.04",
"private": true,
"type": "module",
"workspaces": [
@@ -25,7 +25,18 @@ import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
//
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
const MAX_RESULTS = 8;
/*
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?
@@ -71,8 +82,15 @@ const Row = ({
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.
@@ -84,16 +102,37 @@ const Row = ({
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={item.color ? { backgroundColor: `${item.color}22`, color: item.color } : undefined}
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="h-5 w-5 object-contain" />
<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" />
<Icon className={`h-4 w-4 ${item.color ? '' : 'text-muted-foreground'}`} />
) : (
<Search className="h-4 w-4" />
<Search className="h-4 w-4 text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
@@ -115,7 +154,7 @@ export const Spotlight = () => {
const navigate = useNavigate();
const items = useSpotlightItems();
const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [items, query]);
const results = useMemo(() => rank(items, query), [items, query]);
const close = useCallback(() => {
setOpen(false);
@@ -1,6 +1,23 @@
import { useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Settings, User, Puzzle, Server, Cpu, Users } 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';
@@ -32,6 +49,15 @@ export type SpotlightItem = {
/** 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';
};
/**
@@ -41,19 +67,67 @@ export type SpotlightItem = {
* 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.
*
* Worth revisiting if it drifts. `hint` is what makes them findable — typing "keys" should reach the API
* keys screen, and the label alone does not say where it lives.
* 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: User },
{ id: 'set:api-keys', label: 'API keys', to: '/settings/profile/api-keys', hint: 'per-app keys', icon: User },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: User },
{ id: 'set:integrations', label: 'Integrations', to: '/settings/integrations', icon: Puzzle },
{ id: 'set:server', label: 'Server settings', to: '/settings/server', icon: Server },
{ id: 'set:system', label: 'System', to: '/settings/system', icon: Cpu },
{ id: 'set:users', label: 'Users', to: '/settings/users', hint: 'accounts and permissions', icon: Users },
{ id: 'set:ai', label: 'AI', to: '/settings/ai', hint: 'models and harnesses', icon: Settings },
{ 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. */
@@ -70,6 +144,7 @@ export function useSpotlightItems(): SpotlightItem[] {
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
@@ -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)],
);
+1
View File
@@ -37,6 +37,7 @@ 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';
+1
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
+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' });
});
+2
View File
@@ -41,6 +41,7 @@ import { queueRouter } from './api/queue/queue';
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
import { bugReportRouter } from './api/bug-report/bug-report';
import { agentStatusRouter } from './api/agent-status/router';
import { claudeLoginRouter } from './api/auth/claude-login';
import { chatRouter } from './api/chat/chat';
import { commandHqRouter } from './api/command-hq/router';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
@@ -149,6 +150,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/queue', queueRouter],
['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
['/claude-auth', claudeLoginRouter],
['/chat', chatRouter],
['/command-hq', commandHqRouter],
['/pipeline-jobs', pipelineJobsRouter],
+11 -1
View File
@@ -278,7 +278,17 @@ const CORE_REGISTRY: Permission[] = [
// non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports
// two booleans about the caller's own home and nothing about anyone else, which is what lets a member be
// told "run `claude` once to sign in" instead of being shown a tile that 403s.
api: ['/chat', '/agent-status'],
// `/claude-auth` is the `/login` flow — it spawns `claude auth login` AS THE CALLER and writes the
// credential into the caller's own home. It rides this permission rather than earning its own for the
// reason that makes the pairing 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.
//
// NOT under `/auth`, which `totality.ts` exempts from the gate entirely because it is where a caller
// goes to BECOME authenticated. Mounted there, an unauthenticated request could start a login as the
// owner.
api: ['/chat', '/agent-status', '/claude-auth'],
// 'chat-notify' is a session-independent socket (the bell feature): it carries no sessionId and no
// transcript, just a ping that a chat this account can already reach has finished a turn or needs
// input. Riding this permission rather than a new one, because it is meaningless without `chat`
+6 -3
View File
@@ -7,9 +7,12 @@
* is not localhost the whole object is undefined, so `navigator.clipboard.writeText`
* throws `TypeError: Cannot read properties of undefined`.
*
* Officer is reached over the tailnet — `http://officer-dev:9000` — which is
* neither https nor localhost. Twenty call sites were affected, in three states
* that all looked fine in review:
* Officer is reachable over plain http — `http://officer-dev:9000` on the tailnet —
* which is neither https nor localhost. The owner's own browsing is over https
* (`platform.officer.dev`) and was already https when this was written; the http
* origins are the other ways in, and an install with no certificate has no other
* way in at all. Twenty call sites were affected, in three states that all looked
* fine in review:
*
* bare `navigator.clipboard.writeText(x)` threw, killing the handler
* `navigator.clipboard?.writeText(x)` did nothing, silently
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react';
import { Check, Copy, ExternalLink, Loader2 } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useClient } from 'hooks/useClient';
import { errorText } from 'helpers/error-text';
import { copyToClipboard } from 'helpers/clipboard';
/*
`/login` — the same flow as the terminal, without the terminal.
Officer authenticates `claude` with a Claude SUBSCRIPTION over OAuth, which is a browser round trip:
open a URL, authorise, paste a code back. Until now the only way to do that here was to open a terminal
panel and drive the CLI by hand — which works, and is a strange thing to ask of somebody whose whole
session is a browser.
Three states, and the middle one is the person leaving to do something else:
starting → waiting for the code → done
*/
type Started = { id: string; url: string; expiresInMs: number };
type Props = { open: boolean; onOpenChange: (open: boolean) => void };
export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => {
const client = useClient();
const [started, setStarted] = useState<Started | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLInputElement>(null);
// Start on open, and abandon on close. The server holds a live `claude` process per pending login, so a
// dialog dismissed without telling it leaves that process parked on a read of stdin.
useEffect(() => {
if (!open) return;
let cancelled = false;
setStarted(null);
setCode('');
setError(null);
setDone(false);
void (async () => {
try {
const res = await client.post<Started>('/claude-auth', {});
if (!cancelled) setStarted(res);
} catch (err) {
if (!cancelled) setError(errorText(err, 'could not start the login'));
}
})();
return () => {
cancelled = true;
};
}, [open]);
useEffect(() => {
if (!open && started) void client.delete(`/claude-auth/${started.id}`).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const submit = async () => {
if (!started || !code.trim() || busy) return;
setBusy(true);
setError(null);
try {
await client.post(`/claude-auth/${started.id}/code`, { code: code.trim() });
setDone(true);
} catch (err) {
setError(errorText(err, 'that code was not accepted'));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sign in to Claude</DialogTitle>
</DialogHeader>
{done ? (
<div className="flex items-center gap-2 py-4 text-sm">
<Check className="h-4 w-4 text-success" />
Signed in. New turns use these credentials — no restart needed.
</div>
) : !started ? (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
{error ? (
<span className="text-destructive">{error}</span>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Asking the CLI for a sign-in link…
</>
)}
</div>
) : (
<div className="space-y-4 py-2">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Open this, authorise, and paste back the code it gives you.
</p>
<div className="flex items-center gap-2">
{/* A real anchor. Every link here opens a new tab, which is what you want for this one —
losing the dialog would lose the process holding the other half of the exchange. */}
<a
href={started.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Authorise in claude.com
<ExternalLink className="h-3.5 w-3.5" />
</a>
{/* Copy, because opening here opens in THIS browser — already signed into whichever account
you are trying to move away from. Pasting into another profile or another device is how
you sign in as somebody else, and this dialog exists precisely for the machine with two
Anthropic accounts on it. */}
<button
type="button"
onClick={() => {
void copyToClipboard(started.url).then((ok) => {
setCopied(ok);
if (ok) setTimeout(() => setCopied(false), 2_000);
else setError('could not reach the clipboard — select the link and copy it by hand');
});
}}
title="Copy the link, to open it in another browser or on your phone"
className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-foreground/5"
>
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? 'Copied' : 'Copy link'}
</button>
</div>
</div>
<div className="space-y-1">
<Input
ref={codeRef}
autoFocus
value={code}
onChange={(ev) => setCode(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submit();
}}
placeholder="paste the code"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Expires in {Math.round(started.expiresInMs / 60_000)} minutes. Run <code>/login</code> again if it does.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
type="button"
disabled={!code.trim() || busy}
onClick={() => void submit()}
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-40"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Sign in
</button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import { ModelSelector } from './ModelSelector';
import { BackgroundJobDialog } from './BackgroundJobDialog';
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
import { armBackgroundJob } from '../../../background-job-origin';
import { useWorkspace } from '../../../components/Workspace';
import { AttachmentList } from './AttachmentList';
@@ -17,6 +18,7 @@ type InputAreaProps = {
};
export const InputArea = ({ manager }: InputAreaProps) => {
const [loginOpen, setLoginOpen] = useState(false);
const {
input,
setInput,
@@ -105,7 +107,17 @@ export const InputArea = ({ manager }: InputAreaProps) => {
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
onKeyDown={(ev) => {
// Enter is how anybody actually sends, so the interception has to be here as well as on the
// button. Shift-Enter is a newline and stays one.
if (ev.key === 'Enter' && !ev.shiftKey && input.trim() === '/login') {
ev.preventDefault();
setInput('');
setLoginOpen(true);
return;
}
handleKeyDown(ev);
}}
onPaste={(ev) => {
// Let an image paste fall through to the browser's default (nothing) rather than attaching
// something this model will discard.
@@ -141,7 +153,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
</Button>
)}
<Button
onClick={handleSend}
onClick={() => {
// `/login` never reaches the agent. It is not a prompt — it is the browser-only user doing the
// one thing that used to need a terminal panel, so the composer answers it locally.
if (input.trim() === '/login') {
setInput('');
setLoginOpen(true);
return;
}
handleSend();
}}
disabled={!input.trim() || !isConnected}
size="icon"
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
@@ -169,6 +190,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
/>
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
<ClaudeLoginDialog open={loginOpen} onOpenChange={setLoginOpen} />
<BackgroundJobDialog
open={jobDialogOpen}
onOpenChange={setJobDialogOpen}
@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { UsageAccount } from '../useClaudeUsage';
import { currentLimit, formatAge, formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
import type { SwitchableAccount } from '../useAnthropicAccounts';
import { useAnthropicAccounts } from '../useAnthropicAccounts';
/**
* Claude Code's own usage display, on the harness pill — for every account, not just the one signed in.
@@ -129,38 +132,70 @@ const Models = ({ account }: { account: UsageAccount }) => {
};
/**
* One chip per account, and only when there is more than one.
* A chip per Anthropic account this machine has seen, and clicking one does one of two things.
*
* A tab strip over a single account is chrome that never does anything — the same reasoning that collapses
* the provider strip to a label when there is one harness on offer.
* switch — a stored credential exists, so the click BECOMES that account
* view — only a stored reading exists, so the click shows its cached numbers and nothing changes
*
* The second kind is not a degraded version of the first, it is a different question. An account whose
* sign-in is gone still has percentages and a reset time worth reading — "has my other allowance come back
* yet" is answerable from a snapshot, because a reset time is an absolute instant and `currentLimit` rolls a
* past window forward rather than showing a stale one. What you cannot do is act on the answer without a
* `/login`, and that is what the chip has to make obvious BEFORE it is clicked.
*
* So: filled for the account you are, plain for one you can become, and dashed-outline for one you can only
* look at.
*/
const AccountTabs = ({
accounts,
selected,
onSelect,
type Chip = {
key: string;
email: string;
title: string;
kind: 'current' | 'switch' | 'view';
busy: boolean;
};
const AccountChips = ({
chips,
isSwitching,
viewing,
onClick,
}: {
accounts: UsageAccount[];
selected: string;
onSelect: (email: string) => void;
chips: Chip[];
isSwitching: boolean;
viewing: string | null;
onClick: (chip: Chip) => void;
}) => (
<div className="flex items-center gap-1 overflow-x-auto border-t border-border px-2 py-2">
{accounts.map((account) => (
<button
key={account.email}
type="button"
onClick={() => onSelect(account.email)}
// The local part alone: the domain is what the accounts have in common and the part before it is
// what tells them apart, and the panel is 320px wide.
title={account.email}
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors ${
account.email === selected
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
}`}
>
{account.email.split('@')[0]}
</button>
))}
<div className="flex flex-wrap items-center gap-1 border-t border-border px-2 py-2">
{chips.map((chip) => {
const looking = chip.kind === 'view' && viewing === chip.email;
return (
<button
key={chip.key}
type="button"
disabled={isSwitching || (chip.kind === 'current' && !viewing)}
onClick={() => onClick(chip)}
title={chip.title}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
chip.kind === 'current' && !viewing
? 'bg-primary text-primary-foreground'
: chip.kind === 'view'
? // Dashed and unfilled: the border says "not a real destination" without greying it out to
// the point of looking broken, because it IS clickable — just not for switching.
`cursor-pointer border border-dashed border-border ${
looking ? 'bg-foreground/10 text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
}`
: 'cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
} ${isSwitching && !chip.busy ? 'opacity-50' : ''}`}
>
{chip.busy && <Loader2 className="h-3 w-3 animate-spin" />}
{/* The WHOLE address. This showed the local part alone, on the reasoning that the domain is what
accounts have in common — which is wrong for the case that matters: a personal and a work
account belonging to one person share the local part, so the domain is the half that tells
them apart. Two chips read as the same account. */}
{chip.email}
</button>
);
})}
</div>
);
@@ -193,7 +228,8 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
const enabled = isOwner && harness === 'claude-code';
const [open, setOpen] = useState(false);
const { accounts, isLoading, isRefreshing, isError } = useClaudeUsage(enabled && open);
const { accounts: switchable, isSwitching, switchingTo, switchTo, switchError } = useAnthropicAccounts(open);
const { accounts, allAccounts, isLoading, isRefreshing, isError } = useClaudeUsage(enabled && open);
/*
Which tab is showing.
@@ -204,8 +240,57 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
Null means "whichever is current", resolved at render — so the panel opens on the signed-in account
without needing an effect to select it once the data arrives.
*/
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const selected = accounts.find((a) => a.email === selectedEmail) ?? accounts[0] ?? null;
/*
No selection state any more, deliberately.
The panel shows the account you are SIGNED INTO, full stop, and `useClaudeUsage` already filters to it.
A selection would let the numbers and the chips disagree — reading one account's percentages with
another's chip lit — and that disagreement is the exact confusion this whole change set out to remove.
*/
/*
`viewing` is null for the normal case — the account you are signed into — and an email when you have
clicked an archived chip to read its cached numbers.
It is deliberately NOT a general tab selection. Only accounts with no stored credential can be viewed,
because for any other one the useful gesture is switching to it and then reading live numbers. That keeps
the invariant that made phase one worth doing: if the panel shows a signed-in account, it is THE
signed-in account.
*/
const [viewing, setViewing] = useState<string | null>(null);
useEffect(() => {
if (!open) setViewing(null);
}, [open]);
const selected = (viewing ? allAccounts.find((a) => a.email === viewing) : accounts[0]) ?? accounts[0] ?? null;
/*
Accounts with a stored READING but no stored credential — an old login that can be looked at and not
returned to. That is what is worth saying out loud.
This counted `allAccounts - accounts` until the switcher existed, which then double-counted: it called
an account "not shown" while its chip sat two lines below.
*/
const archived = allAccounts.filter((a) => !switchable.some((s) => s.emailAccount === a.email));
const chips: Chip[] = [
...switchable.map((a) => ({
key: a.accountUuid,
email: a.emailAccount,
kind: (a.current ? 'current' : 'switch') as Chip['kind'],
busy: isSwitching && switchingTo === a.accountUuid,
title: a.current
? `${a.emailAccount} — signed in`
: a.usable
? `Switch to ${a.emailAccount}${a.orgName ? ` (${a.orgName})` : ''}`
: `${a.emailAccount} — sign-in expired, run /login`,
})),
...archived.map((a) => ({
key: `archived:${a.email}`,
email: a.email,
kind: 'view' as Chip['kind'],
busy: false,
title: `${a.email} — no saved sign-in. Click to read its last known usage; run /login to use it.`,
})),
];
// The countdowns tick. Only while the panel is open — an interval running behind a closed popover is a
// re-render a minute for something nobody is looking at.
@@ -234,13 +319,32 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
{/* Plan and account in the accent rather than muted: this line is the ANSWER to "whose numbers am
I looking at", which matters more the moment there is a second tab, and it was reading as
chrome under the title. */}
<div className="truncate text-xs text-primary">
{selected
? [selected.plan, selected.email].filter(Boolean).join(' · ')
: isLoading
? 'Reading usage…'
: 'Not signed in'}
</div>
{/* The email on its own line, unshared. It is the answer to the only question this panel is
being opened to settle — which account am I spending — and joined with the plan it was the
half that got truncated first in a 320px popover. */}
{selected ? (
<>
<div className="truncate text-xs font-medium text-primary" title={selected.email}>
{selected.email}
</div>
{selected.plan && <div className="text-[11px] text-muted-foreground">{selected.plan}</div>}
</>
) : (
<div className="text-xs text-primary">{isLoading ? 'Reading usage…' : 'Not signed in'}</div>
)}
{/* Said out loud rather than silently dropped. Phase one shows one account; knowing another
exists and is hidden on purpose is different from wondering where it went. */}
{switchError && (
<div className="mt-1 text-[11px] text-destructive">
{switchError instanceof Error ? switchError.message : 'could not switch account'}
</div>
)}
{/* Said on the account being LOOKED AT rather than as a count somewhere else. The numbers below are
real and the reset times are still correct; the only thing missing is the ability to act on
them, and naming the fix beats naming the problem. */}
{viewing && (
<div className="mt-0.5 text-[10px] text-warning">No saved sign-in — run /login to use this one</div>
)}
{/*
How old this reading is, and it earns its line only when it is not live.
@@ -258,8 +362,24 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
)}
</div>
{accounts.length > 1 && (
<AccountTabs accounts={accounts} selected={selected?.email ?? ''} onSelect={setSelectedEmail} />
{chips.length > 1 && (
<AccountChips
chips={chips}
isSwitching={isSwitching}
viewing={viewing}
onClick={(chip) => {
if (chip.kind === 'view') {
setViewing(chip.email);
return;
}
// Switching returns the panel to live numbers: the account you just became is the one whose
// percentages you want, and leaving an archived snapshot on screen after a switch is how the
// numbers and the chips end up disagreeing.
setViewing(null);
const target = switchable.find((a) => a.emailAccount === chip.email);
if (target && !target.current) void switchTo(target.accountUuid).catch(() => {});
}}
/>
)}
{/* A failure never blanks the panel: the two halves are independent server-side, so a limits problem
@@ -0,0 +1,65 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
/*
The Anthropic accounts this machine's owner can BE, and the act of becoming one.
Separate from `useClaudeUsage`, which answers a different question. Usage is a measurement — how much room
is left — and is only readable for the account currently signed in. This is identity: who could I be, and
make me them. They meet in the popover, where the numbers belong to whichever pill is lit.
*/
export type SwitchableAccount = {
accountUuid: string;
emailAccount: string;
orgName: string | null;
plan: string | null;
capturedAt: string;
lastUsedAt: string | null;
refreshExpiresAt: string | null;
/** False once the refresh token has expired: the pill has to offer `/login` rather than a switch. */
usable: boolean;
current: boolean;
};
const KEY = ['anthropic', 'accounts'];
export function useAnthropicAccounts(enabled: boolean) {
const client = useClient();
const queryClient = useQueryClient();
const query = useQuery<{ accounts: SwitchableAccount[] }>({
queryKey: KEY,
queryFn: () => client.get<{ accounts: SwitchableAccount[] }>('/claude-auth/accounts'),
enabled,
// Cheap — two small file reads and one SELECT — and it must not be stale, because a switch made from a
// terminal is exactly the case this is here to notice.
staleTime: 0,
refetchOnWindowFocus: false,
// A member gets a 403 here by design. Retrying it just spends three round trips arriving at the same no.
retry: false,
});
const switchTo = useMutation({
mutationFn: (accountUuid: string) =>
client.post<{ ok: boolean; email: string; appliesTo: string }>('/claude-auth/switch', { accountUuid }),
onSuccess: async () => {
// Both, and in this order. The account list decides which pill is lit; the usage numbers belong to
// whichever account that is, and showing the old account's percentages under the new name is the one
// outcome that would make somebody distrust the whole panel.
await queryClient.invalidateQueries({ queryKey: KEY });
await queryClient.invalidateQueries({ queryKey: ['chat', 'usage'] });
await queryClient.invalidateQueries({ queryKey: ['chat', 'usage', 'fresh'] });
},
});
return {
accounts: query.data?.accounts ?? [],
isLoading: query.isLoading,
/** True while a switch is in flight — the panel must say so, or nobody believes it happened. */
isSwitching: switchTo.isPending,
switchingTo: switchTo.variables ?? null,
switchError: switchTo.error,
switchTo: switchTo.mutateAsync,
};
}
@@ -76,8 +76,27 @@ export function useClaudeUsage(enabled: boolean) {
if (fresh.data) queryClient.setQueryData(KEY, fresh.data);
}, [fresh.data, queryClient]);
const all = fresh.data?.accounts ?? cached.data?.accounts ?? [];
/*
PHASE ONE — the signed-in account only. Temporary, and a one-line revert: delete `onlyCurrent` and
return `all`.
The stored snapshots for other accounts are a real feature and the server still serves them. The problem
is what they do to the question being asked. This machine's owner has a personal account and a
work one, both appear as tabs, and after signing in you cannot tell from a glance WHICH tab you are
now spending — the panel answers "here is usage for two accounts" when the question is "whose am I on".
So while that is the question, show one account and let its absence mean something: no row marked
`current` renders as "Not signed in", which is a true and useful answer rather than a tab that happens
to be selected.
*/
const onlyCurrent = all.filter((a) => a.current);
return {
accounts: fresh.data?.accounts ?? cached.data?.accounts ?? [],
accounts: onlyCurrent,
/** Every account the server knows about — kept so a caller can say how many are being hidden. */
allAccounts: all,
/** Only true when there is nothing at all to show. A stale list is not a loading state. */
isLoading: cached.isLoading && fresh.isLoading,
/** A refresh in flight over an already-painted panel. Worth a hint, never a spinner over the numbers. */
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from './internal/resizable';
import { useIsMobile } from './internal/useIsMobile';
@@ -296,28 +296,29 @@ export const WorkspaceView = ({
return () => window.removeEventListener('keydown', onKeyDown);
}, [swapSourceId, dragSourceId]);
const ephemeralPanelRef = useRef<ComponentRef<typeof ResizablePanel>>(null);
// ── The ephemeral pane is open when it EXISTS, and that is its only representation ──
//
// It used to be a permanent panel starting at `defaultSize={0}`, opened by an effect calling
// `panel.resize()`. That put the state in two places — the prop said "closed", the effect was supposed
// to correct it — and a correction step is something that can be missed. It was:
//
// Landing on `/files/...?view=x.m4a` (a deep link, or just a refresh) made `isEphemeralOpen` true on
// the FIRST render. But `workspace.isLoaded` is false until `/dashboards` answers, and the early return
// below means nothing is rendered yet — so the effect ran with a null ref and returned. Its deps were
// `[isEphemeralOpen, defaultBaseSize]`, and by the time the group did render neither had changed: one
// was already true, the other is a constant. So it never ran again. The URL said the viewer was open,
// the pane sat at zero width, and double-clicking the file did nothing at all — `?view=` already held
// that name, so there was no state change to react to. Reported as "refresh loses the panel and then
// the file won't open".
//
// Rendering the panel conditionally cannot have that failure: there is no moment where the markup and
// the intent disagree, and a deep link is correct on first paint rather than corrected afterwards.
// `id` + `order` are what make a conditional panel safe in react-resizable-panels 2.x — the same pair
// `WorkspaceRenderer` already uses for the panel tree.
const isEphemeralOpen = !!ephemeral;
useEffect(() => {
const panel = ephemeralPanelRef.current;
if (!panel) return;
// `react-resizable-panels` asserts rather than no-ops when it is asked to resize a group it has not
// laid out yet — and an assert thrown from an effect aborts the commit, which here means the whole
// dashboard rather than the pane that could not be sized. The pane is a file preview; the panels
// behind it are a night's work. Only the ephemeral pane's own geometry is at stake, and the next
// render tries again.
try {
if (isEphemeralOpen) {
panel.resize(100 - (ephemeral.defaultBaseSize ?? 40));
} else {
panel.collapse();
}
} catch (err) {
console.warn('[workspace] could not size the ephemeral pane yet', err);
}
}, [isEphemeralOpen, ephemeral?.defaultBaseSize]);
// The workspace's share of the group when the ephemeral pane is open; the pane takes the remainder.
const baseSize = ephemeral?.defaultBaseSize ?? 40;
const onMobileBack = useMemo(() => {
if (!isMobile) return null;
@@ -390,7 +391,7 @@ export const WorkspaceView = ({
/>
) : (
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
<ResizablePanel defaultSize={100} minSize={15}>
<ResizablePanel id="workspace-base" order={1} defaultSize={isEphemeralOpen ? baseSize : 100} minSize={15}>
<WorkspaceRenderer
layout={layout}
registry={registry}
@@ -405,20 +406,22 @@ export const WorkspaceView = ({
onResized={handleResized}
/>
</ResizablePanel>
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
<ResizablePanel ref={ephemeralPanelRef} collapsible collapsedSize={0} defaultSize={0} minSize={15}>
{isEphemeralOpen ? (
<WorkspaceRenderer
layout={ephemeral.layout}
registry={registry}
components={ephemeral.components}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
) : null}
</ResizablePanel>
{isEphemeralOpen && ephemeral ? (
<>
<ResizableHandle className="bg-transparent after:bg-transparent" />
<ResizablePanel id="workspace-ephemeral" order={2} defaultSize={100 - baseSize} minSize={15}>
<WorkspaceRenderer
layout={ephemeral.layout}
registry={registry}
components={ephemeral.components}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
)}
</WorkspaceProvider>
@@ -0,0 +1,111 @@
import { describe, expect, test } from 'bun:test';
import { render } from '@testing-library/react';
import type { LayoutNode } from '../core/types';
import type { AppRegistryMap, DashboardState, EphemeralPanels } from './types';
import { WorkspaceView } from './WorkspaceView';
/**
* The ephemeral pane — the file viewer that opens beside a workspace — and whether it is OPEN.
*
* Every bug this has had looked identical from outside: the URL says a file is open, the pane is not
* there, and clicking the file again does nothing, because the URL already says what the click would say.
* The cause was the same shape each time — open/closed expressed twice, once in the markup and once in an
* effect meant to correct it, with the correction able to run at a moment where it could not take effect.
*
* So these assert the SIZE the group gives the pane, never merely that the element exists. A pane
* rendered at zero width is precisely the failure being guarded against, and "is it in the document"
* reports that as a pass. `data-panel-size` is react-resizable-panels' own computed answer.
*
* Each test renders its own tree: `@testing-library/react` unmounts between tests, so a render shared by
* a describe block is detached by the time the second test reads it.
*/
const BASE_PANEL: LayoutNode = { type: 'panel', id: 'p1', appType: 'test/app' };
const dashboard = (isLoaded: boolean): DashboardState => ({
key: 'screens/test',
value: BASE_PANEL,
setValue: () => {},
isLoaded,
});
const registry = {
'test/app': { name: 'T', icon: () => null, component: () => <div>BASE</div> },
} as unknown as AppRegistryMap;
const viewer: EphemeralPanels = {
layout: { type: 'panel', id: 'files-viewer', appType: null },
components: { 'files-viewer': { component: () => <div>VIEWER</div> } },
defaultBaseSize: 40,
};
/** The group's own computed share for one pane, or null when the pane is not in the tree. */
const paneSize = (container: HTMLElement, id: string): number | null => {
const el = container.querySelector(`[data-panel-id="${id}"]`);
const size = el?.getAttribute('data-panel-size');
return size == null ? null : Number(size);
};
describe('nothing open', () => {
test('the pane is not in the tree at all', () => {
const { container } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} />);
expect(paneSize(container, 'workspace-ephemeral')).toBeNull();
});
test('the workspace has the whole group', () => {
const { container } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} />);
expect(paneSize(container, 'workspace-base')).toBe(100);
});
});
describe('a deep link — open on the very first render', () => {
test('the pane is open on first paint rather than corrected afterwards', () => {
const { container } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={viewer} />);
expect(paneSize(container, 'workspace-ephemeral')).toBe(60);
expect(paneSize(container, 'workspace-base')).toBe(40);
});
});
// THE REGRESSION, and the reason this file exists. `workspace.isLoaded` is false until /dashboards
// answers, and WorkspaceView renders nothing at all until then — so a `?view=` present at load time was
// already "open" before there was any panel group to size. The old imperative effect ran once against a
// null ref and returned; its dependencies (already-true `isEphemeralOpen`, a constant size) could not
// change afterwards, so it never ran again. URL open, pane at zero width, and clicking the file inert
// because the URL needed no change.
describe('open before the dashboard has finished loading', () => {
test('the pane opens when the group appears, with no state change to trigger it', () => {
const { container, rerender } = render(
<WorkspaceView workspace={dashboard(false)} registry={registry} ephemeral={viewer} />,
);
expect(paneSize(container, 'workspace-base')).toBeNull(); // nothing renders while loading
rerender(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={viewer} />);
expect(paneSize(container, 'workspace-ephemeral')).toBe(60);
expect(paneSize(container, 'workspace-base')).toBe(40);
});
});
describe('opening and closing while the page is live', () => {
test('opening gives the pane its share and closing gives it back', () => {
const { container, rerender } = render(<WorkspaceView workspace={dashboard(true)} registry={registry} />);
expect(paneSize(container, 'workspace-base')).toBe(100);
rerender(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={viewer} />);
expect(paneSize(container, 'workspace-ephemeral')).toBe(60);
rerender(<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={null} />);
expect(paneSize(container, 'workspace-ephemeral')).toBeNull();
expect(paneSize(container, 'workspace-base')).toBe(100);
});
});
describe('a pane that asks for a different split', () => {
test('defaultBaseSize is the WORKSPACE share and the pane takes the remainder', () => {
const { container } = render(
<WorkspaceView workspace={dashboard(true)} registry={registry} ephemeral={{ ...viewer, defaultBaseSize: 25 }} />,
);
expect(paneSize(container, 'workspace-base')).toBe(25);
expect(paneSize(container, 'workspace-ephemeral')).toBe(75);
});
});
@@ -0,0 +1,32 @@
---
id: background-jobs/a-spawned-agent-is-invisible-in-the-interface
title: 'An agent spawned by a background job is invisible in the interface'
theme: background-jobs
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**An agent spawned by a background job is invisible in the interface, even though it is fully readable.**
A watcher loop that fires spawns `claude -p`, and that child writes an ordinary transcript under the
project directory for its cwd. So it opens in `/chat/<its-id>` like any other session — verified on
2026-08-21 while the deploy was mid-flight: fourteen messages, the right cwd, rendered live in the normal
chat view with no work needed.
Nothing in the UI tells you that id exists. The parent's job panel shows the child's `stream-json` piped
through `jq`, which is genuinely good — you see its tool calls as they happen — but it is a text stream in
a small box, and there is no link. You can only find the session by knowing to look for it.
Two fixes, and the small one is most of the value:
- **Have the loop print the transcript id as its first line.** One `echo`, no platform change, and the id
becomes clickable-by-copy in the panel the owner is already watching.
- **A real row for it.** The child is a genuine session with a genuine cost; Command HQ could file it
against the commander that spawned it, which would also fix cost attribution — today the child's tokens
land nowhere, so a commander's cost line reads `$0.28` for a night that spent several dollars.
The second one needs a decision about what the child *is*. A commander is defined by its directory
(`isCommandersDir`), and the child runs in the platform checkout, so it does not qualify — deliberately.
Making it visible probably means a different relationship than "another commander".
@@ -0,0 +1,33 @@
---
id: fleet/a-deploy-report-verifies-pm2-not-that-the-app
title: 'A deploy verifies PM2, not that the app still works'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 4
---
**A deploy verifies PM2, not that the app still works.** The deploy commander raised this against itself,
unprompted, in its own report: _"Nothing was verified beyond `pm2`."_ That is the most valuable line in
either report so far and it should not be true twice more.
What step 6 checks today is that the process it restarted is `online` and that `restart_time` moved. That
catches a crash on boot. It does not catch a server that starts, listens, and serves a 500 — or one that
serves a stale bundle, which is the exact failure that cost an hour on 2026-08-21 when a rebuilt frontend
was never picked up because the running process still held the previous `index.gen.html`.
The check that would have caught all of it is one line and needs nothing built:
```bash
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:<port>/
```
Two things to settle rather than assume. **The port differs per host** — take it from that host's `.env`
(`OFFICERDEV_PORT`), not from a constant; `officer-dev` is 9000 and nothing guarantees the others are.
And **a 200 on `/` is a low bar**: it proves the SPA shell is served. An endpoint that touches Postgres
would prove more, and choosing one is the actual design question here — probably `/api/health` if it
exists, and if it does not, that is the smaller item to do first.
Worth adding to the plan format too, so a release can name its own smoke test rather than relying on a
generic one.
@@ -2,7 +2,7 @@
id: fleet/a-way-to-mark-a-commit-this-one-ships-so-a
title: "A way to mark a commit \"this one ships\", so a release is not just `master`'s tip"
theme: fleet
status: open
status: done
created: 2026-08-21
date_source: stated
order: 2
@@ -29,3 +29,22 @@ Shape to settle when it is built:
be a chosen one.
- The updater then does a checkout at that SHA, not a `pull` — same discipline the plugin-pinning
item asks for: **verify `rev-parse HEAD` matches, or refuse.**
---
**Done 2026-08-21 — as a tag, which is what this item ruled out.** The owner reversed it on being shown
the trade: "we don't even need a code word if we tag with deploy/date and the version". `deploy/<version>`,
e.g. `deploy/2026.08.21.02`.
The argument that changed it was not about refs at all. A codeword **can be written by accident**: every
document about deploying contains it, this file included, and so does every conversation planning one — so
the trigger needs 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 also names its own version, which a codeword
does not, and `git ls-remote --tags` answers "is there one" without fetching.
The cost this item correctly identified is real and was accepted: a tag is a second ref to mint and push.
It is two commands in the release recipe.
What replaced the "un-marking is forward-only" worry: the deploy is idempotent by a **local** record, not
by the ref. `scripts/deploy-claim.sh` takes one claim per version and never gives it back to a machine, so
a tag being moved or deleted cannot cause a second rollout.
@@ -2,7 +2,7 @@
id: fleet/an-orchestrator-that-acts-on-every-server-at
title: "An orchestrator that acts on every server at once"
theme: fleet
status: open
status: done
created: 2026-08-20
date_source: assumed
order: 1
@@ -23,3 +23,24 @@ Open: whether this is a script over SSH, an Officer screen, or a chat capability
`headscale` and `omega` — checkout present, nothing running — are targets at all. Note the
platform already has an **Update button** for a single server (`03abdeb2`); this is that, fanned
out, and it should probably share the code rather than reimplement the pull.
---
**Done 2026-08-21, and not as any of the three shapes this item proposed.** It is not a script, not a
screen and not a chat capability — it is an **agent** given a written procedure
(`prompts/deploy-commander.md`) and a list it has to read rather than a list baked into it. Tonight it
rolled `2026.08.21.02` and `2026.08.21.03` across six hosts unattended and wrote its own reports.
All three verified facts in this item survived contact and all three earned their place: it takes each
checkout from PM2's `pm_cwd`, so `alpha`'s stale second checkout was never touched; it pulls in place
rather than assuming a remote; and it takes the login user per host, so `deadpool` was reached as `br`.
Two things this item did not anticipate, both now in the procedure. It must **skip its own host entirely**
— restarting `officer` there kills the rollout mid-flight — which leaves that one machine needing a
deliberate restart afterwards (`todo/fleet/the-deploy-commander-cannot-finish-its-own-host`). And a
release needs **one attempt only**: the first watcher retried a failed deploy every two minutes, so that
rule is now `scripts/deploy-claim.sh` rather than a paragraph anyone re-implements.
It does **not** share code with the single-server Update button. Worth revisiting, but the overlap turned
out to be smaller than expected: the button restarts a process, and almost all of this is the reading,
skipping and reporting around that.
@@ -0,0 +1,28 @@
---
id: fleet/plugins-update-restarts-every-sidecar-so-the
title: '`plugins:update` restarts every sidecar, so the report understates what moved'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 7
---
**`bun run plugins:update` restarts sidecars itself**, printing `sidecar: started` per plugin. On `alpha`
that is twelve processes bouncing during step 3 — before the plan's own restart step, and not something
any plan asked for.
Nothing is broken and this is not new behaviour. The problem is honesty: a deploy report whose "restarted"
column reads `officer` is describing one restart when thirteen happened, and the whole value of these
reports is that they can be trusted about what moved.
Two ways out, and they are not the same decision:
- **Make the report accurate** — the plan format grows a note, or the commander reads `restart_time` for
every process rather than only the ones it restarted. Cheap, and it is the honest minimum.
- **Make the script not do it** — a `--no-restart` flag, with the deploy restarting what the plan names.
That is the better shape (a plan that says "restart `officer` only" should mean it) but it is a change
to something that works, and a plugin whose sidecar is not restarted after an update may well be broken
in a way nobody notices until later.
Do the first regardless. The second is a real design call.
@@ -0,0 +1,34 @@
---
id: fleet/the-deploy-commander-cannot-finish-its-own-host
title: 'The deploy commander cannot finish its own host'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 5
---
**The deploy commander cannot finish its own host, so one machine is always a build behind until somebody
restarts it by hand.** After `2026.08.21.03` that skip was two releases deep on `officer-dev` —
`restart_time` unchanged at 155 — with the code for both sitting on disk.
The skip itself is correct and should not be removed. The commander is a session inside
`officer-claude-code` on that machine; restarting `officer` there ends the rollout mid-flight, and it is
not hypothetical — a manual deploy on 2026-08-21 had to leave that one sidecar alone to survive writing
its own report. Restarting only `officer` and leaving the sidecar would technically work and is worse: it
puts one host in a state nobody chose, a step behind the rest, discovered later.
So the question is what finishes it, and there are three honest answers:
- **A deferred restart the platform runs after the session ends.** Cheapest. The commander writes a
one-shot request, something outside the agent's process honours it, and the report says a restart is
pending rather than outstanding. Needs a thing that is not the agent and not `officer` — `at`, a systemd
transient unit, or PM2 itself.
- **A second machine deploys this one.** Cleanest and needs no new mechanism at all: any other host's
commander has no reason to skip `officer-dev`. Costs a second armed watcher and raises "who deploys the
deployer" — two watchers must not both claim a release, which `scripts/deploy-claim.sh` already prevents
per host but **not across hosts**, since the claim is local by design.
- **Leave it manual and make it loud.** The report already says "outstanding". A notification would close
most of the gap for none of the work.
Not a decision to make from the code — it is the owner's call which of those three shapes he wants.
@@ -0,0 +1,34 @@
---
id: fleet/the-watcher-loop-is-rewritten-by-an-agent-every
title: 'The watcher loop is rewritten by an agent every time it arms'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 6
---
**The watcher loop is rewritten from scratch by an agent every time one arms**, so its correctness is
re-rolled on every arming. The first one on 2026-08-21 was careful, logged honestly, set a `timeout` on
its network call, routed stderr away from the JSON it was parsing — and still contained an unbounded
retry, because the obvious way to ask "have I done this release" is to test the `.done` file, and that
file only exists on **success**.
`scripts/deploy-claim.sh` fixed that one instance by moving the rule into code. The general problem is
untouched: the next loop can get something else subtly wrong, and nothing will notice until it costs
money or misses a release.
Things a platform-owned loop would make constant rather than re-derived, all of them learned by an agent
getting them wrong once:
- Claim the version before spawning anything.
- `OFFICER_ROOT` set explicitly — it is **empty** in the sidecar's environment, so the lock path resolves
to `/data/deploys` and every release looks permanently undeployed.
- stderr kept out of the `stream-json` stream, or the `claude.ai connectors are disabled` warning corrupts
the first `jq` parse.
- `jq --unbuffered`, or the job panel shows nothing for twenty minutes and reads as hung.
The tension worth naming: an agent writing its own loop is also the reason this worked at all on the first
night, from a prose description, with no watcher feature built. A fixed script is more correct and less
general. The likely answer is a **script the loop calls** rather than a script that replaces it — which is
what the claim rule already is, and it could absorb the four items above the same way.
@@ -0,0 +1,30 @@
---
id: known-bugs/a-commander-opened-from-chat-loses-its-context
title: 'A commander opened from /chat loses its commander controls'
theme: known-bugs
status: open
created: 2026-08-21
date_source: stated
order: 4
---
**A commander opened from `/chat` reports the ordinary chat context, so the composer hides every control
that only exists for a commander.** The visible symptom is the background-job dialog offering "Stop when
it fires" and "Keep going" but not **"Exit and hand back to the agent, then re-arm"** — the one a watcher
actually wants, and the dialog's own default inside a commander.
Introduced 2026-08-21 by a deliberate UX change: a commander in the Live list opens in `/chat` rather than
navigating away to Command HQ, because being thrown out of the page you are reading costs more than the
metadata you did not ask for. That change is right and should stay.
The defect is that `isCommander` is derived from **the screen** rather than from **the session**:
`isCommander={manager.context === 'command-hq'}`, and `/chat` passes no such context. A commander is a
commander wherever it is rendered.
The fix is to carry it on the session. `ClaudeSessionSummary` and `/chat/live` both already have
`isCommander`, derived server-side from the cwd; `GET /chat/sessions/:id` does not, and that is the one the
chat detail reads. Adding it there and letting the composer take it from the session rather than the route
closes this and makes the same thing work in any future surface that renders a chat.
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
option and a component prop, neither reading the other's — which is what hid the original bug twice.
@@ -0,0 +1,26 @@
---
id: known-bugs/askuserquestion-can-fail-to-parse-its-own-call
title: 'AskUserQuestion can fail to parse its own call'
theme: known-bugs
status: open
created: 2026-08-21
date_source: stated
order: 3
---
**`AskUserQuestion` rejected a well-formed call with `InputValidationError: called with input that could
not be parsed as JSON`.** Seen 2026-08-21 in the deploy commander's first turn, on Sonnet.
The tell is in the transcript: the failed call arrives as `{"__unparsedToolInput": {"raw": "{\"questions\":
[…"}}` — the arguments reached the harness as a **string** rather than an object, and the harness reported
that as the model's fault. The retry, byte-identical in intent, went through and the question rendered
normally.
So it is recoverable and it self-corrected, which is why it is filed here rather than treated as urgent.
What makes it worth fixing anyway: this is the same tool as the stall bug fixed earlier the same day, on
the path where an agent asks a question and waits. A parse failure there burns a turn, and if it happened
on the **second** attempt too the agent would most likely give up asking and guess — which is the failure
mode the question mechanism exists to prevent.
Worth capturing the raw payload when this fires rather than only the first 200 bytes, since the message
truncates exactly where the interesting part would be.
@@ -0,0 +1,30 @@
---
id: mobile-apps/the-live-chat-swipe-order-should-exclude
title: 'The live-chat swipe order should exclude commanders'
theme: mobile-apps
status: open
created: 2026-08-21
date_source: stated
order: 3
---
**The iPhone app swipes left and right between live chats, and commanders should not be in that order.**
The reason it needs the server's help is that the phone has nowhere to put a tab. The web app solved the
same problem on 2026-08-21 with two tabs inside Live — `Agents (n)` / `Commanders (n)` — and a phone
swiping through a flat sequence cannot do that, so it can only filter if the payload says which rows are
commanders.
**The server side is done and shipped in `2026.08.21.02`.** `GET /chat/live` rows now carry
`isCommander: boolean`, derived server-side from the session's cwd rather than from a database lookup:
every commander shares one directory, so the directory is the answer, it costs no query, and it is already
correct during a commander's first turn before any row exists. `GET /chat/sessions` carries it too.
So the app work is: read the field, drop `isCommander` rows from the swipe sequence, and decide whether
they are reachable at all on mobile or simply absent. A deploy commander appearing between two
conversations you are having is the thing to prevent.
**Owed: a short document for the mobile developer** — the field, where it comes from, and the one caveat
worth stating, which is that a row is `isCommander` from its first turn even before it has a title. The
owner asked for this on 2026-08-21 and said explicitly it should be written against a shipped version
rather than an intended one; that version is `2026.08.21.02`.
+14
View File
@@ -22,3 +22,17 @@ Four bugs came out of that work and are fixed: closing a pane sent you home; `?v
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.
**A fifth, fixed 2026-08-21.** The URL survived a refresh and the pane still did not open — reported as
"refresh loses the panel, and then the file won't open". Nothing to do with the query string this time:
`WorkspaceView` gave the ephemeral pane `defaultSize={0}` and opened it from an effect calling
`panel.resize()`, so open/closed lived in two places. On a deep link the effect ran before
`workspace.isLoaded` was true — when the early return means nothing is rendered and the ref is null — and
its deps could not change afterwards, so it never ran again. The second half is what made it look like a
click bug: with `?view=` already naming the file, double-clicking it changed no state, so nothing re-ran.
The pane is now rendered conditionally with `id` + `order` and sized declaratively; `ephemeral-pane.test.tsx`
asserts the SIZE rather than the element's presence, because a pane at zero width passes any
"is it in the document" check. Five of its six tests fail against the old implementation.
The shape is worth keeping in mind for the rest of this theme: **state that the URL owns, mirrored into an
imperative correction, will eventually be read at a moment the correction cannot run.**