Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98177c3bc9 | ||
|
|
3cab16ca5b | ||
|
|
f023ab5da0 | ||
|
|
b10a49c082 | ||
|
|
4b2cb3d266 | ||
|
|
7d7a80782e | ||
|
|
bd9d26199f | ||
|
|
58615ee5e7 | ||
|
|
31cdb93691 | ||
|
|
8917f36be5 | ||
|
|
530e5880e1 | ||
|
|
887749722c | ||
|
|
9ce81547c4 |
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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)],
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.**
|
||||
|
||||
Reference in New Issue
Block a user