aebd6ff0c02eb7afe982e9a50956b9dd28997a39
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aebd6ff0c0 |
jellyfin, extracted into plugins/jellyfin — the platform half
Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Also closes the same data hazard — jellyfin_servers was absent from the drizzle barrel, so bun db:push would have dropped it. No frontend call site needed repointing: mountPrefix derives /jellyfin from the appName, which is what the router already hardcoded. Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin broke 18 tests, the FOURTH extraction in a row to break the same files — photos and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it pins planSteps and runInstall, which are installer logic, and a service leaving for plugins/ says nothing about whether the installer still plans the right steps. catalogue.test.ts stays the place real entries are checked well-formed. members/catalogue/preflight re-anchored to memos, which has jellyfin's exact shape. The .prettierignore rule for the vendored hls.mjs moved into the plugin rather than being left behind naming a deleted path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b196c9cb55 |
soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c4b262dc51 |
provision-user-dirs script now sets modes, by calling the real provisioner
The script imported DATA_PATH and USER_DIRS from data-path.ts and then reimplemented the mkdir loop without the chmod that module's own provisionUserDirs applies — 711 on the account root, 700 on each subdir. A hand-provisioned account would have come out 755 at the default umask, where a UI-provisioned one is closed. Its header comment warned about exactly this drift while being an instance of it. Nothing was ever created that way here: every account on this box was made by the create-account handler and is correctly 711/700. This is a latent divergence between two paths that claim to produce identical accounts, not damage to repair. Calls provisionUserDirs(email) instead. Two consequences worth naming: re-running now re-asserts modes on existing directories, so it is also the repair; and DRY_RUN skips the call outright rather than gaining a dry-run mode in the real provisioning path to serve a script. Verified: dry run creates nothing, a fresh root comes out 711/700, and a directory chmod'd to 755 is restored on the next run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a4b96daf8 |
fix question options rendering permanently disabled
AskUserQuestion has no answer channel: it resolves itself the moment it is called, returning "The user did not answer the questions." before the buttons have painted. QuestionActivity gated its options on `message.output === undefined`, so that self-resolve disabled every option on every question, always. Reported from real use — muted text, no-entry cursor, nothing clickable. The output means the tool call is over, which is true and irrelevant. The answer travels back as an ordinary chat message. `sent` is the only real latch. The test was the worse half: it asserted the greyed-out state was correct, using the literal self-resolve string as its fixture. Code and test were wrong in the same direction, so a green suite sat on top of a feature that had never once worked. Inverted it, and verified it fails against the old logic rather than just passing against the new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8028364f1 |
transmission, extracted into plugins/transmission — the platform half
Sixth extraction, straight after photos. Everything transmission now lives at gitea.officer.dev/plugins/transmission. Deleted, not commented: the sidecar, the api router, the Transmission app (28 files including detail/ and dialogs/), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, the hono.ts import and mount comments, and the app-store catalogue entry. No database change: transmission owns no tables. Its connection lives in service_connections, a core table it now depends on from outside — a real coupling, recorded in the plugin's own PLUGIN.md rather than left to be discovered. ── Test fixtures, repointed rather than deleted ── Transmission was the canonical `members: 'none'` fixture — a single-tenant daemon with no user concept — across three app-store test files. slskd has the identical shape, so the assertions moved there. Deleting them would have quietly dropped coverage of the no-members install path along with the plugin, which is the same trap the vault extraction hit with `members: 'invite'`. ── Vocabulary swept, per the runbook ── Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as the worked example of the Workspace/Panel shape. Both are plugins now, so those pointers dangled at code no longer in the tree. Repointed at /command-hq and /soulseek, which are core. bunx tsgo clean. 1080 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a327dfc82c |
photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration already server-side, frontend already panels. Everything photos now lives at gitea.officer.dev/plugins/photos. Deleted, not commented: the sidecar, the api router, the db directory, the Photos app (25 files), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, both database barrels, the hono.ts import comment, and the app-store catalogue entry. The catalogue deletion is required rather than tidying — the trap the runbook records as having bitten twice. permissionAvailability() derives from sidecar_installs, which a plugin never gets a row in, so a leftover entry makes the permission permanently 'unavailable' and withholds the route and dock tile on a server where the plugin is installed and working. ── Test fixtures, repointed rather than deleted ── Photos was the canonical app-store fixture across four test files, chosen for being existing|provisioned with members: 'accounts'. Jellyfin has the identical shape, so the assertions moved there — they are about the installer, not about photos, and deleting them would have quietly dropped coverage of the provisioned install path along with the plugin. One loss worth naming: Immich's `provisioned` install mode went with the catalogue entry. Docker provisioning for plugins does not exist yet, so photos is point-at-your-own-instance until it does. bunx tsgo clean. 1080 pass / 0 fail, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7819eff6a1 |
chat: render AskUserQuestion, instead of dropping it as raw JSON
The model has been asking questions in Officer all along. None of them ever rendered. MessageBubble dispatched the interactive question UI on `toolName === 'question'` — OpenCode's name for it. Claude Code's tool is `AskUserQuestion`, so every question from a Claude session fell through to the generic ToolActivity and printed its input as JSON in a bubble. Nothing threw, nothing logged, and the payload was arriving correctly the whole time, so it read as "the model never asks" rather than as a bug. Both harnesses send the same shape under different names; only the name was ever wrong. ── What this deliberately does NOT do ── It does not make the agent wait. Checked against the installed SDK rather than assumed: AskUserQuestion is implemented inside the bundled cli.js (with its own "No answer provided" fallback), appears nowhere in sdk.d.ts, and there is no control method or hook that can return a tool RESULT. canUseTool and PreToolUse can only allow or deny, and canUseTool is shadowed entirely under bypassPermissions, which is how Officer runs. So the tool self-resolves as unanswered the moment it is called, whatever the client does. The two ways to pretend otherwise — forging a tool result, or smuggling the choice through a denial reason — both lie to the model about what happened. Neither is here. The question is rendered, and the answer goes back as the next message, exactly what a person would have typed. The tool contract is untouched. The cost is real and stated in the file: the agent will usually have carried on and reads the answer a beat late. The day the SDK grows an answer channel, this component is the piece that already knows how to ask. ── Also fixed while in there ── State was single-valued and shared across questions, but the schema allows up to four. With more than one, the first click sent a partial answer and locked the rest. Now keyed by question index: nothing is sent until every question has an answer, and a multi-question reply is labelled by header so it cannot be ambiguous. Multi-select joins with commas, which is what the tool's own output schema documents. `multiSelect` (Claude Code) and `multiple` (OpenCode) are normalised in one place rather than at each read site. 6 tests pinning both harness shapes, the multi-question rule, the client-side "Other" escape hatch (both schemas tell the model not to offer one, because providing it is the client's job), and that an already-resolved question is read-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c13271a631 |
runbook: record the hand-rolled-proxy bodyParser trap
Cost an afternoon on the vault extraction and presented as far as possible from
its cause: every POST through /api/vault answered "sidecar unreachable" while
bodyless GETs worked, so it read as an intermittently dead sidecar rather than
a consumed request stream.
bodyParser runs across the protected router and reads JSON and form bodies,
disturbing the stream; a catch-all that then forwards ctx.req.raw.body forwards
something already consumed, Bun's fetch throws, and the catch reports the
sidecar as down.
createSidecarProxy registers its prefix itself, so core sidecars and any plugin
using it never meet this. Vault proxies by hand and the extraction dropped the
registration. Found by the mobile dev, who went looking on the server side
after the app kept failing where the browser extension did not — the split was
the clue, since the extension's routes are mounted above the protected router
and never see bodyParser at all.
Verified before recording: registerProxiedPrefix and isProxiedPath do what the
fix assumes, bodyParser consults them, create-proxy.ts:83 registers for core,
no vault handler reads ctx.get('body') (all three call ctx.req.json()), and the
registered prefix covers every path the mobile app uses while still not
matching /api/vaultwarden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8367fada13 |
plugins: let a plugin claim top-level paths and divert /api by header
Restores the setup the vault extraction regressed: paste the bare Officer URL
into a Bitwarden client and log in. Nothing else.
The extraction moved the Bitwarden door to /api/vault/bw, which is correct and
unusable — a client derives /api, /identity, /icons and /notifications from ONE
url it is given, so a prefixed base turns setup into five fields copied by hand
into an advanced pane. That is not a feature anyone completes, and the owner
said so. My regression, my wrong call: I treated "a plugin cannot serve
top-level paths" as a fact about the platform when it was a fact about what had
been built.
Two additions, both manifest-declared, both narrow:
unauthenticatedRootPaths single lowercase segments (`/identity`), served
above the gate. Refused if reserved (`/api`,
`/auth`, `/plugins`, `/dav`, …), refused if another
plugin already claims it, refused if it is anything
more structural than one name.
protocolHeaders hands `/api/*` to the plugin when the request
carries one. Needed because /api is the one
namespace both servers claim — Vaultwarden has
/api/sync, Officer has /api/chat — and the path
cannot tell them apart, so the client says who it
is. ROUTING, not authentication: a forged header
reaches the plugin, which demands its own
credential, and cannot reach anything of Officer's.
The worst it does is cost that caller Officer's API.
The Bun route table gets an entry per claimed segment, built from the plugin
set — `refreshPluginMounts` runs at line ~204, long before serve() at ~378, so
the claims are known. This is the constraint I earlier asserted made the whole
thing impossible, without checking it. The real cost is smaller and stated in
the code: a plugin claiming a root path is not fully installed until
`pm2 restart officer`, because the table is fixed once serve() is called.
Every claim is logged at boot by plugin — a root path answering without a
session should not be something you read a manifest to discover.
13 more tests (30 in the file): reserved segments, malformed segments, two
plugins colliding, and segment-not-substring matching so `/identityx` never
matches `/identity`. Suite 1074 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
dc190bb790 |
plugins: implement unauthenticatedPaths — the half that was never built
`unauthenticatedPaths` has been in PluginManifest since it was designed, with
validation (`publicPathProblem`), a long docstring, and a promise that
`assertPluginPublicPaths` re-checks it at boot. Nothing read the field. No
function by that name existed. Kiosk, the plugin it was written for, declares
none — so it validated, documented itself, and did nothing, and no plugin has
ever actually been served above the gate.
Vault is the first real user: a stock Bitwarden client speaks Vaultwarden's
protocol and has nowhere to put a platform JWT, so it cannot use the
authenticated /api/vault router at all.
── What was missing, and where it had to go ──
Two places, not one, and the second is the one that matters:
• the permission gate skips declared paths — but that gate only runs when a
token WAS presented (`payload && !isOwner && …`), so it is what stops a
valid non-owner reaching something ungranted.
• `protectedRouter.use(userMiddleware)` is what stops an ANONYMOUS request.
A path meant to answer without a session has to skip that too, so it is now
conditional on the same predicate. Fixing only the gate would have left
every declared path returning 401 — passing its own tests and failing in
the one case it exists for.
── Containment ──
Paths resolve against the plugin's own mount (`/api` + mountPrefix + path) and
containment is re-checked there, so a plugin cannot name `/auth` or another
plugin's routes however it edits its manifest. Refusal is per-path: one bad
entry does not take the good ones with it, and each is logged by name.
Matching is on segments rather than characters — `/api/vault/bwx` must not
match `/api/vault/bw/*`, the same class of bug `permissionForApiPath` guards
against with `/user` vs `/username`.
Every plugin with public paths is logged at boot: a route that answers without
a session should not be something you have to read a manifest to discover.
17 tests, pinning the containment rules rather than the happy path. Suite
1061 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5e4f3fe29f |
fix all seven failing tests — every one was a stale assertion, not a live bug
The suite has been carried at "1038 pass / 7 fail, same as baseline" for long
enough that the count became the thing being checked instead of the tests. All
seven turned out to pin a contract that changed on 2026-08-13, when a batch of
features were switched off pending plugin extraction. None was a defect in
running code; two were tests that could no longer detect what they were written
to detect.
── Five in registry.test.ts, one root cause ──
Switching a feature off comments out its `api:` line but keeps the permission
(the frontend route still needs gating, and existing role grants have to keep
meaning something). Eleven permissions therefore declare no API prefix now.
• `REAL_WS` still listed 'desktop', which server.tsx stopped serving. The
file's own header says it mirrors that map by hand and that this is "the
drift this file keeps catching" — it caught it, and the failure was read as
baseline for five days.
• 'refuses a claim on a router that no longer exists' withdrew /gitea, which
claims nothing any more, so nothing threw. The test could not fail for the
right reason OR pass. Re-pointed at /tasks, which is core and mounted.
• Two path→permission tests asserted /api/gitea and /api/caldav resolve.
Re-pointed at /tasks and at /user vs /users — the latter is a live overlap
AND a real privilege boundary (resolving /api/users to 'account' would hand
the ordinary-account permission the user-administration API), where the old
/dav vs /caldav pair mapped to one permission and never showed the property.
• 'every permission declares at least one api prefix' stopped being true by
design. Replaced with what it was protecting — a permission must gate
SOMETHING reachable, api or route, since one gating nothing can be granted
and revoked to no effect while reading as a control. `notify` is named as
the one genuine exception rather than silently tolerated: no API, and no
screen by design.
── plugins/music/cliamp: a real ordering fix, pushed separately ──
openPlayer checked for the cliamp binary BEFORE validating the path, so a path
escaping the owner's home answered "cliamp not found on host" on any machine
without it. Fixed there (3f4ee82): validate the request, then inspect the host.
── The pty timeout: an env var that had done nothing for five days ──
The test pinned `HOME_DIR: '/tmp'`. `HOME_DIR` was removed from the platform on
2026-08-13 when the install root became derived, and sessions.mjs resolves `~`
with node's `homedir()` — which reads `$HOME`. So the shell opened in the real
home, `pwd` never printed /tmp, and the test spent its full 15s timeout waiting
for output that could not arrive. One character class of a rename, five days
ago, costing 15s of every suite run since.
1044 pass, 0 fail, 85 files (unchanged). Suite is ~15s quicker for the obvious
reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f6a1194905 |
vault, extracted into plugins/vault — the platform half
Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.
Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.
The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.
── The one new piece of platform: src/servers/account-lifecycle.ts ──
Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.
Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)
Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.
── Two things deleted rather than carried, both dark since 2026-08-13 ──
The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.
The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.
── Tests ──
Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.
bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.
Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c775e8c7e3 |
re-enable vault — plugin extraction deferred, this is the same code as before
Reverses
|
||
|
|
3803d42243 |
plugins: don't require a literal .git suffix on a catalogue clone URL
Found chasing a real install failure: music's marketplace row was hand- entered as https://gitea.officer.dev/plugins/music with no .git suffix, and fetchPlugin's URL check refused it outright with a message ("https URLs only, no credentials") that named neither actual reason. The suffix was never a security property — https-only, safe character set, no embedded credentials are the real checks, and Gitea/GitHub both clone identically with or without .git. Made the suffix optional rather than required, so a future hand-entered row missing it doesn't hit the same wall. Also fixed the row itself in officer_marketplace (added .git, matching every other plugin's entry) for consistency, and confirmed the gitea repo itself was never the problem — plugins/music already exists there with both commits, unaffected by this bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f77a41d26a |
command hq: delete a commander, from the grid card and the detail bar
DELETE /command-hq/commanders/:sessionId — deletes the transcript itself via deleteClaudeSession (the same function DELETE /chat/sessions/:id calls, so a /clear chain goes with it, every part), then the command_hq_sessions row via deleteCommandHqSession (existing, exported, never previously called from a route — its docstring said as much: "no product path to this yet"). Deliberately real deletion, not an unfile-back-to-/chat path — there still isn't one of those, and this doesn't create one. Documented next to "The /chat <-> /command-hq relationship" so it doesn't read as contradicting the "a session never moves the other direction" rule directly above it. Frontend: both the grid card and the detail bar's meta row get an inline confirm (trash icon -> check/cancel), same shape as ChatHistory's own SessionRow delete. The grid card's delete control is a sibling of the card's <Link>, not nested inside it, per docs/navigation-audit.md — the card is a stretched full-cover Link (absolute inset-0, default stacking) with the delete control in its own stacking context (z-10) on top of it, so the card stays click-anywhere except where the trash icon sits. Deleting the commander currently open in the detail panel navigates back to the bare list rather than leaving it pointed at a 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a335fad9a6 |
background jobs: tell the agent not to discard command output
Traced "(no output yet)" on a live commander to the actual running process on this machine rather than guessing: the tray's log-tail was working correctly (it's a general /chat feature, nothing to fix there) — the task's .output file was genuinely empty because the agent had written `timeout 8 ping -c 1 -W 5 google.com >/dev/null 2>&1`, throwing away ping's own output and only echoing on a failed ping. Correct for "let me know if it goes down," but it means a healthy watcher shows nothing at all, indistin- guishable at a glance from a stuck one. buildBackgroundJobPrompt gains an unconditional rule — every job shape, not only watchers — telling the agent not to redirect a command's own output to /dev/null or similar: whatever it would print in a terminal is what the owner wants to see live in the job's log. Landed at rule 8 for watchers (after the existing fire rule), 5 for a bare command, 4 for a bare prompt — whichever number falls after the rules already in that branch, so rule 7's existing identity (referenced by number in tests, docs and COMMS) is untouched. Also lightly reworded rule 7's "stay silent" line: it was already only about the agent's own narration, not the underlying command's output, but the distinction wasn't explicit and reads exactly like the instinct that produced the >/dev/null in the first place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
963c965056 |
command hq: auto-name a commander from its watcher's own task description
The initial title (the mechanics preamble, truncated, when a background job is
the very first message) is expected — that's just Claude Code naming the
session from its first message, same as always. But by the time the watcher's
task actually starts, the harness has already handed it a real description
("Watch google.com reachability" for a ping watcher), and that reads far
better than the preamble ever will.
Hooked into the exact moment pendingArmWatcher is consumed — the FIRST arm,
never a self-re-arm, which the watcher-loop code already had to distinguish
for its own reasons. Resolves a fresh ChatIdentity via resolveHomeDir and
calls renameClaudeSession with msg.description. Best-effort: a failure is
logged and does not touch watcherArmed.
Known gap, noted in the doc rather than fixed: the detail panel's title bar
is fetched once on mount and won't live-refresh when this fires while it's
open. The grid's cards don't have this problem since GET /commanders already
polls every 15s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
eed906f45b |
command hq: show token totals alongside cost, not just dollars
Both the grid card and the detail meta bar now read "N turns · 12.3k tok · $0.20" — tokens first, since that's what was actually asked to be primary, dollars kept alongside rather than dropped. formatTokens abbreviates past 1000 (12.3k, 1.2M) the same way a long-running watcher's cost already reads at a glance; the raw sum stops being readable well before the dollar figure does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a9be3a92c9 |
command hq: web frontend v1 — nav, grid, detail
Builds the Workspace/Panel screen the design doc's "Layout, v1" described but nothing had implemented yet: /command-hq and /command-hq/:sessionId, three always-mounted panels (officerdev/src/apps/CommandHQ/) — a flat category nav filtering the grid via ?category=, a commander grid reading GET /commanders, and a detail pane that is either an empty state, the new-commander composer, or the selected commander's resumed transcript. Follows the frontend route conventions in CLAUDE.md throughout: Workspace/Panel framework (locked, three fixed panels, no standalone screen), category and session both addressable in the URL rather than a channel, real <Link>s for every row and card, a route pair (/command-hq + /command-hq/:sessionId) the same shape as /chat's. The composer reuses the exact mechanism /chat already uses to create a commander — an ordinary turn with context: 'command-hq' via useChat/ EmbeddableChat — rather than the mobile-only POST /commanders/new HTTP route. Once the harness reports a real transcript id the URL is replaced onto it (useChat's own permalink writes /chat/<id>, which is the wrong screen here, so that's done by hand). fileAsCommander always files a new commander uncategorised — it has no way to know a category was selected in the composer — so if one was, a client-side PATCH sets it after the fact, the same race POST /commanders/new already resolves server-side for mobile, just resolved here on the client instead. The detail panel's transcript resume mirrors ChatPanelWrapper's fetch-then- mount pattern (GET /chat/sessions/:id first, then useChat with resumeSessionId + initialMessages + paginate) rather than reusing it directly — Command HQ's version is simpler (no dashboard/agent-panel concerns) and scopes rename/category/disarm through Command HQ's own PATCH route rather than /chat's, since a commander lives in a different directory than an ordinary chat session. Card status is the three-state read COMMS/command-hq/mobile-4.md proposed and the design record adopted: green running, amber armed-but-not-running (the watcherArmed && !running pair that makes a silently-dead watcher visible), grey neither. Registered in AppRegistry.tsx, re-exported from officerdev's barrel (route helpers + types, per CLAUDE.md's rule for anything an officer-web screen needs), a CORE_DOCK_ITEMS tile (not in DEFAULT_DOCK_PATHS — opt-in via the Dock settings screen, same as Jobs/Monitor/Activity), and a usePageTitle rule. bunx tsgo clean, suite at its 7 pre-existing baseline failures, none of them this feature's. NOT yet clicked through against a running server — the composer's redirect-on-real-id, the disarm button, the category-race PATCH have all typechecked and nothing more. That's the next real test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b7d8bbf36 |
add gen:ecosystem script to rebuild ecosystem.config.cjs from the database
bun gen:ecosystem writes the whole file from three sources: CORE_PROCESSES read directly out of scripts/setup/officer-setup/lib/services.sh (shelled out to bash, so there is exactly one list rather than a TS copy to keep in sync by hand), marketplace plugins that are installed AND enabled in Postgres and actually ship a sidecar/, and app-store services that are installed AND enabled, using each catalogue entry's `process` name against the src/servers/sidecar/<id>/index.ts convention. Extracted `pluginPm2Entry` (a pure DiscoveredPlugin -> Pm2App) out of addPluginToEcosystem so the live install/uninstall path and this from-scratch rebuild share one definition of what a plugin's entry looks like, rather than two that can drift apart. addPluginToEcosystem's own behavior is unchanged. Complements, not replaces, the existing incremental add/remove in servers/plugins/ecosystem.ts — that edits the live file at install time; this regenerates the whole thing, for a fresh clone, drift, or restoring from a backup. Found while testing against this machine's real file, worth flagging rather than silently working around: officer-marketplace (the marketplace.officer.dev process, src/servers/marketplace/server.tsx) is in this machine's ecosystem.config.cjs by hand and is modeled by neither the plugin system nor the app-store catalogue, so this script does not know about it and would drop its entry on a real run. Left alone rather than guessed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9e641bc228 |
COMMS: reply to mobile-4 — cost-tracking schema change, preamble proposal deferred
Tells the mobile side what moved under fields they already reference: watcherLastFiredAt is no longer a column (command_hq_watcher_fires / command_hq_turns hold what it summarized), watcherArmedAt/watcherStoppedAt are new, and two routes (.../turns, .../fires) expose the raw series. Same argument-order bug flagged as a general caution. Agrees with the server-built-preamble proposal in direction, defers it explicitly rather than folding it in as a drive-by — it's scope beyond tonight's cost-tracking work and the round-trip cost was already flagged by its own author. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
764ba23206 |
command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost over time, and cost per trigger — derived, not stored, following the same rule `running` already uses. Two new tables carry it: - command_hq_watcher_fires: one row per trigger (sessionRowId, taskId, firedAt). Replaces the single watcherLastFiredAt column, which lost the previous fire's timestamp every time a new one landed — fine for "when did it last fire", useless for "how many times, and what did each one cost". - command_hq_turns: one row per completed turn (sessionRowId, fireId nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed — running total, cost-over-time and cost-per-trigger are all queries over this table. Turn count needed no column either: COUNT(*) already answers it, confirmed explicitly with the owner rather than assumed. command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any "last fired" cache — set only on the false<->true transitions, so duration (current run, or the last completed one) is computable without a full transition history table. Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose the raw series behind the cost summary and watcherLastFiredAt (now computed via MAX(fired_at), not read off a column — the field survives in the JSON response under the same name). Bug found and fixed while wiring this up, recorded in docs/command-hq.md: recordCommandHqWatcherFired's signature changed from (userId, sessionId) to (sessionRowId, taskId) and one call site kept the old argument order — tsgo stayed clean because number/string still matched structurally. Caught by re-reading every call site by hand, not by any tool. Needs bun db:push before any route answers with real data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56911f6874 |
COMMS: the self-re-arm evidence is stronger than either write-up said
Andre's own count: at least 10-15 runs, before and after the current mechanism existed, zero failures. Both notes had it as "hours, this session". Recorded as a correction because it is a different class of claim. "It worked for hours tonight" supports a decision; "it has never failed across a dozen runs and two surrounding mechanisms" supports not revisiting it — which matters, because self-re-arm looks fragile on paper and the instinct of anyone reading it cold is to add a safety net the evidence says is unnecessary. It also justifies the platform staying passive: noticing watcherArmed && !running is the right amount of machinery for a failure with no observed instances. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
971a8bf0d4 |
COMMS: re-synced the preamble, and a proposal to stop having to
The flag was right: apps/commandhq has its own composer and ships a verbatim copy of background-job-prompt.ts, so handoffContract was still promising "the harness re-arms you". Worse than stale — a mobile-armed watcher would have waited for a re-arm that was never coming, firing once and silently stopping. Re-copied and diffed. That is the third re-sync in one session, each caught only because I happened to be reading the diff. A stale preamble typechecks, builds, and produces a job that runs, so nothing catches it. Proposes the fix: build the preamble on the server. It is a pure function of the spec, both clients already assemble that spec locally, and the create endpoint could take the spec instead of a finished prompt — one implementation, on the side that owns the mechanism the text describes. displayText and cwd both drop out as things the server can derive rather than be told. Cost stated too: it puts prompt text behind a round trip. Not asked for tonight. Also records why the mechanism changed, because it generalises: the synthetic turn lost to the fact that this session's own watcher had been self-re-arming for hours with zero failures. A measurement beat a hypothesis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
624d67ed52 |
watcher loop: the agent re-arms itself, same turn — final mechanism, confirmed against real usage
second correction to the re-arm design tonight, and this one came from the owner, not from me. my plan (platform sends a synthetic follow-up turn once the acting turn's result lands, instructing re-arm) was technically sound — it keeps the agent inside the sdk's own Monitor tracking, which is the thing the FIRST wrong plan (silent respawn) broke — but it costs a real extra turn every cycle. pushed back on with hard evidence: the mobile dev's own watcher, on this repo, this session, had been running with THEM re-arming themselves, same turn, for hours already — zero failures. that beats a reliability concern I raised without having tested it. so: handoffContract now instructs the agent to re-arm itself, as the last action of the SAME turn that handles the notification — no synthetic turn, no platform spawn, no extra cost. exitContract's handoff branch corrected to match (was still saying "the harness re-arms a firing on its own", exactly backwards now). server side simplifies to match: createMessageHandler's task:started hook now handles both cases with one rule — pendingArmWatcher (session-object flag, not closure-captured, since arming happens turns after the closure was built) claims the FIRST task:started as the watcher; every SUBSEQUENT task:started while already armed is read as the agent's own self-re-arm and just updates watcherTaskId to match. task:notification records the fire when it matches the tracked id. the platform never drives anything — it only notices what the agent already did. pulled COMMS/command-hq/mobile-3.md, which answered the PREVIOUS (synthetic turn) version — crossed in transit. their transcript-bloat concern is moot now (no synthetic turn exists to bloat anything). their `watcherArmed && !running` detectable-failure pair stands regardless of which mechanism won and is exactly right — kept as-is, matches their own three-state dot (running/armed-but-not/neither). replied in platform.md. all four proposal items now built: prompt+dialog, stopped-vs-died, the re-arm mechanism (this commit), arming state+plumbing. bunx tsgo clean, suite unchanged (1038 pass, same 7 pre-existing baseline failures). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cc5340b8e0 |
COMMS: on the synthetic re-arm turn, and the state pair that catches a lost one
The mechanism is right — driving the agent to reissue is the only shape that keeps the notification path intact, and finding restartBackgroundTask unusable before building on it was the better half of that commit. Two things to build in rather than discover. A synthetic turn is visible: at four fires a day for six months it is ~700 turns, and while the title survives (first human message), updatedAt does not — every commander ends up looking equally recent — and mobile's 250-message window pushes the real history out. Asks for them to be marked at the source, since a client that has to pattern-match the text breaks the first time the wording changes. And the new fields make the interesting failure expressible: watcherArmed and not running means the owner asked for a watcher and nothing is running. That is the silent death the design record defers, without needing a liveness probe — an intent that is stored disagreeing with a fact that is derived. Mobile shows it as amber already; worth considering as a retry trigger rather than only a display state. Mobile now carries watcherArmed, watcherLastFiredAt, the three-state dot, disarm via PATCH, and armWatcher threaded from the sheet and read by nobody yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f369b4ca18 |
watcher loop: arming state and end-to-end plumbing, item 3's mechanism found wrong before writing it
item 4 first (schema + plumbing), ahead of the proposal's own suggested
order, because item 3's trigger needed somewhere to write to before it could
be tested at all.
command_hq_sessions gains watcherArmed/watcherTaskId/watcherLastFiredAt.
watcherArmed is allowed to be stored unlike `running` — it's the owner's own
intent, not a live-process fact nothing external can silently falsify the
way a sidecar restart falsifies running. armCommandHqWatcher/
recordCommandHqWatcherFired/disarmCommandHqWatcher in queries.ts, exposed on
GET /commanders, GET /commanders/:sessionId, and PATCH accepts
{watcherArmed:false} to disarm (arming itself has no route — needs a live
task id only a running turn has).
armWatcher threaded the full client-to-server path: BackgroundJobDialog ->
sendWithHiddenPrefix -> QueuedPrompt -> sendPrompt's new last param -> the
chat websocket message -> ClientMessage -> startClaudeTurn's msg. typechecks
end to end, consumed NOWHERE yet — safely inert, not a shipped mechanism.
the real finding: my plan to reuse task-restart.ts's restartBackgroundTask
for the actual re-arm trigger is wrong, and I caught it before writing it
rather than after. that function respawns a process OUTSIDE the sdk's own
Monitor tracking — chat.ts's restart route emits its own task:notification
via emitToSession specifically because the harness stopped watching that
process. it updates the ui; it cannot reach back into the agent's context,
because there's no mechanism by which it could. a handoff watcher needs the
opposite — the agent must be TOLD again when it fires again, which only
happens through the sdk's own notification delivery, which only exists for
a job the sdk itself is still tracking.
so re-arming can't be a silent platform respawn. it has to drive the AGENT
to reissue its own run_in_background/Monitor call — the platform's job is
making that automatic and correct (right command via commandForTask, right
moment, no double-arm), not doing the reissuing itself. likely shape: a
synthetic follow-up turn, not from the user, once the acting turn's result
lands. not implemented — raised with the owner before building around a
wrong assumption a second time tonight.
docs/command-hq.md corrected to match — the earlier "reuse restartBackgroundTask"
claim removed and replaced with what's actually true.
bunx tsgo clean, suite unchanged at baseline (1038 pass, same 7
pre-existing failures).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7229ce021d |
watcher loop: the prompt/dialog half, and stopped no longer reads as failed
owner's call, confirmed 2026-08-18: implement watcher-loop-proposal.md
exactly as written, before the first real tests — this governs agent
behavior generally, not just command hq. items 1-2 of the proposal's own
suggested order, done; items 3-4 (harness-owned re-arm, arming state on the
commander row) are the harder half, next.
FireBehavior replaces the stopOnFire boolean: 'stop' | 'handoff' |
'keep-going'. handoff carries the proposal's one-sentence contract verbatim
("the harness re-arms you — do not re-arm yourself, and do not build your
own polling loop on top of this one") and rewrites the closing IF IT EXITS
block so it stops contradicting rule 7 (the old universal "do not restart
it, i will start it again" directly lied about what handoff jobs actually
do).
isCommander gates the option to where the promise can be kept. the proposal
frames this entirely in terms of commanders — arming state's only home is
the commander row — so outside one there's nowhere to persist "keep
re-arming this," and offering the option there would make a promise nothing
fulfills. not wired to true anywhere yet: command hq's own composer, which
would set it, doesn't exist. every existing /chat caller is unaffected —
two options, stop default, unchanged from before tonight.
stopped-vs-died (§5): a job officer had just SIGTERM'd came back
status:'failed' (exit code 144) from the harness's own classification,
which this codebase does not control. fixed client-side — the tray already
knows the instant stop is clicked, so it now also marks that, and
useChat's task:notification handler overrides the displayed status
regardless of what the harness reported. known gap, stated in both the code
and the doc: in-memory, so it doesn't survive a refresh in the narrow window
between clicking stop and the notification landing.
docs/command-hq.md gains "the watcher loop" section: what's built, what
isn't, the proposal's own suggested order followed as given.
bunx tsgo clean. suite: 1038 pass (+9 new tests), same 7 pre-existing
baseline failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f810bdc48e |
command hq: displayText, caught by the mobile side before either of us ran this
mobile-2.md found it: a commander created to become a background job sends buildBackgroundJobPrompt's full mechanics as `prompt` — a page of preamble, the owner's actual sentence only at the end — and command_hq_sessions has no name column, so the title comes straight from `(displayText ?? prompt).slice(0,100)`. without it every such commander titles itself with the mechanics text, and the grid becomes a column of identical unreadable cards. same precedence the websocket path already uses on both clients (sendWithHiddenPrefix on web, useSessionView.ts on mobile) — now the same on this endpoint too. fixed before either side had run this against a real server, so there's nothing to clean up on a live grid. also confirmed rather than assumed, per mobile's other note: cwd cannot be talked into a different one on this path. resolveChatCwd's command-hq branch ignores msg.cwd unconditionally and this endpoint never sets it. replied in COMMS/command-hq/platform.md. also read watcher-loop-proposal.md (exit/act/drain/re-arm as harness infrastructure, "Andre's call" to build it before first tests) — not starting it off a COMMS note alone, taking it to the owner directly since it's a real scope addition beyond what tonight's command hq build was scoped to. bunx tsgo clean, suite at baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cc121f3156 | Merge branch 'master' of gitea.officer.dev:officerdev/platform | ||
|
|
7ad64445ee |
command hq: POST /commanders/new, for a client that cannot open the websocket
mobile's need, decided with the owner in COMMS/command-hq/mobile.md, now
built: { prompt (required), categoryId? } -> { sessionId, cwd }, sessionId
being claude's REAL transcript id, not a placeholder.
the owner confirmed prompt is required and described why: a commander
usually starts as an ordinary back-and-forth before a background job is ever
armed via the existing dialog, sometimes it's just "you know what to do" —
either way there's always a real first message, same as any conversation.
no empty-commander case to design around.
getting a real transcript id costs actually starting the turn, since claude
assigns one only once system.init has been reported. extracted the
turn-starting half of handleClaudeCodeChat into startClaudeTurn(ws: ... |
null, ..., onOutcome?) so both the websocket path and this endpoint drive
one spawn function instead of two that drift — handleClaudeCodeChat is now
a thin wrapper with an unchanged signature. onOutcome mirrors every
sendToClient(ws, {type:'error',...}) refusal point (member not signed in,
claude not installed, spawn failure) plus the session:claude success case,
so an http caller gets the real reason instead of a generic 30s timeout.
the category-write race: fileAsCommander (same hook the web path uses,
since context: 'command-hq' is set either way) always files with no
category. the endpoint's own createCommandHqSession is upsert-safe
regardless of which insert wins, and an unconditional setCommandHqSessionCategory
UPDATE runs after — always last, always wins, so the requested category is
what's left standing no matter the race.
said plainly in COMMS/command-hq/platform.md: this refactor of live spawn
machinery did not get the second-pair-of-eyes review i said i wanted before
touching it. bunx tsgo clean, suite at the same 7 pre-existing baseline
failures. not exercised against a running server on either side yet.
docs/command-hq.md updated throughout — API table, "how a commander is
created", "still open" now empty of blockers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2f3e2db869 |
COMMS: exit, act, drain, re-arm — infrastructure, plus one sentence of prompt
Andre wants this implemented, prompt and code, before the first real tests. The argument is the evidence from running one: every mistake made was mechanical, not judgemental — two watchers alive at once, firing on the agent's own push, forgetting to re-baseline. Those are protocol steps that must happen every cycle, and a model is the wrong thing to hold a protocol across turns when the failure is invisible: a watcher that stopped being re-armed looks exactly like one that has not fired yet. So the harness owns the loop — re-arm from the post-action baseline (which makes draining automatic rather than remembered), one watcher per job, do not fire on the agent's own writes, and know the difference between stopped and died. The preamble stops describing a loop and promises one instead, in a sentence, so the model does not invent its own to fill the gap — which is what happened tonight. And the state belongs on the commander row: a card can say "armed, last fired 3h ago", which derived running cannot, and a row survives an agent that is in a new session or out of context. It settles stopped-vs-died for free, since the thing that re-arms knows which it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f652edab54 |
COMMS: answering the two questions on the create endpoint
prompt required — there is no empty-commander case on either client. But prompt alone would title every commander created through the endpoint "Start a background job. The mechanics below are load-bearing..." — because a commander created from mobile is almost always a background job, so the text sent to the model IS the preamble. The websocket already solved this: title is displayText ?? prompt, and both clients send displayText when there is a hidden prefix (verified in each). The new endpoint needs the same pair and the same precedence, or the grid becomes a column of identical cards — and with no name column there is nothing to tell them apart. Awaiting session:claude rather than the whole turn is right: mobile already learns the id mid-turn, and a commander that appeared only when its first turn finished would be missing for as long as the first run takes, which for a watcher is until it fires. Offered to review the extraction diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e9b1ce5e94 |
command hq: the web creation path, and a member could not have used it
`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.
found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.
the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.
pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).
docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.
bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ff88c93b80 |
COMMS: two watchers, one event — re-arming without stopping the old one
Mine rather than the mechanism's, and recorded because the mechanism invites it: having acted on a fire, I armed a fresh watcher while the previous one was still alive. Two watchers on one branch produce two notifications for one event, and the second is indistinguishable from a real second push. It compounds the self-trigger fault above — the agent pushes, both fire, neither has anything to report. The discipline is dull: stop the watcher before you push, re-arm after. A pgrep before arming costs nothing and catches it. For Command HQ it is a hazard rather than tidiness. A commander armed twice is two processes doing the same work behind one card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1c1c669029 |
COMMS: what broke while actually running a background job
Written after arming, firing, draining and re-arming a real watcher rather than reading the code. Command HQ is a home for these, so anything wrong with the mechanism shows up there first and worst — a commander is meant to run for months. Six faults, the first structural: "keep going" and "tell the agent" are mutually exclusive, because a background process reaches the model only when it EXITS. A looping job can do shell work and nothing else — it cannot read a diff or judge whether a push matters. So the dialog offers a mode that silently means "the agent will never see this again", which is the wrong default for the exact case watchers exist for. Asks for a third option: exit, hand back, re-arm. Then: drain before re-arming or lose what lands in the gap; a watcher on a branch the agent also pushes to fires on its own writes, and author filtering cannot help because both sides are Claude; `timeout` does not exist on macOS so rule 6 cannot be followed as written; a job stopped on purpose is reported as failed, which would read as broken on a grid of cards; and the offered working directory is the conversation's rather than the watched thing's. Also what worked and should not be traded away — ask-before-arming earned its place in the first minute — with one caveat: the output rules leave nowhere to report that the thing asked for cannot work as specified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2ae58d29a |
COMMS: notes live in the repo now, starting with command-hq
Andre: keep these in the platform repo with proper tracking rather than scp-ing them to a directory on alpha. A note on a server is invisible to whoever picks the work up next, has no history, and cannot be reviewed beside the change it argues about. In git it is diffable, it arrives with a pull, and the commit that answers a note sits next to the note. First one is the mobile side of Command HQ: what the API got right, and the single thing a client cannot do — create a commander, because no endpoint returns the commanders directory and only the web composer flags the spawn. The decision reached with Andre is recorded there, with the two rejected alternatives and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eb94af87f4 |
command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission (personal: ['/'], no grant to hold — every query is scoped to the caller's own user_id at the sql layer regardless). registered in the permission registry and PROTECTED_MOUNTS together, so totality holds by construction. GET /commanders is the grid endpoint: everything /chat/sessions already returns per session, plus categoryId/tagIds/running. `running` is derived live off collectRunningTasks (now exported from websocket.ts) folded over chat_session_events — not a stored column, on purpose, matching the design doc's stance that a status field would go stale exactly when a sidecar restart silently kills a watcher. GET /commanders/:sessionId deliberately does NOT duplicate the transcript — that stays GET /chat/sessions/:id, unchanged. a client calls both. /chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds, .catch()'d so a hiccup on this feature can't take the whole chat list down) — the other half of the one predicate this whole design turns on. there is deliberately no POST /commanders/new: creation is an ordinary chat turn in the commanders directory, which is the next commit. docs/command-hq.md gains an API reference table for the mobile dev, and a status line at the top saying plainly what is real and what is not yet: needs `bun db:push` before any route returns real data, frontend not started. bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all registry/totality drift and the pty socket test — none touch this router). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56fe3b22df |
command hq: data model and the shared commanders directory
first commit of command-hq/command-hq.md's design. two tables plus a many-to-many for tags, no server surface yet — this is data layer only. command_hq_sessions has no name column: /chat never persists a title to postgres, renameClaudeSession appends a summary entry straight into the transcript's own jsonl, and that stays the ONLY place a commander's name can live. no status column either — liveness is derived from chat_session_events the same way the background-job tray already derives it, because a stored status would go stale the moment the harness sidecar restarts without a final task:notification and nothing would ever correct it. category is a plain FK (one per commander, confirmed in spec); tags are the many-to-many case, schema only, no ui planned yet. also: getCommandersDir(email) -> DATA_PATH/<email>/commanders, one shared directory for every commander a user has. the first design was per-commander directories and was dropped same-night: creating a fresh directory timed against the harness's own spawn is a race, and a shared pre-existing directory removes it instead of handling it. deliberately DATA_PATH-rooted even for the owner, unlike ordinary /chat which runs in the owner's real home — that's what keeps commanders out of both the owner's own project transcripts and the /chat list. bunx tsgo clean. no db:push yet — next commit is the router, and the table needs to exist before anything can call it; will note the push requirement clearly when that lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0f93262a8c |
usage panel: verified for one account, not for two
the owner confirmed it live the same day. what that leaves untested is the half the cache exists for — a second login, the tabs, a stored row surviving a switch — so the docs now say which half is proven instead of claiming neither is. also records the bug the first deploy found, since the doc had asserted the degradation that turned out to be broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3cc9df27d7 |
a failed usage write must not discard the reading
the panel rendered "Not signed in" over an account that was signed in fine. cause: `snapshotClaudeUsage` had the read and the write in ONE try block, so on a machine that had not run `db:push` the upsert threw on a missing `ai_provider_usage`, the catch returned null, and a perfectly good reading went in the bin. the route then merged null into an empty stored list and answered with no accounts at all. which makes the claim in docs/ai-usage-panel.md — "degrades to no tabs rather than a 500" — false as written. the stored half is an ENHANCEMENT: it buys tabs for accounts that cannot be measured, and an enhancement that fails must cost only itself. so the write gets its own try and the reading is returned either way. observed on this machine rather than reasoned about: to_regclass came back null, and the regression test fails against the old shape (verified by reverting it, not by assuming). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e2eb21f83b |
usage panel: plan and account in the accent, not muted
it is the answer to 'whose numbers am i looking at', which matters more the moment there is a second tab, and it was reading as chrome under the title. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19f558077e |
document the usage panel and what testing it would mean
it has never run: db:push has not been applied and neither process has been restarted. docs/ai-usage-panel.md records where every number comes from and why officer cannot derive any of them itself, the two things the live payload settled (every model-scoped bucket is null while limits[] carries a real fable window; the window is named by kind, never by the model name), the measured costs, and the staleness rules — which are the part most likely to be wrong, because they assert things about data they did not measure. sixteen ordered tests in five groups. group C is the actual feature. test 10 is the one that would break silently: a refresh must not overwrite an account it could not measure. also records what the scan measurement found, since it decides the deferred work — 74.7% of message ids appear in more than one transcript file, so per-file aggregates cannot be summed and an incremental scan is not the one-liner it looks like. TODO.md gains an entry under "waiting on the owner to run it", beside plan mode, which is also still unexercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9745f54fa1 |
cache usage per ai account, so a switched-away account still answers
half a usage reading cannot be taken for an account you are not signed into. the limits come from anthropic's oauth endpoint using the credential the cli holds for the CURRENT login, and the tokens come from that login's transcripts — switch accounts and the previous one is not slow to read, it is unreadable. so: `ai_provider_usage`, keyed (user_id, ai_provider, email_account), with a snapshot taken at the end of every turn. fire-and-forget off the `result` message, owner-gated, capped by the reader's own 60s cache so a burst of short turns is not a burst of 150MB scans. the panel gains tabs, one per account. GET /chat/usage now answers from postgres alone — instant, and it carries the accounts that cannot be measured — and ?refresh=1 re-measures the signed-in one and writes it back. the client fires both and swaps. the reason a stale row is worth showing rather than apologising for is that `resetsAt` is an ABSOLUTE instant. a row from six hours ago has an as-of percentage and an exactly correct "resets in 14h 20m", ticked client-side with no network. and where a window has rolled over since, `currentLimit` reads it as 0% — the stored figure is not merely stale there, it is wrong in the direction that matters, since the account is actually free. weekly rolls forward to its next boundary (the length is in the kind, so that is arithmetic); session does not, because the five-hour window starts on first use rather than on a grid and there is no clock to count down. fetchedAt is now the older of the two halves rather than a stamp at the end, so a cached reading does not report itself as current. needs `bun db:push`. degrades to no tabs without it rather than a 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc68a289ff |
usage panel on the claude code pill
clicking the harness pill opens what claude code's own usage display shows: session/weekly/scoped limits with reset countdowns, tokens by day for the last week, tokens by model. officer has none of these numbers and could not derive them. per-session cost is in-memory in websocket.ts and dies on restart, and the rate-limit windows belong to the ANTHROPIC ACCOUNT — spent by every `claude` on the box, not only by this chat. so both halves come from the cli's own record: the oauth usage endpoint for the windows, ~/.claude/projects for the tokens, ~/.claude.json for which account is signed in. shape and the non-obvious correctness details are lifted from omarchy's agents collector (basecamp/omarchy quattro, via pastilhas.agents which adds the account email): dedup by message id, the four token counters, the per-payload percent-vs-fraction scale, and reading the model-scoped window out of limits[] rather than the flat buckets — every seven_day_* bucket is null on this account while limits[] carries a real fable window. verified against the live payload today; limitsFromPayload is tested against it. owner-only in the handler, not the registry: `chat` is confined, so a member holding it reaches every route here, and these are someone else's windows. the pill falls back to the plain label it always was for anyone else and for opencode. 442ms cold, 1ms warm on 87MB of transcripts; cached 60s server-side, and the query only runs when the popover opens. not exercised through http — the route needs an officer restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
77f90a91f0 |
show the model that answered instead of the bare harness string
the picker rendered the literal `claude-code` for any chat that named no
tier. that is DEFAULT_MODEL — the bare harness — and it routes correctly
(no --model is passed, so the cli picks for itself) but names no catalogue
entry, so radix matched no item and the fallback from
|
||
|
|
0868e637c0 | handoff: two chat fixes for the next session | ||
|
|
2f31d464be |
plugins: declare unauthenticated paths, validated; mounting designed and measured
half of the primitive kiosk needs. the manifest field and its validator are in;
the mounting is documented and not wired, so nothing serves anything publicly
today.
paths are relative to the plugin's own mount and refused rather than corrected:
no leading-slash-missing, no .., no //, no query or fragment, wildcard only as a
trailing /*, and never the bare / or /*. the plugin is trusted with what those
paths DO, never with which paths.
the mounting was measured rather than reasoned about, and two obvious designs
are wrong:
mounting the public router at the plugin prefix gives correct fall-through but
lets the router serve any route it defines - the manifest becomes documentation
rather than enforcement.
adding a use('*') guard blocks that leak and BREAKS the plugin's authenticated
api: a use('*') middleware always matches, so the parent never continues.
measured - an authenticated route 404'd with a valid token.
what works is registering only the declared patterns, delegating into a
pre-mounted app. undeclared paths never register a handler, so they fall through
naturally. enforcement by registration rather than by checking, which is why a
plugin cannot widen it.
docs/plugin-unauthenticated-paths.md has the probe results and the six
remaining steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f6658bd092 | docs: kiosk replaces the marketplace, and the shape it takes | ||
|
|
d88bf7b5f5 | todo: what has to be true before the plugin ecosystem opens | ||
|
|
e350c798bb | todo: the three plugin-system gaps the email extraction exposed | ||
|
|
b63badaf67 |
remove the email residue the extraction left in the platform
a sweep for remnants after 2026-08-18. nothing was broken - no dangling import, symbol, route or permission key anywhere in src/ - but the platform was still speaking the plugin's vocabulary in a dozen places. the one that mattered is live code rather than prose: ChatPanelWrapper hardcodes workspace.id === 'email' to send context: 'email', which is the frontend half of the pair whose backend half (resolveEmailCwd) carries a fifteen-line justification. the two ends now both say so, including that adding a SECOND id there is the signal the platform needs a real way for a plugin to declare a chat context. the rest: two dead imports left by removing the settings section, an empty 'Email' type-section header, a protocol.ts section header with no members naming the plugin's SSE design, and a queue comment pointing at sidecar/email/sync-runner.ts - a path in another repository now. CLAUDE.md, AGENTS.md and working-on-officer.md described email as a platform feature, an app permission and a pm2 process. those are the files a fresh agent reads first, so they were the highest-risk staleness in the tree. TODO.md's whole Email section moved to the plugin's own repository. it held OPEN items pointing at sidecar/email/store.ts, which is exactly how a leftover list sends someone to edit files that do not exist. the delete-account dialog promised it would remove email accounts and playlists. both tables belong to plugins now, so that cascade only happens where the plugin is installed - it now says 'anything an installed plugin keeps for them' instead of naming two it cannot guarantee. imapflow and mailparser STAY in package.json. the plugin has no package.json of its own and resolves from the platform's node_modules, so removing them would break the installed plugin's sidecar. the fix is giving plugins their own dependency manifest, not deleting these. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e88e3a70cf | email in the bundled catalogue, the offline floor under the store | ||
|
|
98c37c4ce9 |
email leaves the platform for plugins/email
the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.
three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (
|
||
|
|
4655d374ec | email extraction: plugin side built, platform untouched | ||
|
|
c5ed6cc32c | plan of record for extracting email into a plugin | ||
|
|
43fb2952fa |
a test plan for plan mode, for a day that is not today
the owner has no time to test any of it, so the checklist has to work for someone with none of today's context: eleven ordered tests, what each should show, and what its failure looks like. test 4 is called out as the one most likely to be wrong - approving ExitPlanMode drops the harness into default rather than back into bypassPermissions, which is the finding that changed the design and therefore the thing the implementation is most likely to have got wrong. also separates what was verified from what was not: the probe runs against a real claude, the turn-stream unit tests, and then everything end to end, which is nothing. the inline 'what to watch' list in chat-plan-mode.md is replaced by a pointer so there is one source of truth, and TODO.md now says live-but-unexercised rather than built-not-run - the restarts are done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
004f702747 |
no rotation needed: nobody else has ever used this install
the owner confirmed the additional accounts are test rows - none has ever signed in, no member has opened a shell or a chat. so the proxy secret was readable by accounts that do not exist in practice, and there is nothing to rotate. the mode fix stands: it is about the install that gets used, not this one. dropping the TODO and recording the reasoning instead, because a rotation item left sitting there reads as outstanding work forever, and the next person to find a world-readable secret should ask who uses the box before reaching for the alarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56ddb6bebf |
close the anthropic proxy secret, and re-close it on every boot
data/sidecar/claude-state.json holds proxySecret, and that string IS the owner's Claude Max subscription: officer-anthropic-proxy accepts it as x-api-key, swaps it for the owner's OAuth bearer and forwards to api.anthropic.com. it listens on 127.0.0.1, which every local account can reach. it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and Bun.write at ubuntu's default umask of 002. confirmed by reading the live secret as 'nobody', an account with no relationship to the platform, and by confirming the proxy 401s without the key so the secret is the only control. this does NOT mean members shared the subscription. spawnClaudeAsMember runs their claude through setpriv --reset-env with a six-name allowlist, so ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns go straight to anthropic on their own OAuth. that design is intact - it could just be walked around by anyone with a shell. fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the authoritative fix, and on every write rather than at creation because writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets() sweeps at boot because officer-anthropic-proxy is a separate pm2 process that can recreate the file hours after officer started, and because every existing install has the old mode until something repairs it. it repairs rather than refusing to boot, unlike assertSecretsClosed beside it. the line is who wrote the file: .env is the installer's and we must not guess at it; this is our own output at the machine's umask, so refusing to start would punish the user for our bug. the per-account copies are swept too. no live secret in them today, but they map session keys to transcript uuids for named accounts. tests verified to bite: removing the chmod while still reporting success fails four of them. a chmod does not undo exposure - the secret still needs rotating on the production host. TODO.md has the sequence, including that officer-claude-code caches it and must be restarted alongside the proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
63f866750b | todo: plan mode is built and needs the owner to run it | ||
|
|
4125949468 | record plan mode as built, and what to watch on the first run | ||
|
|
1b811dfa28 |
make plan mode reachable in chat
opusplan has been in the picker since
|
||
|
|
9be484a105 | verify plan mode is reachable on a bypassPermissions session | ||
|
|
a4907fdfdb |
plan of record for plan mode, and mark the sessions list built
opusplan is in the picker and does nothing on this install: it is a per-MODE model switch (Opus while in
plan mode, Sonnet at rest — the CLI's own strings call them the upgrade and resting models), and
claude-manager hardcodes permissionMode: 'bypassPermissions', so plan mode is never entered. Not
orchestration, which is what the name suggests and what we both assumed.
Making the mode reachable is the fix. setPermissionMode is a control request on the live query, same channel
as the setModel that landed in
|
||
|
|
5565867ba9 |
indent and space a directory's sessions
The rows sat flush against their own heading and against the next directory, so an expanded node ran into its neighbours as one undifferentiated column — the tree stopped reading as a hierarchy at exactly the moment it had one. pl-8 lines the rows up under the heading's TEXT rather than under its chevron, which is what makes "these belong to that" legible without drawing a guide line, and py-2 separates the group from the headings above and below it. |
||
|
|
773f0e1c8b |
never store a model the picker cannot render
The selected model went blank after a turn: visible only by opening the dropdown.
`officerModel` was coming back as `claude-code` — the bare harness. That is `DEFAULT_MODEL`, used when the
client sends no model and the user has no default. It ROUTES fine (isClaudeModel is a prefix test, and
split('/')[1] is undefined so the CLI picks its own), but it names no catalogue entry, so Radix's
`SelectValue` matched no item and rendered nothing.
It fed itself. A blank picker sends no model, so the next turn fell back to the bare harness again and
overwrote the real value — it destroyed a correctly backfilled `claude-code/opus` within one turn. My own
write path did that, added this afternoon.
Two changes. The server refuses to record a model with no tier: a bare harness means "no explicit choice",
and the honest response is to leave the previous answer alone rather than overwrite it with a non-answer.
And the trigger no longer trusts that its value names an item — an unmatchable value renders as its own
string instead of as an empty control, which also covers a retired model or a vanished OpenCode provider.
Repaired the one corrupted row in place by deleting it, so the transcript family fallback answers for that
session until its next real turn; it now reports claude-code/opus again.
|
||
|
|
640a74bcd5 |
sessions list: readable tabs, a real separator, headings that stay put
Four things, three you asked for and one the screenshot gave away. The separator under the tabs now borrows the panel header's own tokens — --panel-header-bg and --panel-header-border — rather than bg-muted/30 + border-border. Two rules a few pixels apart that do not match read as a mistake, and border-border was faint enough here to look like no separator at all. The active tab was bg-background sitting on a bg-background/60 track: a few percent of opacity was the only thing distinguishing it. The track is gone, each tab is its own control with a gap between them, and the active one carries a border, a solid fill and a shadow so it reads as raised. Directory headings are sticky and heavier. An expanded directory can be taller than the panel, so the heading scrolled away with its contents and halfway down a long list nothing on screen said which directory you were reading. Opaque bg-muted, not a tint — a translucent sticky header shows the rows sliding under it. And the paths were rendering backwards: dir="rtl" keeps a long path's tail visible but reorders the whole string, so ~/officer-dev/platform displayed as officer-dev/platform/~ with the home marker at the end. Plain truncate now, full path on the title. |
||
|
|
d59bc0d152 |
one sessions panel with three views
The /chat sidebar was two panels in a vertical 25/75 split — Live above, Sessions below — plus a directory
dropdown in the list header. Three problems that compounded: the Live panel was empty almost always, so a
quarter of the sidebar was permanently blank; the two panels had entirely disjoint indicators, so a
conversation with a turn running looked ordinary in the history; and the dropdown showed one directory at a
time, which turned "where was I working on that" into a search.
Now one panel, three tabs. Tree answers "what have I got, by project". Latest answers "what was I just
doing". Live answers "what is running". Same rows, same indicators, one place.
Tree is an accordion over /chat/pwds, each header carrying the full path from ~, the session count, and a
live count — plus New Chat here, since the tree only lists directories that already have history. A
COLLAPSED NODE ISSUES NO REQUEST: sessions are read by scanning transcripts off disk, and eager mounting
would be one scan per directory for rows nobody asked to see. `useClaudeSessions(cwd, {enabled})` is the
whole mechanism. The per-directory live count comes from the poll's own `cwd`, never from
`liveSessionTitle`, which reads a whole transcript and whose own comment warns against calling it from
anywhere hotter than a ten-second poll.
Latest needs a new server mode, `GET /chat/sessions?all=1` → `listAllClaudeSessions`. It loops directories
rather than scanning flat, because /clear chains are merged per group and a flat scan would let two
unrelated chains in different directories collapse into each other. Fetched only while the tab is showing.
Rows now carry the model, from the batched `getChatSessionModels` — one query per page, which is what that
query was added for.
THE MIGRATION IS THE DANGEROUS PART AND IT IS INVERTED. Every persisted layout still names `chat-live`, an
appType that no longer exists; `normalizeLayout` would collapse it onto the fallback and WRITE THAT BACK,
which is what made /offscale render three server pickers this morning (
|
||
|
|
fc67973876 |
record a resumed session's model when it switches, not when the turn ends
`createMessageHandler` is built per turn, so `transcriptId` started null on every one — and
`applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while
there was nothing to key the row on. A resumed conversation therefore recorded its model only on the closing
`result`, and a turn that died took the switch with it.
Observed rather than reasoned: the sidecar log said `claude-code/fable -> claude-code/opus[1m]` while the
stored row still said `claude-code/opus`.
Seeded from the resume id, which the Claude path already has in scope for the spawn. A brand-new
conversation still waits for `session:claude` — it has no id yet by definition — but resumed is the case
where switching actually happens.
Also lands docs/chat-sessions-list.md: the plan of record for the sessions-list revamp, agreed with the owner
today. Six numbered targets, four decisions with their reasoning, and the landmines — chiefly that merging
the two left-column panels strands `chat-live` in every persisted layout, which is the same stale-appType
shape that made /offscale render three server pickers this morning (
|
||
|
|
813bc200bf |
a session remembers which model it is using
resume a conversation and the model picker showed the first entry in the catalogue instead of the model the session was actually on. that was invisible for as long as the first entry was `opus`, and became obvious the day `fable` went above it — so this is a pre-existing bug my model-list change exposed, not one it introduced. two defects, stacked. the value did not survive a refresh: `initialModel` came from `props.model ?? locationState?.model`, and both are volatile — a panel channel and react-router location state. on a hard load neither exists, the settings default is skipped (it only runs for NEW chats), and ModelSelector's fallback effect fired `onModelChange(providerModels[0].id)`. and even when it did arrive it could not match. a transcript records the API model; the picker offers CLI aliases. `claude-opus-5` is in a different id space from `claude-code/opus[1m]`, and the mapping is one-way: opus and opus[1m] both serve claude-opus-5, opusplan serves whichever model answered. read out of a real 59MB transcript, the distinct values were claude-opus-5, <synthetic>, claude-sonnet-5, claude-haiku-4-5-20251001, claude-fable-5 — note the synthetic marker and the inconsistent date suffix. so the transcript cannot answer "what should the picker show", and a new table does: chat_session_models (session_id pk, model, updated_at) officer's own id space, so opus[1m] and opusplan round-trip. keyed on the TRANSCRIPT uuid rather than officer's session id, because that is what the session list joins on — which is why the write waits for the harness to announce the id rather than firing at turn start. records the EFFECTIVE model, so a refused switch stores what answered. not derived from chat_session_events, which does now hold model:switched: right data, wrong index. that table holds every assistant message and is unbounded, and "latest model for these 200 sessions" would scan far more than the question deserves. not a chat_sessions table either — a session IS its transcript, and a row holding title and cwd would be a second source of truth for two fields that already have one. BACKFILL RUNS FROM postdb:push, so every server picks it up on its next schema push. bun does fire pre/post hooks for colon-named scripts — verified on 1.3.14 before building on it, because a post hook that silently never runs is the worst outcome here. insert-only, exits 0 on every failure path (the push has already applied; a cosmetic backfill must not make it look failed). seeded 11 of 12 transcripts on this machine. it reads the FIRST model, which sounds wrong for a table storing the current one — but switching only became possible today, so no older transcript can hold two, and first costs a bounded head read instead of reading 59MB. also: the family map returns null for mythos rather than a neighbouring family (Project Glasswing only — a picker entry that 401s is worse than none), and ModelSelector no longer picks a default once a conversation has started. an unset picker is the honest state when the server cannot say. tests: the alias map against the five real transcript values, and the selector's default gated on hasStarted. re-broke the guard to confirm it bites. still to do: `model` on the session LIST rows, which the sessions-list revamp wants. the storage and the batched read (getChatSessionModels) are in place for it. |
||
|
|
57bbcca9ae |
fable, and every model alias the CLI actually takes
the claude tier list had three entries — opus, sonnet, haiku — and the CLI accepts nine. `--help` says
`--model` takes either an alias for the latest model in a family or a full name, and the aliases the
INSTALLED cli accepts came out of its own binary with `strings` rather than from memory: default, opus,
sonnet, haiku, fable, mythos, opusplan, and a [1m] suffix on the families with a 1M-context variant.
verified against 2.1.233.
aliases rather than full ids (claude-opus-5) so a new model in a family needs no edit here. the cost is
that what `opus` resolves to moves under you — right for a picker, wrong for a reproducible eval.
mythos is deliberately left out: claude-mythos-5 is Project Glasswing only, and a picker entry that 401s
is worse than a missing one. the comment says how to add it.
opusplan is the one entry that is a MODE rather than a model — opus plans, sonnet executes.
NOT derived from the harness, though it could be: Query.supportedModels() returns the real list with
display names and effort levels, but it is a control request and needs a live session, and this endpoint
is what the composer calls before there is one. noted as open in the file.
the test pins the alias set and the property everything else rests on: claude-manager derives what it
hands the SDK with `split('/')[1]`, so a bracketed alias only works while the id has exactly one slash and
the brackets are unencoded. tidying an id into claude-code/opus/1m would fail as the harness quietly
running the wrong model, not as an error.
|
||
|
|
4c24df93c1 |
a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected. |
||
|
|
0b7f13cadc | todo: renaming a panel key silently rewrites saved layouts | ||
|
|
ed3806f998 |
installing a plugin resets its saved layout
a layout stores appType STRINGS. rename a panel key between versions and every saved layout still names the old one; normalizeLayout swaps each unknown appType for allowed[0] — whichever panel the plugin lists first — and WorkspaceView persists the repair. so renaming headscale-* to offscale-* rewrote the owner's layout into three copies of the server picker and saved it. ServersView never mounted, there was no register form on the page at all, and it survived a server restart, a sidecar restart and a plugin reinstall because nothing on disk was wrong. found by counting elements in a real browser: 3 links, 0 buttons. install is the only place that knows a plugin's panels may have just changed shape, so that is where the stale row dies — for every user, since the damage is not per-user and install is owner-only. nothing is lost: a plugin screen is locked, so the layout is the author's design rather than someone's arrangement, and useDashboardState re-seeds from defaultLayout on the next load. does NOT fix the underlying trap: renaming a panel key is still a silent data migration for any screen that is not a plugin. see TODO. |
||
|
|
2c11e42717 | register /themes in the unprotected surface so the totality check passes | ||
|
|
0d29e242a3 |
themes are installable, like plugins
`platform/themes/<id>/` — untracked, same rule as `plugins/`, only the instructions
tracked. Drop a directory in and the theme appears in the picker on the next page load.
themes/my-theme/theme.css required — `[data-theme='my-theme'] { … }`
themes/my-theme/theme.json required — { name, family, dark }
themes/my-theme/assets/ optional — wallpaper, logo
── Why this is cheaper than installing a plugin ──
A plugin is code: it needs a manifest module, mounting, and a frontend rebuild to reach
the bundler. A theme is a block of CSS custom properties, so it needs none of that. The
server reads the directory at request time and the client adds a `<link>`. No install
step, no rebuild, no restart — which is the whole reason this is eighty lines of
discovery rather than four hundred.
The id is the DIRECTORY NAME, not a manifest field, for the same reason a plugin's
`appName` is not one: an id that can disagree with where its files sit eventually will.
Discovery checks that the CSS actually targets its own directory name, because the
failure is otherwise silent — the theme appears, is selected, and nothing changes.
── Problems are reported, not swallowed ──
A malformed theme returns a reason rather than vanishing:
broken-probe: theme.css does not target [data-theme='broken-probe']
The client logs those, so an author has somewhere to look instead of wondering why
their theme never appeared.
── Boundaries kept ──
The `theme` package still performs no IO. It gained an `extraThemes` prop; Officer
fetches and injects, exactly as it supplies the panel registry to `workspaces`. A
failure to fetch is not an error — no server, no session, no directory all mean "you get
the built-in themes", which is a complete product.
`/api/themes` is a `core` capability: read-only, and not deniable, because an account
that cannot read the theme list renders unstyled. Installing remains a directory on
disk; an upload endpoint would be a way to write arbitrary CSS into the page from a
request.
Verified in-process: listing, the problem report, CSS served as text/css, and a
traversal attempt answered 404.
── Needs a restart ──
Server-side change. Until then the client's fetch 404s and silently keeps the built-ins,
so nothing breaks in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
54f63b70df |
arrows are the only global binding
`ctrl+shift+h`/`l` are gone. The arrows do that job, and two spellings for one action is what invited the reasonable guess that `ctrl+shift+j` moved down — which opens the browser console. Worth recording that the reported conflict was not one we had: nothing bound `ctrl+shift+j`. The global letters were `h` and `l` only, and the `j`/`k` keys live inside the list with NO modifier. But an interface that makes someone guess a wrong key is the interface's fault, not the guess's. Inside the list the vim keys stay, unmodified: `j`/`k`/`l`/`h` beside `↓`/`↑`/`→`/`←`. Nothing there can collide with a browser shortcut, and this is a vim person's application. Verified: `ctrl+shift+l` no longer cycles, `ctrl+shift+→` does, and `j` then `l` still browses inside the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f492905023 |
one modifier, and arrows beside the vim keys
Standardised on `ctrl+shift` for all three global bindings, and added arrows everywhere the vim keys work — `h`/`←`, `l`/`→`, `j`/`↓`, `k`/`↑` are now interchangeable. ctrl+shift+→ or ctrl+shift+l next theme ctrl+shift+← or ctrl+shift+h previous theme ctrl+shift+space the list ── Why ctrl+shift and not the alternatives ── Checked GNOME's actual defaults rather than reasoning from convention: <Control><Alt>Left/Right switch workspace <Control><Shift><Alt>Left/Right move window to workspace <Super><Alt>Left/Right switch workspace So ctrl+alt is unusable — those arrows change your workspace, and `ctrl+alt+l` locks the screen, which is presumably why the original bindings put the letters on ctrl+shift in the first place. `shift+alt` is free on Linux but is the keyboard-layout switcher on Windows and `alt+space` opens the window menu. `ctrl+shift` has no OS binding at all. Its one collision is select-by-word inside a text field — which is now explicitly protected. ── The guard moved earlier ── Typing was only checked once the list was OPEN. With arrows on the global cycle that was no longer enough: `ctrl+shift+←/→` is select-by-word in every text field, and stealing it mid-sentence to change a colour scheme would be indefensible. The check now runs before the global cycle too. Verified against a focused textarea: theme unchanged, and the word selection still happened. The first run of that test reported a hijack, and the test was wrong — it clicked a selector that matched nothing, swallowed the failure, and read body-focus as a text field. `ctrl+alt+space` still opens the list. It shipped an hour ago and fingers learn faster than they unlearn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ecd45c4a9 |
themes get their own control, and the vimazing keys
Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where a preference goes when nobody has decided it matters. It now has its own button in the header, between the controls and the avatar, grouped by family. ── The keys, ported from the owner's vimazing ── ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI ctrl+alt+space open the list j / k move the focus l or space apply the focused theme to the app BEHIND the list h back to where you started enter keep what you are looking at escape restore and close The browse is the point, and it is why the overlay is a portal over a 55% backdrop rather than a modal: you are not reading names, you are watching the application change. The theme you started on is captured when the list opens, so `h` and `escape` undo a whole browse however many previews happened inside it. Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea, select or anything contenteditable. ── Where it lives ── The key handling is in the theme package — it is behaviour that belongs to themes, and it needs no UI. The list and the button are Officer's, because a dialog would drag a component library into a package that currently has none. Verified by driving the real keyboard in a browser: start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay j, l -> tokyo-night (previewed) · escape -> plain, closed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba0c8600b8 |
chat: render images in a turn, and stop mangling the ones that were already links
Remote images were broken in the web chat and the failure looked like a network fault. It was a rewrite. injectImages scans a turn for absolute file paths ending in an image extension and points them at the file browser — good behaviour, since an agent that writes /tmp/shot.png should show it rather than make you go and open it. But 'https://host/images/duck.png' CONTAINS '/images/duck.png', so a perfectly good markdown image became  scheme stripped, host read as a directory. Every remote image failed. injectImages now skips regions that are already links — !?[..](..) alongside the code fences it already skipped — and refuses any match preceded by ':' or '/', or starting with '//', since those are the tail or the whole of a URL somebody wrote deliberately. Moved to chat-images.ts with tests, because a regex with three exceptions is exactly the thing that regresses silently. Rendering added with it: markdown images now go through ChatImage, capped at 14rem HIGH — not wide, so a wide screenshot and a tall phone capture take comparable room — and open full size in a dialog on click. A failure names the alt text and the URL, because the useful question is always which host did not answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8fa3f7da3e |
hex everywhere, because the palettes were half broken
Reported as "the ellipsis doesn't show in the streaming bubble". It was the visible corner of something much larger: every Tailwind semantic utility was broken under all eleven ported palettes. `@theme` mapped `--color-muted-foreground: hsl(var(--muted-foreground))`, which needs an unwrapped triplet. A ported palette publishes `#6e6a86`, so the mapping produced `hsl(#6e6a86)` — invalid — and the utility fell back to white or transparent: duck-dark text-muted-foreground=rgb(148,163,184) bg-muted=rgb(30,41,59) rose-pine text-muted-foreground=rgb(255,255,255) bg-muted=rgba(0,0,0,0) The themes looked convincing because panels, the page and the header read their variables DIRECTLY rather than through a utility class. Everything that went through Tailwind was quietly wrong, and the thinking dots — `bg-muted-foreground/60` — were simply invisible. So: 123 HSL triplets converted to hex across the three hand-written themes, and 37 `hsl(var(--x))` mappings unwrapped. One format everywhere, which is also the format every published palette ships in. The triplets only ever existed so `bg-card/50` could get opacity. That was tested earlier tonight and turned out unnecessary — Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for any colour format. Having proved it, I then left the mappings wrapped, which is what broke the palettes. Verified across six themes: `bg-muted` and `bg-muted-foreground/60` resolve in every one of them, and Duck's values are unchanged through the conversion — `bg-muted` is `rgb(30,41,59)` before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94a55fe679 |
panel chrome stops being black
Reported against a dark theme: the panel header's title, zoom readout and controls were hardcoded `text-black`, `text-black/60`, `hover:bg-black/10` and `border-black/10`. Legible on Duck's pale glass, invisible on every palette theme — which is the whole class of bug the theme work exists to remove, sitting in the one component I moved between packages and did not re-read. Three derived tokens, so no theme has to think about them: --panel-header-fg-dim the header's ink at 60% (the zoom readout) --panel-header-border the header's ink at 15% (the rule under it) --panel-control-hover the header's ink at 12% (control hover) The swap, drop-target and app-picker chrome used `duck-teal` and `text-white` directly. Those now use `--panel-accent` and a new `--panel-accent-fg`, so a chip on the accent stays readable whatever the accent is. Verified across five themes rather than by reading: duck-dark ink=rgb(248,250,252) duck-light ink=rgb(2,8,23) rose-pine ink=rgb(224,222,244) gruvbox ink=rgb(235,219,178) catppuccin-latte ink=rgb(76,79,105) Two comments corrected while there: both asserted the chrome "is black throughout", which was true when written and is now the opposite of true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fd78495b5a |
every theme declares the mark
The logo is now stated by each of the fourteen themes rather than supplied by a default none of them mentions. The base default stays empty, which is the right answer for a theme written by a stranger — showing nothing beats showing someone else's brand. Explicit costs one line and buys two things: a theme that ships a brand says so, and a white-labelled build replaces a line it can see instead of hunting for the default that was quietly doing the work. Verified in the browser across four themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b106f67288 |
twenty tokens is the contract; eleven palettes prove it
Officer now ships fourteen themes. Eleven of them were written by other people, ported unchanged, and none of them knows that panels, docks, glass, grids or scrollbars exist. ── What changed ── `base.css` derives every surface from the standard tokens: a panel is `card` at 92%, the header is `card` at 85%, grid lines are `border` faded, scrollbars are `muted-foreground`. Those defaults sit on `:root`; a theme sits on `[data-theme='x']` and both land on <html>, so a theme's own declaration simply outranks them. No machinery. State tones are defaulted too — shadcn's vocabulary has `destructive` and nothing else, and green-means-good is a convention rather than a palette decision. That is what drops the required contract from 29 to 20: exactly the tokens every published shadcn palette already ships. So there are now three tiers, and an author picks one: palette 20 tokens Tokyo Night, Gruvbox, Nord — surfaces derived textured + a few surfaces glass, blur, radius, a wallpaper of its own white-label + assets its own logo and background Duck is tier three and is untouched — verified by probe: it still reports `panel=#ffffff1f`, `blur=24px` and its own grid, because its declarations outrank the new defaults. ── Verified before committing, not assumed ── The whole HSL-triplet design existed to make `bg-card/50` work. Tested it: Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for a HEX token, with a `@supports` fallback. The unwrapped triplets were never necessary, and the ported palettes are plain hex. Six themes probed in the browser: each derives panels from its own card colour, including the light one (Catppuccin Latte). ── Credit ── The eleven palettes and the idea of a small standard contract come from the owner's `vimazing` repository, which solved this more simply than we had. What Officer adds is the surface layer underneath, so a palette gets a textured app for free. ── One open decision ── `--logo-src` defaults to empty, so a palette theme shows no mark at all. Correct for a third-party theme; probably wrong for Officer, where the alternative is defaulting to the officer.dev logo and letting a white-label theme replace it. One line either way. tsgo clean, suite unchanged, builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1dfadef506 |
stop plugin clones being committable, and undo the symlinks that were
Merging the workspaces branch replaced `plugins/{example,music,offscale}` — real git
clones — with symlinks pointing at themselves. The build then failed with
`SymLinkLoop`, which is a confusing way to be told your plugins are gone.
My fault, in a way worth writing down. I symlinked the plugins into a git worktree so
it could build against them, then committed with `git add -A`. The links were untracked
rather than ignored, so they went in. On master, checking out a symlink where a
directory stands removes the directory — and since the clone contents were untracked,
git did it without complaint.
Recovered by re-cloning all three from gitea. Nothing was lost that was not already
pushed there; uncommitted work inside those clones would have been, which is the part
that could have been expensive.
`plugins/*` is now gitignored with `!plugins/*.md`, so the convention that plugin
clones are not this repository's content is enforced rather than remembered. A worktree
symlink cannot be committed again.
Verified after recovery: tsgo clean, frontend rebuilds, the live app serves, and
`/api/plugins` answers 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
571deac731 |
one react-resizable-panels, and honest metadata for the peers
Two dependency problems, and only one of them was real. ── Real: two majors of the same library ── The root asked for `react-resizable-panels@^4.0.15` (4.6.5) while `src/workspaces/components` asked for `^2.1.7` (2.1.9). The workspace code targets 2.x, whose API differs — v4 has no `PanelGroup` export, no `order` prop and no `ref` on `Panel` — so a file that resolved the root's copy failed to typecheck in a way that read like a broken extraction. Nothing imported the root's version. It was vestigial, and removing it collapses the repository to one: every importer now resolves 2.1.9 and the lockfile has zero v4 entries. ── Not real: the second React ── Declaring react/react-dom in the package created `src/workspaces/workspaces/node_modules/react`, which looks exactly like the duplicate-instance bug that silently breaks hooks. It is not one. Both locations resolve to the SAME file in bun's store — the local entry is a symlink — which I established by resolving the specifier from each place instead of reading directory names, having already reported it as a hazard on the strength of an `ls`. Declared as optional peers regardless: that is the right metadata for a consumer, and it stops bun installing peers it does not need to. Notes corrected to match, since the wrong version of that story was written down. tsgo clean, package 114/114, full suite 950 pass / 7 fail unchanged, frontend builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4c97e69ecd |
extract the workspace framework into its own package
Phase 1: the Workspace/Panel system is `src/workspaces/workspaces`, and it no longer
knows Officer exists.
The proof is its test suite. 114 tests mount `WorkspaceView` under a BARE React tree —
no react-query provider, no router, no Officer hook — which is only possible because
the host now supplies everything: a registry mapping `appType` to a component, and a
layout with a way to change it.
Officer is untouched. All 24 `WorkspaceView` sites and 8 `WorkspaceLayout` sites call
exactly what they called before; `Workspace/index.ts` became an adapter that adds back
the three things that are genuinely ours — the app registry, fullscreen persistence in
sessionStorage, and the handshake that stands the nav header down for a fullscreen
panel.
── The shape, and why it is this shape ──
`core/` is the tree and every operation on it: pure, no dependencies, no React. `react/`
is one renderer. That split is phase 2's whole cost saving — a second renderer reuses
`splitPanel`, `movePanel`, `normalizeLayout` and the rest untouched, and what will not
port is the resize INTERACTION rather than the layout engine.
It was already half done: `layout-utils.ts` imported only `./types`, and `WorkspaceView`
already received `{ key, value, setValue, isLoaded }` as a prop and always wrote through
an updater. The real coupling was the registry, fullscreen, and four UI imports.
Also deleted ~175 lines of dead code — `DragOverlay`, `LayoutEditor`, `WorkspaceHeader`
— referenced by nothing.
── Two dependency traps ──
**A second React.** Declaring react/react-dom in the package produced a second copy
under its own node_modules. Two React instances break hooks at runtime and are invisible
until something renders. The package now declares almost nothing while it lives here.
**Two versions of react-resizable-panels.** The root asks ^4.0.15 (4.6.5),
`src/workspaces/components` asks ^2.1.7 (2.1.9), and this code targets 2.x — v4 has no
`PanelGroup`, no `order`, no `ref` on Panel. Inheriting the root resolution produced a
screenful of type errors that read like a broken extraction and were a version
mismatch. Pinned; the duplication itself is worth fixing one day.
── Verified ──
tsgo clean. Package suite 114/114. Full suite 950 pass / 7 fail, unchanged. Frontend
builds, and the running app was screenshotted against the pre-extraction baseline: 9 of
16 byte-identical, the rest live data and the rotating wallpaper. Panel chrome, headers,
splitters and the grid all render as before.
Docs written as part of the work: the package README is the contract, and
WORKSPACES-NOTES.md carries the open items — panel-to-panel channels, the theme
classes the package still assumes, and phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f977d26fef |
a ratchet on hardcoded colour, and the docs to match
The three previous attempts at this job died of endlessness, not difficulty. Nothing stopped the next component arriving with its own `text-emerald-400` while an old one was being fixed, so the number never fell for long. `bun run check:theme` counts palette classes per directory against a committed baseline. It does not care what the number is, only which way it moves. 942 across 21 directories today. Every directory can be paid down independently and none can quietly regrow — which turns an infinite job into a finite one. A baseline rather than a ban, deliberately: a rule forbidding palette classes outright fails on its first run, and a check nobody can make pass is a check everybody turns off. Proven by adding one `text-emerald-400` and watching it fail with the directory, the delta, and what to use instead. ── Documentation, which is part of the work rather than after it ── `src/workspaces/theme/README.md` is new and is the contract: how to write a theme, all 84 declarations by family, why colours are unwrapped HSL triplets while surfaces are complete CSS values, why assets are quoted paths, and the rules for components. Start from Plain, not from Duck — every value you change is then one you decided about. `plugins/EXTRACTING-A-PLUGIN.md` gains a rule beside the others: a plugin never hardcodes a colour, and pays its debt down DURING the extraction. The counts are named there — Soulseek 313, Invoices 95, Wallet 68 — because "some colours" does not convey that it is two thirds of the platform's total. The kickoff prompt says the same thing in the form a fresh agent will meet it. That is what makes the plugin exemption safe rather than a way of losing the work: the ratchet stops covering a directory the moment it leaves, so the rule has to travel with it. `THEMES-NOTES.md` rewritten to match reality — it still listed done things as open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df92a985d2 |
build failures say what went wrong
Every failed frontend build has reported `Bundle failed` and nothing else, for as long as anyone remembers. The cause is one line that was never there: `Bun.build` THROWS on failure by default. It throws an `AggregateError` whose own message is the string "Bundle failed" and whose `errors` array holds the real diagnostics. So the carefully written `result.success` branch — with its comment about what a failed build must not do — was unreachable, and the catch dutifully reported `err.message`. `throw: false` makes the returned result carry `logs`, which is what the code below it always assumed. The catch keeps a fallback that unpacks `AggregateError.errors` anyway: the build should no longer throw, and the failure mode this hides is exactly the one that has been wasting people's evenings. Truncation went 500 -> 1000 characters. A single bundler diagnostic with a file, a line and a code frame routinely exceeds 500, and cutting mid-path leaves it unactionable. Proven by breaking the app on purpose: before: Bundle failed after: ResolveMessage: Could not resolve: "./this-module-does-not-exist" ── The first attempt at that proof was worthless ── An UNUSED import reported `ok: true`, which briefly looked like a much worse bug — a broken app building clean. It is not: an unused import is elided by the TypeScript transform before anything tries to resolve it, so there was no error to report. The break only becomes a break once the import is used. Worth recording because "I broke it and the build passed" is an alarming sentence to arrive at by accident. Also verified that a failed build leaves `build/` alone — the staging swap does what its comment claims, so a bad build cannot take the running app down with it. This one shows up in the UI too: the Rebuild button in Settings -> System already displays `error`, so it stops saying "Rebuild failed" and starts naming the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
997f7e7a87 |
the account stores a theme, not a colour mode
Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.
`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.
The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.
── Two bugs found by testing this, both mine, both silent ──
**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.
**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.
Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:
stored: {"colorMode":"dark"} -> returned: {"colorMode":"dark","theme":"duck-dark"}
── Needs a restart ──
The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.
950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ecb6864ff9 |
delete the landing app
Built and shipped from somewhere else now, so this copy was dead weight that still carried its own third copy of the palette — `duck-teal` had three different definitions across three apps, and this was one of them. Removed: `src/apps/landing`, `scripts/build/landing.ts`, the `build:landing` script, and the tree entry in CLAUDE.md. `/landing-page-data` STAYS and is unrelated despite the name — it is the API the sign-in screen asks whether registration is open. Worth saying because a grep for "landing" hits both and only one of them was meant. Nothing imported from the app. tsgo clean, frontend builds, the live app serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
12f1e93c4a |
panels are a themed surface, not three literal rgba pairs
The cornerstone of the layout was the least themeable thing in it. Measured before touching anything, which is the only reason this is the right fix: duck-dark radius=8px bg=rgba(255,255,255,0.12) border=rgba(0,0,0,0.25) blur=24px plain radius=0px bg=rgba(255,255,255,0.12) border=rgba(0,0,0,0.25) blur=24px So the RADIUS was already following `--radius` and I had been telling the owner otherwise — the rounding in the Plain screenshot was something else. What did not follow anything was the surface: three literal rgba pairs and a fixed `backdrop-blur-xl` in the component. Now: plain radius=0px bg=rgb(255,255,255) border=rgb(0,0,0) blur=0px Two distinctions the old code drew in literals are kept as tokens rather than flattened, because both are real and a theme should be able to revisit them. `--panel-raised-*` is the fullscreen surface: DENSER glass, not darker, because panel chrome is dark text and a heavy dark fill hid every icon. `--panel-border-bare` is for a panel with no header, which has nothing else separating it from what it sits on. `--panel-blur` earns its place beyond theming: a flat theme was paying for a 24px backdrop blur it did not want, and on a solid panel the compositor does that work for nothing. ── A near miss worth recording ── The first attempt silently changed nothing. The patch matched on four spaces of indentation where the file has two, `str.replace` reported no error, the build succeeded, and `panel-blur` DID apply — so the class landed while the surface did not. It looked like a partial success and would have shipped as one. Caught by measuring computed styles again rather than trusting the edit; the fix now asserts the replacement changed the file. 950 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfb8a6d6cb |
a sixth tone, and no raw palette left in the core apps
The five core apps — FileBrowser, FileViewer, SystemMonitor, Chat, Plugins — now contain ZERO raw Tailwind palette classes. Every colour in them names a state or a surface, so all of it follows the theme. ── Busy ── The 38 ambers were the blocker, and the answer was not to pick between two wrong mappings. They mean RUNNING — Chat's own comment says "the amber a RUNNING task chip wears" — and the vocabulary had no word for it. `warning` would have made a working job look like a failing one, and `--status-in-progress` is blue. So `tone.ts` has six tones instead of five. It was always written as a closed set that could grow when a genuine sixth state appeared; this is that. `--busy-ink` comes with it, and generalises: a fill bright enough to carry black text is far too light to BE text. The other five tones are dark enough to do both jobs, which is why the problem had not surfaced. `text-amber-950` — ink ON the fill — became `text-busy-foreground`, which is what it always was. ── On fidelity, which stopped being the constraint ── Duck's exact colours were preserved up to this point because an extraction has to be provably invisible. The owner has since said the palette is not precious: the wallpaper, the graph-paper grid and the mark are what matter, and everything else can move. So shades collapse — one busy, one red, one green — rather than being approximated to keep old pixels. The verification built for the old constraint keeps its value: it proves the token layer resolves, which is a different claim from "the colours did not change". Verified: the utilities compile (`text-busy-ink`, `bg-busy`, `border-busy`, `text-busy-foreground`) and resolve in the browser. 950 pass / 7 fail unchanged. tsgo clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a52d3e06f3 |
one red and one green, in the core apps
82 raw palette classes in FileBrowser, FileViewer, SystemMonitor, Chat and Plugins now name a state instead of a colour. `docs/design-language-interface.md` has said to do this for a long time and `tone.ts` implements it; eight files were following it. Only the families whose semantic token IS the colour already in use: destructive #ef4444 == text-red-500 exact success #16a34a == text-green-600 exact So `red-500` and `green-600` are byte-identical renames, and the neighbouring shades (`red-400`, `red-600`, `green-500`, `emerald-500`) shift by a little on purpose — having one red is the point. Verified on screen, not just typechecked. Three greys became `muted-foreground`, which is what "quieter than the text" means here. ── Amber is deliberately untouched, and needs a decision ── 38 amber classes remain and a regex must not have them. They are not warnings. Chat's own comment says it plainly: "the amber a RUNNING task chip wears". It is a BUSY tone, and the token set has no such thing — `--status-in-progress` exists and is blue. Mapping them to `warning` would change both meaning and colour: Duck's `--warning` is #fbbd23 (amber-400) while the code uses amber-600 #d97706 as text and amber-950 as ink on a fill. Text at that lightness on white is not readable, and "running" would start looking like "something is wrong". Two ways out, both the owner's call: add a sixth tone for busy/queued, or accept warning's colour and meaning for it. ── Deliberately out of scope ── The plugin candidates — Soulseek 308, Invoices 95, Wallet 68, Jellyfin 48, Photos 47, Transmission 41 — are two thirds of the debt and are leaving this repository. Their colours belong to their extraction, when the rules exist and their imports are being rewritten anyway. White and black are also untouched: they are media overlays in the file viewer and photo surfaces, correct in every theme because they sit on someone's photograph rather than on a themed surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a0013e4f28 |
note that theme asset packaging is deferred, not solved
A theme references its wallpaper and logo by path because the CSS bundler cannot resolve a runtime `public/` file at build time. That is fine for themes we ship and says nothing about where a third party's assets live. The owner's intent is a convention under `public/` — recorded so the workaround is not mistaken for a design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e9cb244818 |
the wallpaper, the header and the mark belong to the theme
Asked whether these could follow the theme too. They are the three most visible things
on screen and were the three least reachable: `Background.tsx` hardcoded
`url('/landscape1.webp')`, the header carried `rgba(255,255,255,0.25)` and `#1d2724`
in an inline style, and the logo was an `<img src>`.
Between them that is most of what "the app looks like Officer" actually meant. The
Plain theme turned every panel white and all three stayed exactly as they were, which
is how they were found — the leak detector doing its job on its first outing.
Verified across all three themes:
duck-dark wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
duck-light wallpaper=landscape1.webp header=rgba(255,255,255,0.25)
plain wallpaper=none header=rgb(255,255,255)
── Images could not be done the obvious way ──
`--logo-image: url('/officer-logo.svg')` fails the build. Bun's CSS bundler resolves
`url()` at BUILD time and these files are served from `public/` at RUNTIME, so it goes
looking for a module that does not exist. Colours apply themselves; images cannot.
So a theme publishes a quoted PATH — an ordinary string token the bundler ignores —
and `useThemeAsset` reads it and wraps it in `url()`. One hook rather than the read
inline in two components, because the quote-stripping is the kind of detail that gets
forgotten the second time.
The logo is a `<div role="img">` with a background image rather than an `<img>`, since
CSS cannot set a `src` and a white-labelled build has to replace the mark from its
theme rather than by editing the header.
The header's title colour goes through a `.header-ink` class: `EditableTitle` accepts
a className and not a style, and the token stays the source of truth either way.
Panel radius and the ~985 raw palette usages are still outstanding — see
THEMES-NOTES.md.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1be4bcd39a |
prove the extraction faithful, and fix the one thing that was not
The screenshots said seven dark screens differed and could not say why. Comparing COMPUTED theme variables between the old build and the new one, in the same colour mode, answered it in a single run: 47 of 53 matched exactly, and the six that did not were the `--duck-*` variables — EMPTY in the new build. The utilities were remapped to `--brand-primary`, `--ink` and friends, which is correct. What that missed is code reading the VARIABLE rather than using the utility. `prose.css` does exactly that eleven times — chat markdown links, blockquotes, inline code, checkboxes — and `landing/styles.css` does it six more. All silently resolved to nothing. Fixed by keeping the legacy names as aliases in every theme, pointing at the new source of truth rather than duplicating values. That also covers any plugin or inline style doing the same thing, which I have not enumerated. They retire when the utility rename lands. Every variable now matches. ── What the screenshots are actually worth here ── Less than I assumed. The wallpaper rotates per page load, so a control capture of the same build against itself had 4 of 16 screens differ — and a different 4 each time. Between 5 and 6 come out byte-identical to the baseline on any run; the rest is noise I mistook for signal for two rounds, including one where I removed a `color-scheme` line to test a theory it turned out not to explain. The variable diff is the instrument that settles it, and it is cheap. Added as `scripts/theme-vardiff.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
105766a4eb |
extract the visual layer into its own workspace
The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.
Proven both directions, because either alone would be worthless:
Duck renders IDENTICALLY to before — checked against screenshots taken before
anything was touched, not asserted.
Plain renders as white with black ink, no component edits — panels turn white, the
teal avatar turns black, the grid fills vanish.
── What moved ──
`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.
Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is written out in full: a theme that only makes
sense as a diff against another cannot be authored by a stranger or sold to a
customer. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.
The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.
Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.
The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.
── Bug found by the Plain theme ──
`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.
── Tools ──
Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.
Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.
── Not done, deliberately ──
`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.
tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7980ee3761 |
photograph the app before touching how it looks
The themes work moves every colour out of the components and into a token layer, and the entire value of that is being INVISIBLE. Nothing here could have told us whether that held: the tests do not render, and comparing two screens from memory an hour apart misses exactly the things this can break — a border colour, a radius, one mode of two. Eight routes, both colour modes, against the running app. Byte comparison rather than a pixel-tolerance diff: a false alarm costs opening two images, a false all-clear costs the refactor. The first version was worthless and looked fine. It reused one browser context and added an init script per mode — Playwright ACCUMULATES those, so the dark pass ran the light script too, and the class toggle after `goto` was undone by the provider's own effect. Six of eight routes came out byte-identical in light and dark, which reads as a clean run. A context per mode fixes it, the class is asserted after settling rather than before, and identical light/dark pairs now print a warning instead of passing quietly. Verified rather than assumed: 15 distinct images of 16, every route differs between modes, and one was opened to confirm it is the authenticated app and not eight variations of an error boundary. Baselines are gitignored — 7MB of machine-specific PNGs. Regenerate with the script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
690a726f55 |
a member's turn ran as the owner after any idle gap
This is the cause. Everything before it was symptom.
`handleChat` had two spawn call sites. The first, for a new session, passed `member`.
The second — "session already live" — was a separate object literal that did not.
That branch is taken whenever OFFICER still holds the session, and officer holds it
long after the SIDECAR has let go: the sidecar reaps a session after 30 minutes idle,
`_claudeKill` stays set here regardless. So a gap sent a turn with no identity to a
sidecar with nothing to push it onto, and it created a fresh session from those
params. No member means the owner.
[claude:spawn:…] resume=49527b33… cwd=…/data/jg@pertento.ai/home as=owner
green is a Developer with their own Linux account, their own `claude` and their own
login. Every turn after a break ran as the OWNER: owner's HOME, owner's Anthropic
credential, owner's MCP config — which carries a JWT that signs as the owner. The
entire per-member boundary, undone by an omitted line in the branch nobody reads.
It surfaced as breakage rather than as silent escalation only by luck. The owner's
`claude` was handed the member's cwd — a 700 home it cannot enter — and the member's
transcript id, so it exited 1 instead of quietly working. Reported as "he went for a
walk and it stopped working", which is the idle timeout to the minute.
── The fix, and what it then found ──
Both paths now build from one `turnParams`, so they cannot drift again.
Then `member` was made REQUIRED-though-possibly-undefined in the protocol, and the
compiler immediately found three more sites that had left it out:
- `agent-handoff/deliver.ts` — the same bug, worse. No member AND a cwd from
`getOwnerHomeDir`, which ignores the email it is given. A member's agent panel ran
as the owner in the owner's home. Now resolves identity like chat does.
- `agents/agent-runner.ts`, `tasks/pipeline-executor.ts` — correct as they were:
both are `kind: 'execution'`, owner-only and never grantable. They now say
`member: undefined` explicitly, because a field you must fill in is a decision and
a field you may omit is an accident.
`resolveTurnIdentity` is exported so "whose turn is this" has one implementation
rather than three.
── What I got wrong on the way ──
Three wrong answers first — the credential, the working directory, then a recovery
whose condition could never fire — each from reading code and reasoning instead of
making the system say what it was doing. The log line that found this took five
minutes to write and should have been the first thing I did, not the fourth.
Also: `--resume` by hand appeared to work twice, which is what sent me down the wrong
paths. It proved nothing. The id I tested was not the id the platform passed.
tsgo clean, 950 pass / 7 fail unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|