Commit Graph
1488 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 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>
2026-08-18 06:10:50 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 06:03:13 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 05:12:47 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 05:08:48 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 04:56:18 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 04:55:30 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 04:46:31 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 04:23:12 +00:00
pastilhasandClaude Opus 5 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 773f0e1c printed the
raw string's last segment.

officer cannot answer this because it deliberately did not choose. the
harness does, in its system.init `model` field, which the parser was
discarding. so: emit it as a `model:resolved` chat event, translate it to an
officer id in turn-stream, and forward it to the client only when the turn
named no tier.

that last condition is the load-bearing one. the alias map is lossy in one
direction — opus[1m] and opusplan both answer as claude-opus-5 — so
forwarding unconditionally would silently relabel a user's own choice as
plain opus.

display only, and three ways: not durable (like session:claude), never
recorded to chat_session_models, and never written into the client's
selectedModel or the reattach ref. storing it would pin a session to
whatever the cli defaulted to on its first turn, which is the freedom the
bare harness exists to preserve.

untested against a live turn — the parser and translator have unit tests,
the websocket gate does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:58:35 +00:00
pastilhas 0868e637c0 handoff: two chat fixes for the next session 2026-08-18 03:39:41 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 03:19:48 +00:00
pastilhas f6658bd092 docs: kiosk replaces the marketplace, and the shape it takes 2026-08-18 03:01:09 +00:00
pastilhas d88bf7b5f5 todo: what has to be true before the plugin ecosystem opens 2026-08-18 02:46:40 +00:00
pastilhas e350c798bb todo: the three plugin-system gaps the email extraction exposed 2026-08-18 01:05:31 +00:00
pastilhasandClaude Opus 5 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>
2026-08-18 01:01:23 +00:00
pastilhas e88e3a70cf email in the bundled catalogue, the offline floor under the store 2026-08-18 00:45:12 +00:00
pastilhasandClaude Opus 5 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 (fbb6d6c7).

both survivors now read the DISK instead. findEmailAccountName() in
data-path.ts picks the newest emails.db under an account's email_accounts/
directory - no import, no query, and nothing to throw when the plugin is not
installed. the layout was already the platform's, so this costs no coupling.
the agent keeps its email_db tool and an email-context chat still opens in the
right mailbox.

the google oauth callback no longer appends /email to the dock: a plugin's
route is the plugin's business.

app-store/catalogue.ts entry deleted, and that is the whole point rather than
tidying. permissionAvailability derives from sidecar_installs and a plugin only
ever gets a plugin_installs row, so a leftover entry makes the permission
permanently unavailable - denied route, no dock tile - on a machine where the
plugin is installed and healthy. it has bitten headscale and nearly music.

two app-store tests used email as their config-only fixture; repointed at
notify, which is the same shape.

google-auth stays in core and the plugin imports it via @@/. that direction is
allowed and google-auth knows nothing about mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:28:13 +00:00
pastilhas 4655d374ec email extraction: plugin side built, platform untouched 2026-08-17 23:56:39 +00:00
pastilhas c5ed6cc32c plan of record for extracting email into a plugin 2026-08-17 23:52:09 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 23:43:27 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 23:24:05 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 22:27:09 +00:00
pastilhas 63f866750b todo: plan mode is built and needs the owner to run it 2026-08-17 22:14:42 +00:00
pastilhas 4125949468 record plan mode as built, and what to watch on the first run 2026-08-17 20:50:49 +00:00
pastilhasandClaude Opus 5 1b811dfa28 make plan mode reachable in chat
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.

verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.

canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.

sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.

plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.

not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:50:24 +00:00
pastilhas 9be484a105 verify plan mode is reachable on a bypassPermissions session 2026-08-17 19:10:24 +00:00
pastilhas 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 4c24df93, so the mechanism is proven. The hard part is ExitPlanMode: it is a
permission decision, and bypassPermissions auto-approves it, so a naive toggle produces a planner that
instantly approves its own plan. canUseTool is the hook, and whether it can be supplied after session
creation is the one thing to verify before designing any UI.

Records the landmines too — bypassPermissions is deliberate per CLAUDE.md and must be restored on exit, and
a canUseTool promise that never resolves wedges the turn.
2026-08-17 18:44:03 +00:00
pastilhas 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.
2026-08-17 18:10:10 +00:00
pastilhas 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.
2026-08-17 18:06:08 +00:00
pastilhas 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.
2026-08-17 17:58:41 +00:00
pastilhas 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 (ed3806f9). The existing guard adopted
layouts LACKING the panel, so it had to flip to adopting layouts that HAVE it. defaultLayout.test.ts pins
both halves: the replacement must not contain the appType the guard tests for, or the effect fires on its
own output forever.

Deletes LiveSessions.tsx and PwdSelector.tsx — both replaced. Fixes the inherited bug in passing: the
"generating" Loader2 never had `animate-spin`, so the one indicator meaning "work is happening right now"
was a static icon.

View and expanded-node state go in localStorage, not the URL (a tab is a preference, and every session link
would carry it), not usePanelConfig (a NO-OP setter in a locked workspace, and /chat is locked), and not
useDashboardState (its PATCH only accepts layout-shaped objects). /chat/g/<path> still works and opens that
directory in the tree.

Untested against a running server: officer has not been restarted since these changes, and I am not
restarting it mid-conversation. `?all=1` and the model field are verified only by typecheck.
2026-08-17 17:30:44 +00:00
pastilhas 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 (ed3806f9). Written down because the shape
came out of a conversation, not out of the code.
2026-08-17 17:17:52 +00:00
pastilhas 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.
2026-08-17 17:03:19 +00:00
pastilhas 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.
2026-08-17 14:28:13 +00:00
pastilhas 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.
2026-08-17 13:54:08 +00:00
pastilhas 0b7f13cadc todo: renaming a panel key silently rewrites saved layouts 2026-08-17 13:24:57 +00:00
pastilhas 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.
2026-08-17 13:24:27 +00:00
pastilhas 2c11e42717 register /themes in the unprotected surface so the totality check passes 2026-08-17 03:22:16 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 01:52:21 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 01:32:05 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 01:30:33 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 01:15:55 +00:00
pastilhasandClaude Opus 5 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

  ![duck.png](/api/file-browser/raw?path=//host/images/duck.png)

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>
2026-08-17 02:13:28 +01:00
pastilhasandClaude Opus 5 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>
2026-08-17 01:08:54 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 01:04:08 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 00:52:04 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 00:49:13 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 00:27:26 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 00:21:12 +00:00
pastilhasandClaude Opus 5 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>
2026-08-17 00:15:28 +00:00