03fbb1189f7ad6f43b63c9d37fac9288cd0c2665
1572
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
55ecbf4fb8 |
the handoff radio was still rendered nowhere, one layer above where it was fixed
"Exit and hand back to the agent, then re-arm" is the only fire behaviour a commander wants, and it is the
dialog's own default inside one. It was missing from the dialog in Command HQ.
The previous fix wired the consumer: isCommander={manager.context === 'command-hq'}, with a comment about
how the prop had never been passed by anybody. The producer was still not feeding it. EmbeddableChat builds
its manager from its OWN props — useEmbeddableChat(params) — and `context` there is a different value from
the `context` handed to useChat. Command HQ set the hook's and not the component's, so manager.context was
undefined, and the radio stayed hidden in the one place it exists for.
Two hooks named alike sitting on either side of that boundary is what hid it twice: hooks/useChat.ts and
EmbeddableChat/useEmbeddableChat.ts, one taking a `context` option, the other a `context` prop, neither
reading the other's.
Found by the owner trying to arm a deploy commander and finding only "stop when it fires", a one-shot,
where a watcher that re-arms was the whole point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e1ad680475 |
a commander opens in /chat, where you were already reading
Clicking one in the Live list navigated to /command-hq — out of the screen, into a different route with a different layout, having lost your place in the list you were scanning. I argued for that in the commit before this one: /chat renders the transcript without the category, cost, watcher state or the disarm control. It loses to a simpler argument. A commander IS a chat — same harness, same transcript, resumable by the same route — and being thrown out of the page you are reading costs more than metadata you did not ask for. Command HQ is where you go to MANAGE a commander, which is a separate errand from reading what it just said. Verified rather than assumed, because a commander's cwd is not one of /chat's directory groups: the default-group load misses, and GET /chat/sessions/:id falls through to the by-id scan, which returns all six messages and the real cwd with it. So the detail panel resumes it with no cwd hint at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ebfa09e067 |
live splits into Agents and Commanders, and the payload says which is which
A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it is a list where the thing you are looking for is outnumbered. Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it, which hides at zero because a standing `0` there says nothing. These two say a lot at zero: `Commanders (0)` is how you find out the watcher is not running, which is usually why you looked. The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander shares one directory, so the directory IS the answer, it costs no query, and it is already right during a commander's first turn, before any row exists. It has to be in the payload rather than computed in the panel, and that is the point of the change: the iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name them. A deploy commander appearing in that swipe order is precisely what must not happen. A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to disarm it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6dae3e7a14 |
a commander's transcript was fetched, returned, and then ignored
The pane still showed "Send a message to start" over a bar reading 1 turn · $0.28. The id fix was necessary and was not the cause. useChat seeds its message list with useState(initialMessages ?? []) — read on the first render and never again. ExistingCommander fetched the transcript AND called useChat in one component body, so the hook's first render happened with resume still undefined and seeded itself empty. The messages landed a moment later and the hook had no reason to look. The early `Loading…` return does not help: hooks run above it. ChatPanelWrapper has always avoided this, and the line that does it is easy to read as a loading nicety — `if (selectedSessionId && resuming) return null` sits ABOVE the component that owns the hook, so the hook's first render is its only render, and it has real data. Split the same way here: a fetcher, and a chat mounted once there is a transcript to mount it with. Verified server-side while looking for this: loadClaudeSessionById returns the six messages for 172cdd8c, ending on the commander's answer. The data was always there and always reachable. Only the pane could not see it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2379260ffe |
the commander pane threw its own conversation away at the end of the first turn
The messages were on screen, streaming, correct. Then the turn ended and the pane went empty under a metadata bar still reading 1 turn · $0.66. Nothing was lost — the pane discarded it. Once the transcript id was known, NewCommander navigated onto it, and CommandHqDetail answered that URL with a DIFFERENT component: ExistingCommander, keyed on the id. So React unmounted the live chat, closed its socket, and re-read the transcript from the API — a file the harness had only just finished writing. What survived was whatever that fetch returned, which was nothing. A permalink is for a refresh, not an instruction to reload. /chat has always understood that: useChat replaces the URL and remounts nothing. This pane replaced the URL and remounted everything. So the pane now keeps the session it started. CommandHqDetail remembers which id came from its own composer and keeps that component mounted while the URL names it, swapping to the resuming component only for a commander opened from the list. Clearing the memory on a real navigation means coming back later still resumes from the transcript rather than remounting an empty live chat. Worse than the empty pane, and the reason this is not cosmetic: the teardown fired at session:claude for any chat where the harness names its transcript early — the START of a turn. A commander's turns run for tens of minutes. The pane would have dropped the stream and shown a reader nothing at all while a deploy rolled across seven servers. claudeSessionId is now also set at session:claude, not only at result, matching the ref beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
21b893473d |
a new commander showed "Send a message to start" after answering correctly
The first deploy commander ran, cost $0.66, and reported exactly the right thing — and the pane showed an empty state, because the page was fetching an id nothing knows. Two ids for one commander. `session:init` sets `sessionId` to OFFICER's key and arrives first; `session:claude` carries Claude's transcript id and arrives later. `fileAsCommander` files the row under the transcript id, and `command_hq_sessions` is keyed on it. But NewCommander navigated on `chat.sessionId` — the first one it could see — and then set `filed`, which blocked the correction when the real id turned up. So the URL held officer's key, /api/chat/sessions/<that> 404'd twice, and the transcript was unreachable from the UI while sitting perfectly readable on disk. The transcript id was only ever in a REF, deliberately — the reconnect handshake reads it synchronously — so writing it re-rendered nothing and no caller could react to it. It is now state as well, returned alongside sessionId, with the ref kept for the handshake that needs it. setCategory had the same fault and would have silently updated no row: it was keyed on officer's key against a table keyed on the transcript id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
af78f0ed51 |
sessions carry isCommander, so the phone can filter what tabs would hide
A commander IS a Claude session and this list reads transcripts from disk, so nothing distinguished the two: commanders appeared in the chat list looking like conversations somebody started and forgot. The flag is in the PAYLOAD rather than in the web UI, and that is the owner's constraint rather than an implementation preference. The iPhone chat app swipes left and right between live chats and has no tabs to hide behind, so it has to filter commanders out of that gesture itself. A flag only the web knew about would fix one surface and leave the other wrong — which is the shape of half the bugs found tonight. Derived from the CWD, not from a join against command_hq_sessions. Every commander a user has shares one directory — getCommandersDir(email), never per-commander and never client-chosen — and this list already reads each session's cwd. So it costs one string comparison, adds no query, and still works for a commander whose database row was lost. Compared after resolve(), because the cwd comes from a transcript rather than from code and records whatever directory the process was actually in. Verified against this host: 8 sessions, 0 flagged, and that is right — the commanders directory exists, no transcript has that cwd, and Command HQ shows 0 commanders. Still to do: the two live tabs in the web list, Agents (n) and Commanders (n), with live counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7a46612f41 |
the handoff watcher was built and never rendered
BackgroundJobDialog offers three fire behaviours and gates the third, `handoff` — exit, act, re-arm — on
an `isCommander` prop. Nothing ever passed it. So it defaulted to false everywhere and the option
appeared NOWHERE, including inside a Command HQ commander, where the dialog's own code makes it the
default.
Everything else was there: the fire rule, `handoffContract`, `armWatcher` reaching the server, and a note
recording that it had run for hours self-re-arming without failure. Only the radio button was missing,
which made a working mechanism look unbuilt — and it is the one a deploy watcher needs, since
`keep-going` never hands back to an agent at all.
Found while telling the owner how to start the deploy commander: the dialog in a commander showed two
options where I had said there would be three.
The fix is two lines and no new concept. `useEmbeddableChat` already receives `context` and passed it
into `useChat`; it now also returns it, and InputArea gives the dialog `isCommander={context ===
'command-hq'}`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8d476129ea |
an answer that goes nowhere now says so, and one that lands says that too
Second half of the AskUserQuestion report: "when I answer, the agent does not react — I see the bubble for a long time and end up sending a message anyway", intermittently and not reproducible. Two different causes, both invisible from the outside. A DROPPED answer was silent at every step. user-instance discarded the boolean, sendFire does nothing when no sidecar is connected, and the lookup returns undefined for both "no such session" and "not yours". So a lost answer and a slow agent looked identical: a card that does not change. It now warns with the reason, and where a session exists it emits an error into the durable log — the same channel a successful answer uses, so a second pane sees it too, rather than confirming to the browser that clicked. A LANDED answer had the opposite problem. The card goes read-only the moment you pick, but `sent` is local and optimistic while `message.output` only arrives once the harness has produced something. In between there is a real gap, and nothing filled it — so an answered card looked exactly like one that had timed out. It now says "Answer sent — waiting for the agent", which makes the two states different without pretending to know when the agent will speak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a215dcfee |
a turn waiting on a person is not a stalled turn
Reported as: leave an AskUserQuestion unanswered for a while and the chat becomes unusable — refresh, send another message, the agent is stuck. armStall knew about compaction and about background tasks but not about a pending gate. Parking on a question or a plan means emitting nothing until somebody answers; that is what parking IS. So the watchdog could not tell it from a wedged turn, and at 10 minutes against gates that wait 20, it always won: 10m the turn is declared dead, isGenerating goes false, an error is emitted, the session is torn down 20m the gate's own timeout would have resolved it truthfully — never reached That left a ten-minute window to answer. Miss it and the card is still on screen with nothing behind it, and clicking an answer does nothing at all: an answer's only receipt is the tool:result the gate emits when it unblocks, and the gate is gone. From the outside that is precisely "unusable, refresh, send another message". A session with an outstanding question or plan is now left alone by the watchdog. Not unbounded — each gate holds its own 20-minute timer and resolves with a sentence written for the agent — and the timer is re-armed on both resolve paths, the answer and the timeout, so a turn that genuinely wedges AFTER a question is still caught. armStall skips itself while any gate remains, so answering one of two questions keeps the reprieve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ff7358188d |
sub-agents: a spawn cannot take the last of a window
The failure this prevents is specific — a sub-agent burns the end of a limit window and the session the owner is actually sitting in dies mid-turn, because the credits went somewhere nobody was watching. So it is a condition on spawning rather than a refinement: an endpoint that can start unattended work without checking is wrong from its first call, and the symptom lands on someone else's conversation. Wired into POST /command-hq/commanders/new, which — this is the finding — already IS the spawn mechanism the sub-agents item was asking for. It starts a turn with ws: null, waits only for session:claude, and returns the transcript id, with command_hq_sessions naming, listing and costing it. So "commander or something lighter" answers itself: a commander costs nothing extra and a lighter thing would need its own registry to be visible at all. What is still missing is only the agent-callable surface. Refuses rather than queues or downgrades. Queueing means holding work against a window Officer does not control, and the reset it would wait for is a claim about a shared pool — every claude on the machine spends these, including terminals and pipelines. A refusal naming the window, the percentage and the reset is something a caller can act on now. The floor is 15% and the reasoning is in the file: the interactive failure is not "cannot start a chat", it is "a turn already in flight is cut off", and a turn costs a fraction of a window. Fails OPEN, deliberately. The numbers come from an external endpoint with a credential Officer does not own, so a blip or a signed-out CLI must not stop every spawn on the machine with an error about usage that has nothing to do with usage. The problem is reported so a caller can say "started, but unchecked". Seven tests on the pure rule — tightest window decides rather than the average, no limits allowed rather than blocked, exactly-at-the-ceiling refuses. Verified against the live account, where it correctly refuses right now at Session 90%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
03abdeb246 |
an Update button, so a server is not a terminal away from current
Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every time. Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same reason: this checkout is somebody's working copy on at least one machine, and a button that discards uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a checkout made before the default branch was renamed is on one the remote no longer has — the trap plugins/update.ts already hit, and it bites hardest on the installs most in need of updating. It deliberately does not chain into anything. A pull touching bun.lock means an install, which the frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it worked" is less useful than knowing what arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4e2db3dc2f |
themes: generate all 22 from Omarchy, with contrast as a test rather than a taste
The eleven ported themes declared 34 of an 84-token contract. The missing fifty were every surface
(--panel-bg, --card-bg, --glass-bg), every state colour (--success, --warning, --busy, --info), the
header, the scrollbars and the grid — so they fell back to whatever the base stylesheet or the
previously applied theme had left behind. They also carried thirteen tokens nothing in this codebase
reads (--chart-1..5, --sidebar-*), which is the tell: they came from a shadcn template, not from the
contract in the README beside them.
So "highlights mask the content" and "text fades into the background" were not taste. They were
absence, and no amount of re-picking colours would have fixed them.
Generated now, from Omarchy 4's colors.toml, which is genuinely semantic — three background depths,
four foreground weights, accent, selection, muted, mode — and consistent: all 24 keys present in all 22
themes, checked. One mapping in scripts/themes/map.ts means one place to be wrong, one place to fix,
and an Omarchy update becomes a re-run.
Every foreground is driven through ensureContrast against the surface it actually sits on, and the
generator then re-checks sixteen pairs per theme and EXITS NONZERO on a failure. It found four real
bugs in my own mapping, which is the argument for having written it first:
- `on(fill)` picked the better of two palette extremes without checking either passed; rose-pine's
tones are all mid and came out between 2.2:1 and 4.2:1
- ensureContrast chose black or white by a luminance>0.5 test rather than by measuring. rose-pine's
rose sits at 0.25, so it drove toward white and capped at 3.5:1 where black reaches 6:1 easily
- --muted-foreground was derived against the raised surface but renders on the high one; everforest
landed at 4.44:1 and miasma at 4.00 — near-misses that look fine in a screenshot
- borders were held to 3:1, which would ring every panel in a hard line. A panel edge is decoration;
what must be discernible is a form field's boundary, so --input is checked and --border is not
35 failures at first run, 0 now.
The registry and the stylesheet imports are written from the same list, because the old port maintained
them by hand and drifted — catppuccin-mocha sat in THEMES after its stylesheet was gone. That file is
now deleted; Omarchy renamed it to catppuccin.
Eleven new themes arrive with this: ethereal, flexoki-light, hackerman, last-horizon, lumon, lupine,
miasma, retro-82, solitude, vantablack, white. Duck and Plain are Officer's own and are never generated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a2cca526f9 |
spotlight opens on shift-space, and stands down inside a text field
ctrl-space focuses the address bar in Brave, so it never reached the app. shift-space is a real combination inside a text field — it types a space, and people hold shift through one more often than they think while typing capitals. So the trigger is ignored when focus is in an input, a textarea or anything contenteditable, and the space is typed as normal. The cost is worth stating rather than discovering: focus usually IS in the chat composer, so opening Spotlight from there means clicking out first. If that turns out to be the common case, a double-tap of shift is the escape hatch — it conflicts with nothing anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
81f32a1faa |
spotlight: ctrl-space, type a few letters, go
The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you actually use rather than a complete one — which is the other half of this and is not done yet. It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed, permission-filtered answer to "what can this account open here": the shell's screens, plus whatever installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited. Settings sections are added, because they are what people hunt for and the dock deliberately does not carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings screens to the shell to save five lines. Matching is subsequence, not substring, and lives in its own file so it can be tested with strings: "chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests cover the ORDER rather than the matching, because returning the right set is easy and returning it with the right thing first is the product. ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not cmd-k either — that belongs to the browser on some platforms and to text fields on others. Mounted beside the header and the dock rather than inside the content region, so it still gets you off a screen that is maximised, erroring or mid-navigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d043e6650a |
name a published extension for itself, not "extension.zip"
extension.zip says nothing once it is in a Downloads folder — not what it is, not which server it came from, and not which of two it is. The name comes from the extension's own manifest.json, which already carries what a browser will show: "Officer Browser Relay" becomes officer-browser-relay.zip. Generic, since extensions are meant to be a marketplace of them: any plugin shipping extension/ gets a file named after its own manifest, falling back to <appName>-extension.zip when that cannot be read — still better than extension.zip, and it never throws, because a broken extension manifest should fail the install for its own reason rather than because a file could not be named. Uninstall removes both names. An install from earlier today published extension.zip, and leaving that behind would serve a stale extension for a plugin that is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfb2f98ccb |
plugins can ship a browser extension, published unauthenticated
The browser relay is the first extension, not the last, so `extension/` is now a directory any plugin may ship — like `assets/` — and the installer zips it to public/plugins/<name>/extension.zip, served by the existing /plugins/* wildcard with no auth. Unauthenticated deliberately. An `<a download>` is a plain navigation with no Bearer header, so an authenticated URL makes Chrome say "Try to sign in to the site. Then, download again" — advice that cannot work, because signing in is not what is missing. Nothing in an extension is secret; the pairing token is, it is entered afterwards, and it is not in the archive. Built at install rather than committed. The file it replaces was hand-built and had drifted 58 lines behind its source, serving an extension missing the multi-session recovery path. zip.ts writes stored entries with fixed timestamps — no host `zip` binary, and identical bytes for an unchanged directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
45d4823238 |
browser pairing and its extension zip leave the platform
The last two pieces the extraction missed. IntegrationsSettings/BrowserRelay.tsx drove the plugin's pairing from a platform screen, calling /browser/relay-token and working only because the plugin happens to serve that path — the same coupling removed everywhere else this morning. public/browser-relay-extension.zip was a hand-built snapshot that had drifted 58 lines behind the extension it claimed to be: built in August from something predating a February commit, never rebuilt, and missing the multi-session recovery path. The plugin builds the archive from its own directory per request now, so there is nothing left to fall behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e44c0c7665 |
the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history, and nothing regressed, because none of it had been running. What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so the registry stops describing one thing in the other's words, and it drops the /browser frontend route it could no longer serve. Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering panels through appType placeholders — a plugin supplies its own route and panels through Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no longer had: dead at runtime, invisible to the compiler. The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where it lives now. It was written for whoever did this extraction, and this is that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a1f1ce6df |
publish desktop, and stop counting node_modules as a plugin
The catalogue row and the bundled offline fallback, so the plugin installs from the marketplace like every other. Listed honestly: its permission is owner-only at every path, and its websocket is not mounted yet — the README and BLOCKERS.md say so rather than letting an install discover it. marketplace-sync globs plugins/ itself rather than going through discoverPlugins, so the node_modules skip added there did not cover it. Same fix, second place: it was reporting 15/16 and 16/17. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fcdbf02649 |
the platform stops knowing what VNC is
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem. sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four functions and a dozen message types in the platform, describing a feature that is not the platform's — the rule CLAUDE.md states most plainly, and its clearest breach. What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever it likes through them and nothing here has to be edited when it adds a message. The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind a plugin permission cannot have and never will, because a permission nobody can delegate is a contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the key from its list. Also gone: the app store's `vnc` entry, which would have offered an older second way to install the same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13. The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request". It now uses the generic door, which is the better instrument — that file is about the blast radius of a disconnect and never had anything to do with VNC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b1a2d67b76 |
plugins: node_modules is not a broken plugin
The shared install target for plugin npm packages lives in plugins/ because bun resolves node_modules up the tree. Discovery then found it, failed to read a manifest, and reported it as broken on every call — a warning nobody can act on, and a denominator that made the marketplace sync report 15/16. Mine, from the npm-dependencies work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4efc2552d0 |
os-deps: a dependency declares who has to be able to run it
The probe asked the owner's login shell where a binary was, and the install reported that as a fact about the machine. For a confined plugin the work happens as the member's own Linux user, in their own home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable. On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an absolute path resolved. At 750, the more usual default, every member would have lost every binary with nothing reporting it: the install would still have said satisfied, having only ever asked the owner. Nobody chose 751, and that is the part worth fixing. `scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a manifest error rather than a silent fall back to the permissive one. Shared changes three things. It is searched for on system directories only — asking the owner's shell would find the copy in the owner's home and call it satisfied, the exact false pass this exists to stop. Reachability is checked as world-executable AND every parent traversable to the root, because a binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so the download itself never runs as root. An unreachable shared dependency gets its own state and refuses the install ahead of every other check, because no package manager fixes it: the binary is installed and works, and what is wrong is where it lives — reinstalling would report success and change nothing. Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's data is gated; its executable is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
87603b800b |
os-deps: ask the owner's shell where a binary is, instead of guessing directories
`Bun.which` reads the PATH this process started with, and under PM2 that is minimal — a release binary fetched during an install was reported missing seconds later by the same install. The fix was prepending ~/.local/bin here. That is guessing at somebody's environment from inside a crippled one, and guessing does not converge. rustup puts cargo in ~/.cargo/bin, nvm puts node elsewhere, and every per-user installer adds a directory nobody listed. The iris plugin duly wrote its own copy of the function with ~/.cargo/bin bolted on, which then disagreed with this one: the manifest probe refused a rustup-only machine that the sidecar could build on perfectly well. There is nothing to guess. The platform runs as the owner, plugins are installed by the owner, and the owner has a shell whose rc files already put all of it on PATH — that is what those files are for. So ask it: `$SHELL -lc 'command -v <binary>'`, with the shell taken from passwd rather than process.env.SHELL, which under PM2 is whatever started the daemon. sessions.mjs already draws that distinction for terminals; this is the same fact applied to dependency probing. A login shell because ~/.cargo/env and friends are sourced from profile files an interactive-only shell may skip. `command -v` because it is a POSIX builtin whatever the shell is. Two-second timeout so an rc file that waits for input cannot hang an install. Bun.which stays as the fallback, with ~/.local/bin prepended there and only there, for an account with no usable shell. Exported as locateBinary so a plugin's sidecar asks the same question the install did, rather than writing a second answer. Verified on this machine: the login shell resolves ~/.local/bin and ~/.opencode/bin, neither of which is on this process's PATH, and finds a binary Bun.which cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
43aeffd9d3 |
api keys: revoked ones stop filling the screen, and can be forgotten
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a list whose job is "which of my keys still work" answered with a wall of dead ones. Nobody decided that. Revoke was implemented and the list was never taught the difference. Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys" toggle rather than merely dimmed, because on any account in use for a while they outnumber the live ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a bug in the list. DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not null` clause is in the statement rather than checked before it, so there is no ordering in which a live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key cannot be honoured again either way, since authentication matches a hash and a deleted row has none. What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it was killed — which is why it stays a second, deliberate step rather than what revoke does. Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c4042886fc |
api keys live in profile, not integrations
Reported by the owner, who went looking for them in user management and then in profile, and found them in neither. They were under Integrations. A key is not an integration with anything. It is a second way to present the authority the account already has, and the permission registry already says exactly that: `/api-keys` is `core`, sitting beside `/user` and `/dock`, with the comment "a key is not new authority". Profile, next to the password it stands in for, is where somebody looks. Moved rather than duplicated, and the route moves with it — /settings/profile/api-keys. Nothing linked to the old path, so there is no redirect to keep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
10feb76880 |
permissions: a plugin can mark part of its surface owner-only
A plugin worth granting to a Member can also hold the credential the rest of it runs on. Jellyfin's /_config stores a server's admin token and drives account provisioning; the rest of the plugin is exactly what a Member should have. The choice was all of it or none of it. `ownerOnly` on a plugin permission takes sub-paths relative to the mount prefix. The permission stays fully grantable — this is not the ownerOnly PERMISSION that manifest.ts refuses, and that refusal still stands: a permission nobody can delegate is a contradiction. This says a few paths administer the plugin rather than use it. Declared by the plugin, enforced by the platform. A plugin could check this itself and the first one would; the tenth would add a route and forget, with no boot failure and no warning — the shape assertPermissionTotality exists to prevent. In the gate it is enforced once, on the door they all pass through, and a route added later by someone who never read the manifest is covered by default. Checked before selfService and before kind. A path that is somehow both self-service and owner-only is a contradiction, and the safe reading has to win. A write grant does not unlock it either — level is not the question this answers. Also forwards X-Officer-Owner and X-Officer-Level to a sidecar whose proxy names its permission key, for the finer decisions the platform cannot make for it: whose rows a read returns, which fields a level may see. Facts, never the role name — a plugin branching on "Super Admin" would give every plugin repository an opinion about the platform's role vocabulary, and renaming a role would change authorization in code the platform does not own. These are safe to trust only because the proxy copies a three-item allowlist off the inbound request; that is now written where it would be changed. The owner's level is reported as write rather than the absent 'none': they hold everything implicitly and appear in no grants map, so the raw lookup inverts the meaning for the one account that can do anything. No plugin declares one yet. Jellyfin cannot until connections stop being per-user — locking /_config today leaves a Member with a plugin that only shows a connect form they may not submit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
da30d1a69e |
plugins: a reinstalled plugin was still being read from the deleted one
Delete jellyfin, install it again at a newer commit, connect a server — and no accounts were created,
no mapping rows written, and no error anywhere. Every step reported success.
The new checkout writes manifest.ts at exactly the path the old one occupied, and `await import(path)`
returns the module it already has: ESM caches by resolved specifier, and the specifier did not change.
So the platform kept obeying the manifest of a plugin that was no longer on disk. That manifest
predates `userProvisioning`, so the plugin was judged not entitled to the account list, the sidecar
was sent nothing, and provisioning skipped with "the platform has not sent the account list yet" —
a report that goes into the response body nobody reads.
It began working four minutes later for a reason that had nothing to do with the plugin: officer was
restarted for an unrelated deploy, which emptied the module registry.
`importFresh` keys the import on the file's mtime, so a file that changed is evaluated again and one
that did not is still a cache hit — `discoverPlugins` runs on every sidecar registration, every mount
and every plugins page load, so a Date.now() key would leak a module instance each time.
Used for the manifest, the api router and the db schema. For manifests it is complete: every manifest
imports one thing, `import type { PluginManifest }`, which is erased before it runs, so they are leaf
modules. For the other two it narrows the window rather than closing it — only the named file is
re-read, and its own static imports come back from the cache. A restart is still the only thing that
guarantees a plugin's whole module graph is the one on disk, and that is written down in the file
rather than left to be rediscovered.
The test pins the failure and not the mechanism: one case proves a plain import keeps serving a
manifest that is no longer on disk, the next proves importFresh sees the replacement, and a third
proves an unchanged file is evaluated once. mtimes are set explicitly, because two writes inside one
millisecond would otherwise decide the result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
16dd67a3ca |
plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it stays off the more certainly it is stale, and enabling would quietly run whatever was current on the day it was switched off — with every step reporting success, which is exactly how the jellyfin install on alpha re-applied a two-day-old checkout. Deleting no longer has that problem, since it takes the directory with it and installing is always a fresh clone. That left disable/enable as the one remaining way to run old code without being told. The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty working tree — every plugin directory on this machine is one — an unreachable forge, or a directory copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that will not turn back on because a network is down is worse than one running last week's code. So the refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails falls back to the code already on disk, which was running before it was disabled. Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back having reported "not refreshed", and the error that surfaces is about not being installed rather than about git — which is only possible if the refusal was stepped past instead of returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
287cec39d0 |
plugins: enable, disable, delete — and uninstall is gone
Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still- there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled plugin kept its directory, which silently decided what the next install would run. That is how a jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at every step. So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming permissions that are about to stop existing. Delete is offered only on a plugin that is not running — disable is one reversible click, so a live plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also delete all its data", unticked and never inherited from last time. Unticked, the directory goes and the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go with it, which is the only way to a machine that has never had the plugin. The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by anything that already knows how to call the safe verb, and the difference here is somebody's rows. Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant naming a key the platform no longer recognises made the whole permissions screen unsaveable, because the page submits every grant it loaded. Also corrected two rationales that the change made false — the runbook's "uninstall never destroys data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d6aef4cb92 |
plugins: delete is the second verb, and the list says what it is
Uninstalling a plugin left it under a heading that said "Installed", with a caption saying "not installed". It never moved, so a successful uninstall read as a failed one — and nothing on screen said the directory was still sitting there deciding what a reinstall would run. That mattered more than the wording. Install skips the clone when the directory exists (install.ts findPlugin, fetch.ts refusing an existing dir) and never pulls, so reinstalling jellyfin on alpha silently re-applied a checkout from two days earlier. The provisioning code was not on the machine, jellyfin_accounts was never created, and connecting a server did nothing at all — with no error anywhere, because every step it did run succeeded. Two changes, both UI: - The heading is "On this machine", which is what the list is: every plugin DIRECTORY, installed or not. Delete is what moves a plugin back to the marketplace, and now the screen shows why. - Delete is gated on being uninstalled and is a button rather than a small underlined link. It was previously offered in every state, so two clicks separated a running plugin from its tables being dropped. Uninstall, then delete — the order the buttons now teach. Still true and not addressed here: uninstall followed by install reuses the existing directory as it stands. The detail pane says so now; making install fast-forward a clean checkout is a separate call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e37b1a151c |
usernames are required, and unique case-insensitively
two platform fixes that make a plugin's job smaller, both from the owner: downstream services identify a person by name, so the platform has to be the one holding that name to a single meaning. `getUserByUsername` compared with `eq`, so `diogo` and `DIOGO` were two accounts. create-user.ts asks "is this taken?" through that function, which means the check could not see its own collision. it compares on `lower()` now. and the constraint is on `lower(username)`, not the column — a plain unique index is what allowed both to exist. it sits in the schema rather than only in create-user.ts so a second code path, a migration, or someone at a psql prompt cannot get around it. the column is NOT NULL. `validateUsername` has always demanded one, but the column allowed null, so anything skipping validation could write a user nobody could name — and a Linux account or a Jellyfin login then has nothing to work with. both databases were checked before the change: zero null usernames, zero case-collisions on either, so no migration was needed. proved rather than assumed, inside a transaction that was rolled back on the live database: `PROBEUSER` refused against `probeuser` by uq_users_username_lower, and a null username refused by the not-null. no rows written. this also removes a case the jellyfin reconcile had to carry: an officer account with no username could not be matched or created there, because jellyfin authenticates by name and has no email login. |
||
|
|
a6e61d0c26 |
user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich next — needs to know who the platform's users are. it now gets told. DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles` arrive on an unauthenticated socket and are believed, so a capability claimed there would be a plugin granting itself one. the manifest was reviewed when the owner installed it, and sits beside `permissions` where "this plugin will be told who your users are" belongs — on the install screen, not discovered later. PUSHED, not requested. a sidecar has no authenticated way to ask, so a request could only be answered, never authorised. a push inverts that: the platform decides who gets it, and a caller registering under a name with no such manifest gets nothing. `listProvisionableUsers` is a hand-written projection — id, email, username, name, role — and NOT `select *`. `users` holds `password` and `os_user`, and `getUsers()` returns both, so a plugin reaching for the obvious function would get every account's password hash. naming the columns means adding a secret to the table cannot silently add it to what plugins receive. pinned by a test that asserts the key set, not just the absence of today's secrets. the owner is excluded. they bypass every permission check and already have whatever upstream account they want; provisioning them is at best a no-op and at worst a second admin nobody asked for. sent after the ack rather than inside it, so registration never waits on a database query — a slow postgres would otherwise stall every sidecar's startup, and none of them need the list to start. and re-sent on every account create and delete, because the registration copy is a snapshot: a plugin still holding a deleted user would keep an upstream login alive for someone the platform has removed. read once per broadcast, so two plugins cannot be told different things about the same moment. |
||
|
|
c336042b12 |
remove a dangling export, and write down the lockfile question
`pluginPackageJsonExists` was exported for an uninstall hook that was never wired. an export with no caller is an unfinished thought pretending to be a feature, so it is gone — and the behaviour it would have served is right anyway: uninstall KEEPS the plugin directory, so its packages are still declared and still wanted. only "delete and all its data" removes the directory, after which the next install regenerates the merged file without it. the packages linger until then and go on their own. and the thing the owner asked to come back to: CLAUDE.md's frozen-lockfile rule reads as universal, and npmDependencies does not follow it. ranges resolving per machine is what lets two plugins share one sharp, and it was chosen knowingly — but the codebase now holds two postures on one risk with only one of them written down. recorded in TODO.md with the three specific things worth settling rather than left as a feeling that something is inconsistent. |
||
|
|
21bedf1418 |
npmDependencies in the manifest, installed once for every plugin
replaces the per-plugin package.json from earlier tonight. the owner's call, and the reason is one i had not weighed: sharp is ~50 MB of platform binaries and the obvious dependency for anything touching images, so a second and third plugin wanting it would have meant a second and third copy with no version they could converge on. so every plugin's `npmDependencies` merge into ONE generated plugins/package.json, installed into plugins/node_modules/. bun resolves node_modules up the tree, so that directory serves every plugin and none of the platform's own code. ranges intersect rather than alternate. two plugins asking for ^0.33.5 and ^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved version must satisfy both. `||` would have meant "either", which is the silent version of this failure: one plugin quietly getting a version the other cannot import. incompatible requirements now fail the install with bun's own message, and both askers are named in the steps either way. generated from the plugins ON DISK, like generatePluginSchemas, and never edited — so a package cannot appear there without a plugin having declared it. what this gives up, knowingly: a committed lockfile. ranges are the whole point and a range resolves per machine, so plugin packages are resolved at install time like osDependencies are, and unlike the platform's own. CLAUDE.md is emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest type rather than left to be discovered. the generated plugins/package.json and lockfile are gitignored — /plugins/*/ only ignores directories, so files at that level needed naming explicitly. |