cbedfed2bacb44ac38310ab1ace894c0638ab35f
1732
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cbedfed2ba |
Revert "a new chat gets its name without a refresh, and can be renamed"
This reverts commit
|
||
|
|
f9a3699603 |
a new chat gets its name without a refresh, and can be renamed
Start a chat and the header said "New chat" until you navigated away and back, and the rename control was missing entirely. Both halves came from one cause, and it was not the titling. The header resolves its own title by finding the session in the cached LIST — `sessions.find((s) => s.id === sessionId)` — and a newborn conversation is not in that list. It has staleTime 30s and nothing invalidated it, so `current` was undefined: the label fell through to "New chat", and rename was absent because rename is a per-row action on a list the session was not in yet. Navigating away and back refetched it, which is exactly what made this look like a rendering bug. Verified first that the titling itself is fine and needs no change. Claude Code writes an `ai-title` entry early — line 8 in a real transcript, after the user message and BEFORE the first assistant line — and Officer's own `title: summaryTitle || firstUserText || '(untitled)'` means a session is never nameless once its transcript has a user message. The server could always answer; nothing asked it. So the conversation announces itself. On `session:claude` — the START of the turn, when the transcript becomes real on disk — and again at the end of every turn, because the harness may have bettered the name while it ran. The second call sits OUTSIDE the id check deliberately: that is true whether or not a particular `result` happened to carry the id. Cost is one small request per turn per open chat, which is the honest price of a list that is right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
75affec091 |
a commander is identified by its row, not by where it lives
Groundwork for choosing a commander's working directory. Identity was the CWD — every commander shared one directory, so the directory was the answer and it needed no query. The moment a commander can choose its own, that test is wrong in BOTH directions: a commander in the platform checkout reads as an ordinary chat, and an ordinary chat in the old shared directory reads as a commander. So identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free. The isolation the shared directory provided is no longer carrying anything — the surfaces that needed it now filter explicitly, which is what made this possible. Four surfaces moved, and the shape of the change is the same in each: the transcript scanner stops inferring it and the ROUTE decorates from the rows. - `listClaudeSessions` says nothing about commanders any more. It is synchronous over a directory scan and identity now needs a database read, so `withCommanderFlag` applies it at the caller. - the sessions list, `/chat/live` and `GET /chat/sessions/:id` each read `listCommandHqSessionIds` once per request. A set membership test per row rather than a lookup per row: commanders are few, and the alternative turns one list into fifty queries. - `commanderBlock` resolves the row instead of parsing the account out of the path. It needs the email for the record paths, so that is a second read — once per session spawn, beside the affordances and the skills index, which are read there for the same reason. `commanderBlock` had to be hoisted out of `createSession`, which is synchronous, into `spawnClaudeStreaming`, which is async and already holds the account. Passing it in as a parameter rather than making the whole session constructor async. `isCommandersDir` survives but is documented as no longer being the identity test — it remains only for the places that legitimately care about that specific path: creating it for a new OS account, and as the historical home of commanders created before this. A commander mid-first-turn has no row and so reports false for one turn. That is the same turn it must not arm anything on, and it corrects itself the moment it is filed. Verified against real data: a filed commander renders the block, an unknown id and a null id both return null, and the live gate still answers 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
de9d229cfa |
todo: review the secret store built overnight
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cabc4825ef |
a commander never arms on its first turn, and the missing paths are how it knows
The first-turn gap is closed by a rule rather than a mechanism, and the two halves interlock instead of conflicting. A commander cannot be told its record paths on the very first turn: the transcript id is minted as that turn begins, so there is no id to key a path on. That was written up as a limitation. It is not one, because arming on the first turn is not permissible anyway — the first exchange is for agreeing what to watch and what to do about it, and a watcher armed before that agreement is armed on a guess. A wrong condition fails silently in both directions: never fires, or fires constantly, discovered days later. So the absence became the signal, and the block says so: "If you were given no paths above, you ARE on your first turn: that is what their absence means." An agent with no paths is on the turn it must not arm on, and an agent that armed anyway would be unstoppable and undeletable — the same rule reached from the other side. The rule is in both places on purpose. The block, always loaded, carries the instruction and the interlock in four lines. The Commanders skill carries why, at length, for the agent that is about to arm one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
87ff6ed326 |
a commander is told where to record itself, however its loop gets started
The record instruction lived in one place: the preamble the background-job dialog composes. Fine while the dialog was the only way to arm a commander, and wrong for the flow being built — the commander arming itself from the conversation, with the owner never touching the launcher. An agent starting its own background job never sees that preamble, so it would record nothing, and delete would quietly go back to leaving orphaned loops. So the paths are compiled into a commander's session preamble, beside the affordances and the skills index. THE SPLIT, which is the part worth keeping: the METHOD lives in the Commanders skill — read on demand, as long as it needs to be — and the PATHS are compiled in always, because they are a fact about this session that the agent cannot derive. That is exactly the line between a skill and an always-loaded block, and it falls in the same place here as everywhere else. The account is derived from the CWD rather than passed in, because the caller does not have it: the sidecar knows a member's osUser and home, and an owner's turn carries no email at all. A commander's cwd is DATA_PATH/<email>/commanders, so the email is in the path — and it is verified by reconstructing the directory from it, so a coincidentally-shaped path cannot pass. Keyed on the cwd rather than a database lookup, for the same reason every other surface uses isCommandersDir: a session must not be a commander in one place and not another. It also works before the row exists, which a lookup would not — and the first turn is when the row does not exist yet. Stated in the file rather than left to be found: a brand-new commander's FIRST turn gets no block, because the transcript id does not exist until that turn begins. In practice the first turn is the conversation about what to watch and the loop starts on a later one, but a commander that armed itself on its opening message would not have been told where to record. Verified on the three cases: a real commanders cwd renders the block with both paths, an ordinary chat gets null, and a session with no id yet gets null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d6e8a841e |
spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a child's cost arrives later, from the child, long after the loop wrote its id. So the file becomes an INBOX and command_hq_spawned becomes the record. The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could would put an authenticated writer inside a background job, which is worse than the problem. So the loop appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing; the read is the only moment the answer is wanted. What the rows buy that the file cannot: - the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which is the reason this became important. - queries. Cost per commander, how many agents a trigger spawned, which are still running. - somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends. totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a count beside the total, because a commander whose children's costs are unknown must not display a confident total that happens to be too low — that understatement is the whole reason this exists. Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed to be lossy, the rows are not, but a row never ingested does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b7112f6e5d |
a commander records what it is and what it spawned, so deleting one means it
Without this record a commander cannot be stopped or fully deleted, and the delete route's comment claimed otherwise: "the running background process (if any) dies on its own". It does not. Nothing in that route touched a process, so a deleted commander left its loop running — still polling, still able to fire, still able to spawn a deploying agent, with no chip to stop it from and no row left to disarm. THE RECORD, and why the loop has to write it. Three ways to recover the link were tried and none works: the PROCESS holds nothing (no open transcript descriptor, nothing identifying in its environment — tested); the TRANSCRIPT is indistinguishable, because queue-operation/ai-title/last-prompt look like platform markers and are the harness's own, appearing in every session equally; CONTENT matching finds ordinary chats that quote a procedure as readily as the agent following it. So the loop records, because it is the only party that knows. Two files under DATA_PATH/<email>/commander-state/<session>/: `loop-pid` and `spawned`. The reader is deliberately tolerant — a truncated write or a stray line is dropped, not fatal, because a record that failed closed would make a commander undeletable over one bad line. STOPPING needs no new mechanism, only the pid: descendantPids + SIGTERM from the loop takes the loop and any agent under it, which is exactly what that helper was written for. A commander with no recorded pid stops nothing and SAYS so — the honest answer for one armed before this existed. DELETE now cascades in a deliberate order: stop the processes, then the children's transcripts, then the commander. Stopping first because a live agent writing to a transcript being deleted is a race, and because the loop could otherwise spawn a replacement between the last two steps. It returns counts rather than a bare ok — this is the one irreversible action here. The children's transcripts are deleted with the EXISTING deleteClaudeSession: its cwd argument is documented as "a hint, not an authority", so it locates a child that ran in the platform checkout rather than the commanders directory. No new function. THE CHAIN'S WEAK LINK was that the loop cannot know which commander it belongs to. The paths are now handed to it in the background-job prompt, fetched from a new endpoint because they are rooted in DATA_PATH — a server fact. Paths rather than an upload endpoint on purpose: a shell redirect needs no credential, and a background job has no business holding one. The prompt rule is numbered properly rather than landing on 7.5, and the output rule shifts down one when it is present. Verified both shapes produce consecutive numbers. The manager now exposes claudeSessionId, because a commander is keyed on the TRANSCRIPT id everywhere and officer's own session handle is not interchangeable — that mismatch is what once rendered a working commander as an empty pane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee9106ef54 |
the new-commander card gets a slot for example prompts
Requested, and deliberately not written: an example opening prompt is a promise about what the flow accepts, and the flow is being rebuilt. Written now, all three would need rewriting — and a wrong example is worse than none, because a user copies it and blames themselves when it does not work. The note records what they should be when they are written: two or three differing in SHAPE rather than subject — one where the trigger is obvious, one that needs a conversation to pin down, and one that is plainly not a commander at all, so the boundary is visible from the examples rather than only from prose. Marked provisional, so it renders with the draft badge until somebody writes it. That is what the flag is for: this ships on every deploy whether the copy is ready or not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e4f1b75f9b |
a platform skill the agent can open, generated so it cannot rot
A fresh agent answering "what is a commander, how do I start one" has nothing to answer from, and invents. The mechanism to fix that already existed and had one user: skills/load.ts compiles an INDEX into every session — name, when to reach for it, path — and the body costs nothing until the agent opens it. The exact inverse of an affordance, which sends its body always and its description never. So no affordance was added, and that is the correction worth recording: an affordance saying "there is a platform skill" would be a second copy of the index entry, paid for on every turn to say what is already there. Affordances are facts about the CLIENT; this is reference material. The platform skill is GENERATED, because a hand-written one rots and we measured it — Spotlight's settings list drifted in two days, CLAUDE.md claimed 27 permissions against 18 and twenty PM2 entries against thirteen. A wrong skill is worse than a wrong doc: an agent quotes it with confidence and the user cannot tell. Generated: every permission with its kind, label, description, API prefixes, sockets, screens, self-service routes and read-only-writes; and the keyboard shortcuts. Prose, kept beside the generator so the two cannot separate: what the platform is, what each KIND means for a user, and where to look next. It also says what it does NOT cover — installed plugins vary per machine so a generated list would be wrong on every other server, and request shapes are not in the registry — plus a closing instruction not to guess, on the grounds that a question the registry cannot answer is one this file cannot either. docs:check regenerates it into memory and fails if the committed file differs. Verified both directions: appending one line made it fail naming the file, and regenerating made it pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4a206086af |
docs:check — the documentation's checkable claims, against the code
"Update the docs before a deploy" is a step that gets skipped at two in the morning, and its absence is invisible. Every piece of drift found today was mechanical — a count, a route name, a process list — so it is checkable, and a check that exits non-zero cannot be quietly not done. It found two things on its first run, both wrong for over a week: platform/CLAUDE.md "27 entries as of 2026-08-13" the registry holds 18 officer-dev/CLAUDE.md "Officer is twenty PM2 entries" there are 13 The second was worse than a wrong number. It listed eleven processes that do not run here — vault, slskd, headscale, transmission, invoiceshelf, wallet, photos, jellyfin, gitea, music, vnc — while omitting six that do, and named two mybiblepal entries as sharing the daemon when nothing does. Accurate on 2026-08-07, and nobody re-derives a list of processes while editing the sentence around it. Both are corrected, with what they used to claim recorded, because the interesting part is how a maintained list decays rather than that it did. Three checks: the permission registry count, the PM2 entry count, and every settings route Spotlight offers resolving to a section some screen declares. That last one is the class the counts cannot catch — a name, not a number — and it is the drift that shipped a dead link to /settings/users. THE RULE FOR ADDING ONE, written into the file: a check compares a documented claim against a value DERIVED AT RUN TIME, never against a second hard-coded copy. A check with the answer written into it is one more place to drift, and it drifts in the same edit that misses the doc. What it deliberately cannot do is read prose and judge it. It looks only at claims with a machine-checkable counterpart — a small fraction of the documentation, and the fraction that rots fastest. The settings check parses source rather than importing it, and says why: those sections are object literals inside components, so importing them drags React and a DOM into a CLI. `skipped:` lines are printed rather than swallowed — a machine without pm2 cannot check that count, and saying so beats reporting a pass it did not perform. Recorded as required before tagging in CLAUDE.md, and in prompts/deploy-commander.md as explicitly NOT the rollout agent's job: it executes a plan, and a prose edit between two hosts is not that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29d998b6c3 |
a new commander pane explains itself, in skeleton
Starting a commander currently means knowing to open the background-job dialog and arm a watcher from it. That is not discoverable, and it reads as configuration when the real answer is "talk to it" — so the dialog stops being the way in, and the pane explains the way in instead. EmbeddableChat's own empty state is "Send a message to start". True, and it tells a first-time user nothing about what to send. The copy is NOT written, by agreement: the instructions have to describe a flow that does not exist yet, and writing them first produces text that is wrong in a way nobody notices. What is here is the structure, plus the parts already true whatever the flow becomes — what a commander is, and that it begins as an ordinary conversation. Sections carry a `provisional` flag that renders a visible "draft" marker. That is the load-bearing detail: this ships on every deploy whether the copy is ready or not, so a half-written guide must not be able to look finished. Clearing the flag is the last step of writing a section, not a formatting choice. Data rather than JSX, so filling it in later is editing one array instead of hunting through markup — the same shape as the shortcuts registry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
228f2f4638 | deploy report 2026.08.22.02 | ||
|
|
68d60e409b |
2026.08.22.02 — theme favourites, ctrl+? shortcuts, command hq in two columns
Schema change this time: theme_favorites is new, and without the push the theme dropdown fails on a missing relation the first time it reads a starred list. The plan names db:push as a step rather than leaving it to be inferred from a diff. Two things the plan states that a reader could not infer. Command HQ MIGRATES a saved layout on load, so anyone who has opened that screen sees their arrangement replaced once — deliberate, and the screen is locked so the only loss is column sizes. And the email plugin's multi-account work arrives through plugins:update from another repository, affecting only hosts where that plugin is installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>deploy/2026.08.22.02 |
||
|
|
b1dfa3f7bb |
spotlight on shift+space, overriding both of its stock behaviours
Fifth setting in three days: ctrl-space → shift-space → alt-space → ctrl-space → shift-space. Ctrl-space was tried twice on the theory that preventDefault would beat Brave's address bar. It does not — that binding is at the browser level and the key never reaches the handler. Recorded in the file so it is not tried a third time. Shift-space overrides two stock behaviours deliberately, and the owner's reasoning is the right one: it types a space in a text field, and it scrolls a page UP when nothing has focus. Neither is worth a launcher. Page-up-by-shift-space is a fossil, and typing a space is what the unmodified spacebar is for. The cost is stated once in the file rather than argued again: typing in CAPS by holding shift instead of using caps lock puts a shift-space between every word, which opens the launcher mid-sentence. If that turns out to bite, the fix is a text-field guard — which this had for one day, and which is exactly what made the launcher unopenable from the composer, where it is most wanted. Trading that back would be trading down. Every other modifier must be absent: ctrl+shift+space is the theme browser, and one chord opening both is the bug that needed a capture-phase listener to fix. Verified disjoint in the built bundle, both conditions quoted from it rather than from the source. Five moves in three days is the argument for making chords configurable rather than for choosing better ones, which is why the ctrl+? registry names the implementing file for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f586d748d6 |
ctrl+space opens spotlight, and ctrl+? tells you everything else
Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space → ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction — alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and would rather move the app than the window manager. ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches the handler and nothing in this repository can help — the fix would be in Brave. `!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the two conditions are disjoint. CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the value is in state/shortcuts.ts. The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now would rewrite three handlers for nothing visible. Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bcdc769083 |
commanders are one per line, not a grid
sm:grid-cols-2 xl:grid-cols-3 was reasonable when this panel owned 34% of the screen. It owns part of a 24% column now, so two columns of card inside it left each one too narrow for its title — every card truncated and the cost line wrapped. A grid of one. The card is untouched: same rounded pill, same border and hover, same p-4, same gap-3 between them. It simply stops being asked to share a row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
430f058a6d |
command hq: two columns, with the navigation stacked
It was 16 / 34 / 50 — a category column, a commander column, and the transcript. So half the screen was navigation for a list holding a handful of commanders (one, on this machine) while the only thing anybody reads got the other half. Now the two navigation panels are stacked into one 24% column — commanders on top, categories beneath — and the transcript takes 76%. Categories BELOW the commanders, not above: the commanders are what you came for and the categories are how you narrow them. Above, the filter would push the list down the screen and be read first; below, the list is at the top where a list belongs, and the categories can grow their own scrollbar without taking room from it. The categories panel lost seventy pixels of branding — a Radar tile, "Command HQ", "Watchers & long-running jobs" — which made sense when it owned a column and does not now that it sits at the foot of one. The screen's own header says Command HQ two inches above it. `All commanders` and `Uncategorised` moved INTO the scroll box with the categories. They are the same kind of thing, a filter over the list, and splitting them into a separate nav block above made the second look like a different mechanism. The heading and the + button stay pinned; only the list scrolls — the same rule as the theme dropdown. MIGRATION, and it is the part that could have gone wrong. This key is persisted per account, so the default alone moves nobody who has opened the screen. No appType was retired either, so `hasAppType` cannot see it the way the /chat migration could — the test is structural instead: a horizontal root with three children is the old shape. It terminates because the replacement has two, and replacing outright is safe on a `locked` screen where the only user contribution is column sizes. Same reasoning, and same trap, as ChatHistory/index.tsx. Two of my edits silently did nothing first, because I searched for `officerdb` where the module is `officerdev` and used replace without an assertion. Both are asserted now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9b459b79dd |
poll gitea locally — the public door is rate-limited and gitea is on this machine
The deploy watcher kept logging `Connection closed by 77.42.39.155 port 22` followed by "make sure you have the correct access rights", which reads as a credentials problem and is not one. `origin` pointed at gitea.officer.dev, which resolves to the public address and arrives through Nginx Proxy Manager — where every connection appears to come from one container IP, internet bots get that IP penalised by srclimit_penalise, and SSH is refused for long stretches. Gitea is a plugin ON THIS MACHINE, listening on 100.64.0.13:10023. The watcher was leaving the box and coming back in through a proxy to read a repository sitting on its own disk. origin now points at the local address; the public URL is kept as `gitea-public` so nothing is lost. No file was edited to do it, which matters — the watcher is a running bash script and editing one in place is how a loop starts executing half of two versions. Changing the remote's URL takes effect on the next tick with nothing restarted. Loopback would be better still and is not used: 127.0.0.1 fails host key verification because it has no known_hosts entry, while the tailnet address is already trusted. The cost of not knowing this, recorded in the prompt: a tag pushed at 21:47 on 2026-08-21 went unnoticed until 23:07. Eighty minutes, forty-odd ticks, every one of them correctly logged and retried. The loop was never at fault — it was asking through a door that is sometimes shut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab77c66f04 |
todo: configurable shortcuts, a shortcut modal, and the omarchy menu revamp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dd6135cac1 |
favourite a theme from anywhere, and the browser stops fighting the menu
Three reported faults and two additions, all from one keystroke's worth of use. THE MENU OPENED BEHIND THE OVERLAY. The theme pill is a Radix DropdownMenuTrigger, and a focused trigger opens on Space — so ctrl+shift+space opened the browser AND the dropdown behind it. preventDefault could never have fixed it: React attaches its handlers at the root, which bubbles before window, so the trigger saw the key first whatever this listener did with it. The listener now runs in the CAPTURE phase and stops the event dead. THE VIM KEYS DIED WHEN OPENED FROM AN INPUT. Same shape as the Spotlight bug earlier tonight: the guard that refuses to steal keys from a text field was still true after the overlay opened, because focus had never left the field. Opening now blurs, and the guard no longer applies once the list is open — there is no input in it, so there is nothing left for it to protect. Only the GLOBAL bindings defer to a text field now, which is the case it was written for. f STARS WHAT IS FOCUSED, inside the list, so a browse and a shortlist are one pass rather than two. ctrl+shift+f STARS THE CURRENT THEME FROM ANYWHERE, and deliberately without the typing guard the arrows beside it need: word selection is what ctrl+shift+←/→ collides with in a text field, and ctrl+shift+f collides with nothing — no character, no browser binding. Guarding it would only break it in the composer, which is where you are when a theme starts annoying you. The header pill now shows a star when what you are wearing is one of yours — only when true, because a hollow star on every theme is chrome that says nothing two hundred times. The row star in the overlay is a span with role="button", not a button: it sits inside the row's own <button>, and nesting one is invalid HTML that browsers resolve by dropping one of them. useThemeKeys takes the toggle as a callback rather than reaching for it. Favourites are per account and live behind an API, which is app knowledge; this hook owns keys and selection and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bd15a186b5 |
themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording. ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it". Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the ctrl+shift+space browser, or one teaches an order the other contradicts. FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are directories discovered at request time — so a star can outlive its theme and the reader intersects with the live registry rather than trusting the table. Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer "what else is there". Filtering to favourites only is a separate, explicit toggle. THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line telling you the shortcuts exist was visible for half a second and then gone under two hundred entries — which is the same as not having written it. Header and footer are now siblings of a scroll box instead of passengers in it, and the header names alt+space too. THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500, which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account` permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it away from, and caught that by reading rather than by testing. Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200 so the login screen keeps its colours. 10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in the original survives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a15fadbddc |
27 tests on the file standing between a member and a shell
authorize.ts had none, and its own header is what says why that mattered: every deny path in the platform ends there, and the failure mode of a permissive default is not a bug report, it is someone else's session. It had none for a structural reason rather than an oversight. Every path needed two database calls, and the only file here that mocks officerdb leaks its stub into whatever runs after it — so the obvious approach would have traded one untested file for a flaky suite. So the decisions are split from the fetching. resolveEffectivePermissions, decideApiRequest and decideWsProvider are pure functions of their arguments; the async wrappers keep the role cache, the fail-closed catches and the shape callers use. No behaviour change, and the live gate still answers 401 on file-browser, users and tasks. REAL registry keys throughout, deliberately: a suite built on invented permissions keeps passing after a rename, which is the failure an authorization table can least afford. If a key here disappears this file goes red. Pinned beyond the happy paths — the owner carries no grants to consult; core is write unconditionally; a confined grant is dropped without a Linux user and all three drop together; execution and admin grants in the table are ignored even though no CHECK stops the row existing; an unknown key is ignored without disturbing what is legitimately held; self-service is exact in method AND path; a socket needs no level; an unauthenticated caller is refused every door. Two gaps are stated in the file rather than left to be found: ownerOnly has no declaring permission yet, so testing it would test the fixtures; and the grant cache's contract is a claim about call sites, not logic. Found while writing them: readOnlyWrites paths are relative to the permission's api prefix, and getting that wrong fails as "no permission covers this path" rather than as a level error — a misleading message for a misleading path, now commented at the test that hit it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7e054ddf11 |
deploy report 2026.08.22.01 — 6 of 6 hosts, officer-dev skipped
Rolled deploy/2026.08.22.01 to alpha, fofos, macbookpro, edge-pertento-bridge, production-pertento-bridge and deadpool-rezio-bridge. Every host fast-forwarded |
||
|
|
0c2ff88c88 |
a commander is a commander wherever it is opened
Fixing a regression I introduced last night. Commander rows in the Live list were changed to open in /chat rather than navigating to Command HQ — right, and it cost the composer its commander controls, because `isCommander` was computed from the ROUTE: `manager.context === 'command-hq'`. So the same commander, rendered on a different screen, silently lost "exit and hand back, then re-arm" — the only fire behaviour a watcher wants, and the one the dialog makes default inside a commander. GET /chat/sessions/:id now reports isCommander, derived from the session's own cwd by the same isCommandersDir already used by the sessions list and /chat/live. The answer travels with the conversation. The composer reads one value, manager.isCommander, true when EITHER source says so — and both are needed. The screen knows when Command HQ starts a commander, before a session or a transcript exists for any route to report on. The session knows once it does, wherever it is opened. Screen-only was this bug; session-only would have broken the brand-new composer, which is where the whole thing starts. Verified on a real commander rather than by reading: 172cdd8c resolves cwd=…/commanders and isCommander=true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c982353381 |
the boot check now reads the sockets that are served, not a list beside them
assertPermissionTotality exists to refuse a surface nobody has gated, and it was reading the wrong list: Object.keys(handlers) — the map of implementations — while Bun served a hand-written route table twenty lines away. The comment beside it said neither could be a stale copy of the other. That was false, and it had a live instance: /api/cliamp/ws and /api/cliamp/audio/ws stayed in the route table for two days after their handlers and registry claims were commented out. Not exploitable — isWsProviderAllowed finds no permission and 403s a member, while the owner upgrades onto a dead socket — but precisely the drift the guard is for, invisible to the guard. Now there is one declaration. WS_ROUTES maps path → provider, the route table is generated from it, and the check reads it. A socket route cannot exist without being checked. Two smaller guards arrived with it. A route naming a provider with no handler refuses to boot, with the path in the message, instead of 500-ing on first connect. And the provider union that was inline in upgradeWs is named, so the route table typechecks against what that function can actually serve — which caught my first attempt, where WS_ROUTES was Record<string, string>. sidecar and dev-proxy are listed explicitly as served-but-absent-from-WS_ROUTES, because they upgrade through their own handlers rather than upgradeWs. Named so the absence cannot read as an oversight. Half the TODO was stale and it is recorded as such rather than quietly closed: the eight red tests it cites have been green since cliamp was deleted on 2026-08-15. Deleting cliamp removed the symptom and left the shape, which is the more interesting half. Verified against the real prefix lists rather than by reasoning: the served set passes, one unclaimed provider is refused by name, all five generated routes answer 401, and the server boots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
78db9c8c93 |
2026.08.22.01 — 349 themes, spotlight, prompt history, email multi-account
No schema and no dependency change, verified against the previous tag rather than assumed. The last release needed a push for anthropic_accounts and this one needs none, so the plan says so explicitly — a host that runs db:push here has run something the release did not ask for. Two things the plan states that a reader could not infer from the diff. The email plugin's multi-account work is in ANOTHER repository and arrives through plugins:update, affecting only hosts where that plugin is installed. And officer-dev is skipped as always but is NOT behind this time: it was restarted repeatedly through the night as the work landed, so it is already running the tree this tag names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>deploy/2026.08.22.01 |
||
|
|
ff133f88ff |
todo: settings as portable files, per user and per server
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8e181ddf3c |
any key after a recall ends it, and Left is the one that proves the point
A recalled prompt you have started moving around in is a prompt you have adopted, so the next Up must not walk further back over it — the same fault as recalling over a fresh draft, arrived at from the other side. Left and Right are why this cannot be inferred from the text. They change nothing, so any rule watching for edits would miss them entirely, and arrowing into the middle of a recalled prompt is the clearest statement of intent available: you are working on this one now. So the composer leaves the history on any keystroke that is not Up or Down. Bare modifiers are excluded — holding Shift to capitalise the next letter is not editing, and treating it as such would end a recall between two presses of Up. `Dead` is in that set too: a pending accent on a composing layout is not a finished keystroke. leave() clears the stashed draft as well as the position. That draft belonged to the composing session being left, and handing it back after you have started editing something else would paste an older draft over a newer one. Six tests, three of them on the interaction between this and the emptiness rule from the commit before — together they are what stops Up from eating an edited prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02df4ac61c |
prompt history only takes the arrow key from an empty composer
Up replaced what you were typing. The guard was a caret-position test — Up only recalls from the first line — which is correct for a multi-line draft and useless for a one-line one: the caret IS on the first line, so every check passed and a half-written thought was swapped for something from last week. A draft is not recoverable from anywhere and the history is, so the tie goes to the draft. "Only when empty" alone would have made the feature useless, though, and that is the part worth writing down: once a recalled prompt is in the box, the box is not empty, so an emptiness test allows exactly one step back and then refuses. usePromptHistory already knew the difference — indexRef null means composing — it just never told anyone. It now exposes isRecalling(), so the rule is "no text OF YOURS" rather than "no text". Whitespace counts as empty: a composer holding two spaces is not a draft anybody would miss, and an invisible character silently blocking Up is worse than losing it. A draft that merely STARTS with whitespace is still protected. The line tests stay on top of it — in a recalled prompt spanning three lines, Up from the middle moves the caret, because there is a line above to go to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a5f387929d |
spotlight takes focus, and its arrow keys no longer depend on getting it
The arrow keys sometimes did nothing when the launcher was opened from a text field, and the two halves of that are one bug: navigation lived on the panel input's onKeyDown, so every keystroke was conditional on focus having actually landed there. When it had not — the composer kept it — ArrowDown went to the textarea and the launcher never saw the key. Two fixes, because either alone leaves the other half. Opening now blurs whatever had focus, explicitly, before the overlay mounts. The effect that focuses the panel's own input was already there and is enough on paper; "on paper" is what produced an intermittent bug, so the outgoing element is blurred rather than assumed away. And navigation moved to the window listener that already handled Escape, so it works regardless of what has focus — a panel that grabbed it back, a blur that lost a race, an element with its own key handling. The input's copy is deleted; leaving both would move the cursor twice per press whenever focus did land. The handler reads `results` and `active` through refs, deliberately. The listener registers once per open, so closing over them would pin it to their values at registration — arrowing down would read a stale list and Enter would open whatever was first when the panel appeared. Putting them in the effect's deps instead re-registers the listener on every keystroke, which is the same bug in a costume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e4117441b0 |
spotlight had list metrics, and it is not a list
It read as cramped because the first pass reused the app's own list sizing — a 7px tile, text-sm, py-2 — which is right for a row inside a panel competing with everything around it, and wrong for a modal you look at for two seconds with nothing else on screen. So the search input leads at text-base with a 5px glyph, rows are py-2.5 with a 9px tile, and the label is text-[15px] with the hint and the route kept deliberately subordinate rather than scaled with it. Two knock-on changes that are arithmetic rather than taste. The panel widened 560 → 640, because a row now carries a label, a hint and a route on one line and the route is the first thing squeezed out — it is also what disambiguates two rows with the same name. And the results box grew 52vh → 60vh, because taller rows in the same box show fewer results, which would have quietly undone removing the eight-item cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7671368faf |
alt-space opens spotlight from a text field too
The focus guard was still there. I moved the trigger to alt-space, wrote that the guard was no longer load-bearing, and then kept it anyway "as belt-and-braces" — which left the exact bug the move was meant to fix. Focus is usually in the chat composer, so the launcher did not open from the place it is most often wanted. Reported one commit later, by the owner, from a text field. The guard existed because shift-space types a space and eating that keystroke was unacceptable. Alt-space types nothing, so there is nothing to protect. preventDefault on the handler covers the residual case a layout might deliver, which is what the guard was really insuring against. isTyping is deleted rather than left unused: a dead helper carrying a long comment about a decision that has been reversed is worse than no helper, because the comment still reads as current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b07aafc67 |
spotlight opens on alt-space, and on nothing else
Third chord in a day, and each move was the owner naming a collision on the machine he actually uses: ctrl-space is Brave's address bar, shift-space types a space, alt-space is the window menu — and he rebound that rather than give up the chord. Shift-space was the worst of the three and it is worth saying why, because it read as the safe choice. It is a real key combination inside a text field: it types a space, and people hold shift through one more often than they think while typing capitals. So it had to stand down whenever focus was in an input — which is where focus usually is, the chat composer — and a launcher you have to click out of a field to reach is not a launcher. Alt-space means nothing in a text field, so the isTyping guard stops being the thing holding the feature up and becomes belt-and-braces. Kept anyway: two lines against some layout delivering alt-space as a literal character, which is cheap to prevent and expensive to discover. `altKey` with `ev.code`, not `ev.key` — alt-space produces a different `key` on several layouts and nothing at all on others, while the physical key is `Space` everywhere. No second chord. Two triggers for one thing means two things to collide with, and this one has moved twice. The TODO item's id still says ctrl-space. Ids are stable by design, so it stays; its body now records all three moves and which guard was load-bearing for which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f9dfd05676 |
header cluster and breadcrumb colour
Breadcrumb links rested at duck-forest and hovered to duck-teal; they now sit at the hover colour, so the hover and its transition go with it. One shared component, so the full browser and the dashboard widget both pick it up. The current folder stays duck-dark semibold, keeping clickable and current distinct. Header: the theme control leads the right cluster and names the theme it is wearing rather than showing a palette glyph — with 227 of them, which one is on is worth reading. It takes JobsIndicator's pill styling so the cluster stays one shape, and keeps its colour on --header-fg rather than hardcoding white. The jobs indicators and the rescan button are Super Admin only now. Cosmetic, and the comment says so: /jobs stays reachable by URL and rescan is gated server-side, so nothing here is load-bearing. Uses the same user?.role check UserMenu already makes in this folder rather than adding a second convention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cbcd8d65df |
fix accent/secondary foreground derivation; 202 community themes
The nine remaining contrast failures all had the same cause, and it was a real bug in map.ts rather than a limit of the source palettes: --accent and --secondary are both s.high, but their foregrounds were taken from fgRaised, which is checked against s.raised. That is precisely the near-miss the file already documents and fixes for --muted-foreground, one token over. Deriving them against s.high instead rescues all nine (every Solarized variant among them) and is provably a no-op for the official 22 — each already clears 4.5:1 on s.high by 1.3x or more, and ensureContrast returns its input untouched when it passes. Measured all 22 before the change; generate-themes.ts --check reports 0 changes after it. Also: GitLab-hosted repos are now discoverable (one theme lives there, and the raw-URL and default-branch shapes differ from GitHub's), and sourceFor probes a directory named after the theme's own slug, for the packs that nest one directory per theme. An exact slug match is the only prefix tried on purpose — a pack whose variants have names of their own has no single theme that is "the" one, so guessing would be wrong. Every community theme is regenerated in this commit, not just the new ones. aura turned up generated under an older pickAccent rule than its neighbours, because it was ported before that rule changed — the same drift generate-themes.ts was written to prevent, and the reason both halves are written from one source. All 202 now come from one mapping version. Verified afterwards: 227 stylesheets, 227 imports, 227 registry entries, no orphans in either direction. Still out: 14 repos that 404 (deleted or renamed since the site indexed them), grimdark-solarized (no source link at all), cobalt2 (its page links an awesome-list README with no theme in it), and oasis (a six-variant pack with no variant named oasis — porting it means inventing six slugs the site does not list, which is a naming decision). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
861fc4a58b |
add colors.toml and 0x-hex support, rescuing 44 more community themes
Poked at the 62 "no parseable source" skips from the first pass and found two things the pipeline didn't handle: several community repos genuinely ship colors.toml too — some in Omarchy 4's own semantic schema (best case, no derivation needed at all, same quality as the official 22), others reusing the filename for an unrelated flat color0..color15 scheme — and a handful of alacritty.toml files use 0xrrggbb instead of #rrggbb. palette.ts's loadPalette is now a thin wrapper over two exported pieces (parseTomlText, paletteFromRecord) so the community pipeline can attempt the real schema first without duplicating the reader; falls through to the color16 or ANSI-derived paths otherwise. generate-themes.ts --check still reports 0 changes for the official set — the refactor is behavior-preserving there, the lowercase-hex normalization it picked up along the way touched 7 official theme files cosmetically (their source colors.toml has uppercase hex), regenerated for real rather than left inconsistent with their own source. 147 -> 191 community themes ported. Of the original 62 unparseable repos, most turned out to be simply gone (404 on GitHub — repos deleted or renamed since the site listed them); the rest needed one of the two format additions above. 20 themes remain stuck: 16 dead repos, 2 with no repo link at all, and the same handful of genuinely low-contrast source palettes (mostly Solarized variants) where fixing contrast would mean changing the author's actual colours rather than porting them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
a58f4d2b32 |
port 147 community Omarchy themes from omarchythemes.com
The official 22 themes come from basecamp/omarchy's own colors.toml, contrast-checked by generate-themes.ts. A community theme has no such file — it ships per-app configs (alacritty.toml, ghostty.conf) in the older 16-ANSI-slot style, so this adds a sibling pipeline: from-ansi.ts derives the same 24-key Palette shape by role (accent, three background depths, three foreground weights) from those 16 slots, then runs through the same mapPalette + contrast checks the official themes use. Nothing about the official generator changed — verified generate-themes.ts --check still reports 0 changes. port-community-themes.ts discovers each theme's GitHub repo from its omarchythemes.com page, fetches alacritty.toml (preferred) or ghostty.conf, and is idempotent via community-manifest.json so a re-run only fetches what's missing. Community themes get their own registry/index.css marker block, separate from the official GENERATED THEMES block, so the two pipelines can never clobber each other. 121 of 212 attempted made it through cleanly; a second pass tightening accent selection and nudging --input recovered 20 more borderline cases (147 total). The rest failed for real reasons: 62 repos had no parseable colour source, 7 had an accent colour whose own luminance can't clear 4.5:1 against either black or white text, a handful were narrowly under threshold even after the rescue pass, and 2 pages had no repo link at all. Every generated file's header states its source repo and whether it's a light or dark theme. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
a01ae73a1f |
todo: plugin permissions on by default for every role
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
78f9331b89 |
deploy report for 2026.08.21.04 — six hosts, one skipped on purpose
alpha, fofos, macbookpro, edge, production and deadpool all took
|
||
|
|
98177c3bc9 |
2026.08.21.04 — /login in the chat, account switching, and a spotlight that reaches the plugins
First release in three with a SCHEMA CHANGE. anthropic_accounts is new, and without the push the account switcher's route fails on a missing relation the first time the usage panel is opened — so the plan names db:push as a step rather than leaving it to be inferred from the diff. alpha already runs this code, deployed by hand as a preview. The plan says its pull will be a no-op and to run the rest anyway, so it does not read as a skipped host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>deploy/2026.08.21.04 |
||
|
|
3cab16ca5b |
spotlight showed eight items, so every plugin was unreachable
MAX_RESULTS was 8. Core items alone fill eight slots, so on a populated server the empty-query list stopped at Browser and none of the fourteen plugin tiles below it could be reached — unless you already knew the name well enough to type it, which is not a thing a launcher gets to require. The owner caught it on alpha with the dock and the launcher side by side; on this machine, which has few plugins, it looked complete. The items were there the whole time. This was not a missing source — it was a silent truncation, with nothing on screen saying "and 20 more", which is exactly how a cap reads as an absent feature. There was never a layout reason for it either: the results container has scrolled since it was written. Removed, and the active row now scrolls itself into view with `block: 'nearest'` so arrowing past the fold works and arrowing through the visible middle does not jump the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f023ab5da0 |
spotlight draws the dock's tiles, and lists the settings that actually exist
Two things made it read as a generic list rather than as this app's own, and neither was the item source —
that was already the dock, and plugins were already in it.
TILES. SpotlightItem dropped `tile`, so every row got a `${color}22` tinted square: a third visual language
belonging to neither of the dock's two. Which put a coloured pane behind Calendar, Contacts and every
plugin's logo — the exact thing the dock's own comment calls out as reading like a mistake. Rows now use the
dock's rule: a filled square of the item's colour with a white glyph, or no background at all when the
artwork brings its own frame. A settings row with no colour keeps a muted glyph instead of getting a swatch
invented for it.
SETTINGS. The hand-listed sections had drifted within two days of being written, and the file's own comment
predicted it ("worth revisiting if it drifts"). Nine rows against a settings area that has twenty-one:
Languages and Voice missing entirely, every AI and System section missing, `/settings/users` pointing at a
route that does not exist — it is `/settings/user-management/users` — and four different rows sharing the
User glyph, which is most of what made the panel look generic.
Now twenty rows, copied from the five screens that declare them, with each group naming its source file so
checking is one grep. The comment records that the drift HAPPENED rather than predicting it, and says
deriving them from the screens is the fix if it happens a third time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b10a49c082 |
an account with no saved sign-in gets a chip you can read, not one you can use
Three chip states now, and the third is a different question rather than a broken version of the second: filled the account you are plain an account you can become — click switches dashed outline an account you can only look at — click shows its last known usage "Has my other allowance come back yet" is answerable from a snapshot, and the machinery for it was already right: a reset time is an absolute instant, and currentLimit rolls a past window forward and zeroes it instead of showing a stale percentage. Phase one was hiding a correct answer. What you cannot do is act on it without a /login, so the chip says that before it is clicked (in its title and in its border) and the panel says it again while you are reading one. Naming the fix rather than the problem: "No saved sign-in — run /login to use this one". Viewing is deliberately NOT a general tab selection. Only an account with no credential can be viewed, because for any other one the useful gesture is switching to it and then reading live numbers — which keeps phase one's invariant intact: if the panel shows a signed-in account, it is THE signed-in account. Switching clears the view for the same reason, so the numbers and the chips can never disagree. Replaces the count line, which was a fact about somewhere else. The staleness line that was already in this file — "Not signed in · as of 20 minutes ago" — now finally has a case that reaches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b2cb3d266 |
the account chips show the whole address, because the local part is the shared half
Two chips both read `andre.padez`. They are andre.padez@gmail.com and andre.padez@pertento.ai — one person's personal and work accounts, which is the entire case this switcher exists for. The chip showed the local part on the reasoning that the domain is what accounts have in common. That is true of accounts belonging to different people and exactly backwards here: the local part is the half they share and the domain is the half that tells them apart. So the strip wraps instead of scrolling, and the address is not truncated. The "N other accounts not shown" line was also lying. It counted stored usage READINGS minus the one being displayed, which since the switcher landed includes accounts whose chip is two lines below — it said "2 other accounts not shown" with both of them on screen. It now counts only accounts with a reading and no saved credential: an old login you can look at and cannot return to, which is the thing actually worth saying. On this machine that is one, jrg.conceicao@gmail.com. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d7a80782e |
switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.
ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.
anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).
One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.
What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.
Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.
.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.
Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.
The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.
Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bd9d26199f |
the ephemeral pane is open when it exists, not when an effect says so
Refresh on /files/...?view=x.m4a came back with the query string intact and no
viewer, and then double-clicking the file did nothing at all. Two symptoms, one
cause, and the second is what made it read as a click bug.
WorkspaceView gave the pane defaultSize={0} and opened it from an effect calling
panel.resize(), so open/closed was expressed twice — the markup said closed and
the effect was meant to correct it. A correction step can be missed, and this one
was: on a deep link isEphemeralOpen is true on the first render, but
workspace.isLoaded is false until /dashboards answers and the early return means
nothing is rendered, so the effect ran against a null ref and returned. Its deps
were [isEphemeralOpen, defaultBaseSize] — one already true, the other a constant
— so it never ran again. URL open, pane at zero width.
Then the inert double-click follows for free: ?view= already held that name, so
setSearchParams changed nothing, so there was no transition for the effect to
react to. The URL being already correct is exactly why the click had nothing to
say.
Now the pane is rendered conditionally with id + order — the same pair
WorkspaceRenderer already uses — and both panels take declarative sizes. There is
no moment where markup and intent disagree, and a deep link is right on first
paint rather than corrected after it. The try/catch that guarded resizing an
unlaid-out group goes with it; nothing calls resize now.
ephemeral-pane.test.tsx asserts the SIZE the group computes, not that the element
exists — a pane at zero width satisfies any is-it-in-the-document check, which is
the failure being guarded against. Five of its six tests fail against the old
implementation, including the load-order one. Full suite: 1316 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
58615ee5e7 |
/login can hand you the URL instead of opening it here
Opening the link opens it in THIS browser — already signed into whichever account you are trying to move away from. Copying it is how you authorise as somebody else: another browser profile, another browser, or your phone. This dialog exists for a machine with two Anthropic accounts on it, so that is the common case rather than the awkward one. Uses helpers/clipboard, which falls back to execCommand when navigator.clipboard is absent, and reports a failure rather than showing "Copied" over an empty clipboard. Corrected a claim in that helper's own comment while touching it: it said Officer is reached over the tailnet on plain http, as though that were the whole story. The owner browses over https and did when it was written. The http origins are the other ways in, and an install with no certificate has no other way in at all — which is why the fallback stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31cdb93691 |
usage shows the signed-in account only, and says which one it is
Phase one of answering a question the panel could not answer: whose numbers am I looking at. This machine's owner has two Anthropic accounts, personal and work, and both had stored snapshots — so both appeared as tabs. After signing in there was no way to tell from a glance which one was now being spent. The panel was answering "here is usage for two accounts" while the question was "which am I on". So the hook filters to the account marked `current`, and the absence of one now MEANS something: no current row renders as "Not signed in" rather than as whichever tab happened to be selected first. The email also gets its own line instead of sharing one with the plan. Joined as `Team · a@b.c` in a 320px popover, the email is the half that truncates — and it is the half being read. The other accounts are said out loud rather than silently dropped: "1 other account not shown". Knowing one exists and is hidden deliberately is a different thing from wondering where it went. Temporary and a one-line revert by construction: delete `onlyCurrent` in useClaudeUsage and return `all`. The server still serves every account, the tab strip is still in the file, and nothing was deleted — the stored snapshots are a real feature, they were simply drowning the question being asked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8917f36be5 |
/login works in the chat window
Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is a strange thing to ask of somebody whose entire session is a web page. It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for the person, write the code to stdin. The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them. Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against what the terminal appeared to show. Three decisions worth keeping: NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401. On the `chat` grant rather than its own. The pairing is exact: an account that may use the agent must be able to authenticate the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest for free — authorize.ts drops a confined grant for an account with no osUser, which is precisely the account that cannot hold credentials of its own. Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy, whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own Linux user, into their own home, because their turns bill their own subscription and that only holds if the credential is theirs. Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies, so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |