a6e7f64ef21d5caaff7653a179b58c8054ce57ba
1742
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a6e7f64ef2 |
todo: a commander should suggest the cheapest model for each half of itself
Wanted 2026-08-22, for later. Before arming anything, a commander weighs how hard the job actually is and proposes which model each half should run on. TWO models, which is what makes it more than a generic "use something cheaper" note. A commander's own turns are conversation plus one piece of bash; the agent its loop spawns does the real work and its difficulty is set by the job. The deploy commander is the worked example — its own turns were unremarkable on Sonnet while the spawned agent judged six hosts, parsed a plan, decided what to skip and wrote an honest report. Suggested, never imposed: the failure mode of choosing too small is not a slower answer, it is a watcher firing on the wrong condition or an agent half-finishing a deploy. Recorded with the reason it is not urgent: nothing measures whether the choice was right. A spawned agent's cost is not captured at all yet — the column exists and is always null — so a commander suggesting Haiku has no way to learn it was wrong. This wants the cost work first, or at least the knowledge that until then it is a judgement with no feedback loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f065e2c0f8 |
the disabled composer says why, instead of inviting you to type
Caught in a screenshot before any testing: the new-commander composer was correctly disabled and still read "Type a message...". `placeholder` was declared TWICE on the same textarea — the reason I added, and the original below it — and JSX takes the last one. So the gate worked and was completely invisible, which is the worst version: an input that refuses you without saying what it wants. One placeholder now, carrying the reason when disabled and the usual text otherwise. Fourth prop-shaped mistake today, and a new variant: not a prop that does not exist, but a real prop silently overwritten by a second copy of itself. A typecheck cannot see either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1c47d0bba7 |
a commander is named and given a directory before it can be spoken to
The third of the three, and the one the owner will actually see. The new-commander card now collects both
decisions above the guide, and the composer refuses input until they are made — with the reason in the
placeholder rather than a validation message after the fact.
Both are chosen first because neither is cheap afterwards. The directory decides where every relative path in
every turn resolves and which project group holds the transcripts, so changing it later strands a
conversation away from its own history. The name has to reach the transcript the moment it exists, because
Claude Code names a new session from its first prompt and the platform's rename only wins by getting there
first.
The directory is a BUTTON, not a text field: it is an absolute server path, and typing one is how a commander
ends up running somewhere that does not exist. `DirPickerModal` already existed for /chat and browses the
account's own tree.
CAUGHT BEFORE SHIPPING, and only by checking: I first passed `cwd` and `commanderName` as `useChat` options
and `disabled` as an EmbeddableChat prop. None of the three existed. It typechecked anyway, because object
SPREADS bypass excess-property checking — `...(cwd ? { cwd } : {})` is invisible to it. That is the fourth
time today a silently-ignored prop has cost something, so the props are real now:
- `commanderName` is a `useChat` option on the send payload, and rides EVERY turn rather than only the first.
The server applies it once; sending it again costs bytes. Sending it once would lose it if that turn failed
and was retried — and a commander named after its second prompt is exactly what this prevents.
- `disabled` is a real EmbeddableChat prop carrying a reason, threaded to the textarea's NATIVE disabled —
which also removes it from the tab order and blocks a paste, neither of which a guard on send would do.
- the directory goes to EmbeddableChat's existing `cwd` prop, because that is what feeds `sendPrompt`'s
`cwdParam`. Two values chosen in one place travelling by two paths, noted where somebody would otherwise
have to discover it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2bd8701b15 |
a commander runs where the owner chose, and Command HQ stops scanning one folder
Two of the three things the identity change unblocked. THE WORKING DIRECTORY. `resolveChatCwd` forced `getCommandersDir(email)` and ignored `msg.cwd` entirely — every commander shared one empty directory whose only purpose was to BE a cwd, because the cwd was how a commander was recognised. It honours the choice now, resolved through the same `resolveCwd(home, …)` containment the ordinary chat branch uses, so "client-chosen" still cannot mean "anywhere outside the account's tree". No fallback to the old shared folder: the UI will require a directory, and an absent one falls back to the account's home, which is the honest last resort rather than a silent special case. THE LIST. Command HQ found its commanders by scanning that one directory, which would now find only the ones that happen to still live there and silently hide every other. It reads its rows and loads each transcript by id — `loadClaudeSessionSummaryById`, added here — a handful of lookups rather than a directory walk. A row whose transcript is nowhere is treated as absent exactly as a missing file was. `POST /purge` had the same scan and it was the dangerous one: unfiling every commander that chose a different directory, from a housekeeping route, when the rows are what identity now depends on. Three smaller corrections that came with it: - rename and delete passed the commanders directory as their cwd argument. It is documented as a HINT that falls back to locating the transcript anywhere, so they worked — but passing a path we know is usually wrong reads as intent later. They pass `who.home` now. - `POST /command-hq/commanders/new` returned `cwd: getCommandersDir(...)` regardless of where the commander actually ran, and now accepts `cwd` and `name` like the UI will, and reports what it was given. - the router's "Where commanders live" section said every commander shares one directory and that listing is therefore a scan. Both sentences were the design being replaced. `loadClaudeSessionSummaryById` takes its mtime from `listTranscriptsAs` rather than a bare stat: the file may belong to a member, and reading a member's tree needs the as-user path. That is one scoped directory listing per commander — the same call the old whole-folder scan made, once per row instead of once for all, which is the price of letting each choose where it lives. Verified on live data: the existing commander still resolves, with its title and its real updatedAt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d08ae41f1f |
a commander can be named before it says anything
Claude Code will not accept a name for a new session — the harness names its own from the first prompt — so the platform renames the transcript the moment it exists. The owner names the commander up front, beside choosing its working directory, and never sees the rename happen. It works because of what rename actually writes. `renameClaudeSession` stores a `summary` record, which is what sets `hasExplicitTitle`, and an explicit title is never overwritten — not by the harness's `ai-title`, not by the first-message fallback. So the chosen name wins permanently rather than racing the titling. That property already existed and was documented; nothing needed adding to make it hold. Applied SERVER-side, in `fileAsCommander`, on the `session:claude` that first carries a transcript id. Not by the client, because it must not depend on a browser still being open: the name is chosen before the first message, and losing it to a closed tab would leave a commander called after whatever it happened to say first. `chatIdentity` is exported and reused rather than reconstructed. It decides the home and the OS user, and a rename writes a file as that user — a second copy of that resolution is the kind of thing that works until a member uses it. The wire field is carried on the turn rather than sent afterwards, for the same reason the plan-mode flag is: a brand-new conversation has no live session to configure and no stored row to update, so its first message is the first moment anything can be acted on. UI comes with the directory picker — both are chosen in the same place, and neither is optional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a356656a60 |
hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves whatever is newest, so machine-setup provisioning a box today would put a two-day-old release under the platform and every pm2 app except officer-pty. The owner asked for a temporary hold on 1.3. The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest 1.3.x at install time so bug fixes inside the series still land, and no 1.3.14 to rot into "the version we happened to pick" the way setup_22.x did for Node. The version reaches the installer as its documented first positional argument (a release tag), and install_bun refuses to run with an empty one: taking whatever bun.sh serves is the single outcome the pin exists to prevent, so no bun at all is the better failure. Loud, and officer-setup's pre-flight already stops on a missing bun. Resolved from the npm registry rather than GitHub's releases API, which is the obvious source and the wrong one — 60 unauthenticated requests an hour per IP, and it returned 504 twice while this was written. An empty answer installs nothing, so where it comes from is load-bearing. The section now reads four states instead of two. "Installed" was one answer covering a machine inside the pin, one behind it, and one past it; past it is the case worth catching, since that is what bun upgrade leaves and nothing else reports it. Above and below get different prose and both offer to move; nothing is corrected silently. What a setup script cannot cover: bun upgrade takes no version argument — latest stable or --canary, verified against its own --help — so it always jumps past the pin without saying so. Hence the doctor check, which reads engines.bun rather than repeating the number, because a second hardcoded copy is a second thing to drift. bun ignores engines (verified: an install declaring 1.9.x ran clean under 1.3.14), so the field is the declaration and doctor is the enforcement. Confirmed bun install --dry-run still resolves frozen with the field present and bun.lock unchanged. This host is already on 1.3.14 and inside the pin, so nothing moved here. Also fixes three more stale process counts in the same block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1c7619e184 |
the permalink goes through the router, so the open chat learns its own name
Third attempt at this bug, and the first two were fixes to the wrong component. The /chat screen renders
ChatDetailPanel, not ChatHeader — so invalidating the sessions list and correcting which id the panel records
were both real improvements to a surface that is not the one showing "New chat".
The actual cause is one line. The permalink was written with `window.history.replaceState`, which changes the
address bar and tells react-router nothing. On that screen BOTH the title and the rename control come from
`useParams().sessionId` — see the note in DetailBar about renaming the chain's head rather than the tail — so
the router kept reporting no session, the title stayed "New chat", and rename stayed absent. Navigating away
and back forced the router to re-read the location, which is why that appeared to be the cure and why the fix
looked like it belonged in a list.
Now a `navigate(..., { replace: true })`. Replace rather than push, so Back still leaves the conversation
instead of stepping through the same one under a succession of names.
Worth recording for the next one: three separate "the UI did not update" bugs today were all a URL or an id
not reaching the thing that reads it — two of them the officer-key/transcript-id pair, this one the router.
`useQueryState` still writes `history.replaceState` directly; same trap, different hook, left alone rather
than widened into this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3f1ba38f19 |
the chat panel's header names a new conversation too
The list learned the name and the panel header did not, which narrowed it to one lookup: the panel records its session id, and ChatHeader resolves the title by finding that id in the sessions LIST. The panel was recording `chat.sessionId` — OFFICER's session key — and the list is keyed on Claude's TRANSCRIPT id. Two id spaces on either side of one `find`, so it never matched: a new chat's header read "New chat" for the whole life of the conversation and offered no rename, because rename is a per-row action on a list the session was not found in. Selecting the conversation from the list fixed it, which is exactly why navigating away and back looked like the cure. Same trap as the Command HQ pane on 2026-08-21, third time this pair has caused a bug: officer's handle and the harness's transcript id are not interchangeable, and every place that keys one lookup on the other fails silently rather than loudly. The "only a REAL id, never null" rule is unchanged and still needed — both ids are null for a moment while a resumed session connects, and writing that null would turn "this panel has never chosen" into "the user asked for a new chat", so the next reload would open blank. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
577ebde902 |
the services section counts its processes instead of saying six
CORE_PROCESSES grew to seven when caldav and notify became core on 2026-08-19, and five hardcoded "six"/"6 processes" claims around it did not move. Derived from the array now, so the next core process cannot re-create the drift. Same class of rot in preflight's tool_why, which still said bun runs "nineteen of the twenty processes" — a count from the pre-plugin era, replaced with a count-free sentence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
59f600d34d |
a new chat gets its name without a refresh — this time with the import
Re-lands
|
||
|
|
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
|