Commit Graph
775 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 ba664dc5c8 per-panel content zoom for every panel
soulseek had a zoom control wired into its own panel header, persisted
under its own screens/ key. move it into the framework so every panel has
it, and drop the soulseek-specific copy (its header was then identical to
the default, so that goes too, along with the orphan db row).

the factor lives on the LayoutPanel node rather than in its own
useDashboardState key: the layout is already persisted per panel, and a
separate key would seed a server row per panel on mount. absent at 1, so
an untouched panel adds nothing to the stored layout.

uses css zoom, not transform: scale. a transform repaints at a different
size without re-laying out, so the panel keeps its 100% geometry and
anything anchored or percentage-sized lands wrong — chat's composer made
that obvious. zoom scales used lengths instead: children reflow, h-full
still resolves to the panel, and rem-based tailwind text scales with it.

@container moves onto the zoomed element so container queries respond to
the effective width, the way they would in a genuinely narrower panel.

zoomable: false opts out the terminals (xterm measures its own cell grid)
and remote desktop (novnc does its own scaling and pointer mapping).

fixes chat's virtualiser under zoom: it measured bubbles with
getBoundingClientRect (rendered px) but positions them with translateY
(layout px), so at 70% every bubble was placed too early and they stacked.
new helpers/measure-zoomed divides the element's currentCSSZoom back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:58:25 +00:00
pastilhasandClaude Opus 5 575b4a5966 add a living todo for the workspace/panel framework
the framework has no tests, no error boundaries and a handful of known
defects that keep resurfacing mid-feature. write them down once, ranked,
so they can be picked off in the context of whatever is being built.

notable: the dashboards PATCH dispatcher silently drops any key family it
has no branch for, and three in use today (tmux, nvim, claude-code, all
from Terminal's statePrefix) match nothing — so that state never persists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:58:16 +00:00
pastilhasandClaude Opus 5 6d0d103c78 carry attached images through to the model
The composer already uploaded an image, split its data URL and put the bytes on the wire as
`images`. Nothing on the server read them. The `chat` ClientMessage had no such field, and the
prompt reached the sidecar as a bare string, so all the model ever saw was the client-generated
`[Attached image: …]` placeholder — a label describing a picture it was never shown.

The transport was never the obstacle: `query()` consumes an async iterable of user messages whose
`content` is an Anthropic `MessageParam`, and only `pushTurn` hardcoding a string kept it to text.
So `images` is threaded through the four hops that dropped it and turned into native image content
blocks at the end, renaming `mediaType` to the API's `media_type` at that last step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:54:39 +00:00
pastilhasandClaude Opus 5 5134501a2f walkthrough: the tab-name clone check was a guess
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:27:12 +00:00
pastilhasandClaude Opus 5 ff5095e71f a tab name should survive coming back to the page
The clone check keyed off `navigation.type`, which only reports `reload` for F5/Ctrl-R. Every other way
back into the app — Enter in the address bar, a link, re-opening the URL after the server was down — is
`navigate`, and threw away the name you typed.

Ask instead of guess: each tab holds an id beside its name, and a copy is a tab whose id is still held
by a live tab, which the original says over a BroadcastChannel. A refresh has nobody to answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:26:37 +00:00
pastilhasandClaude Opus 5 66fca28927 retry a turn the agent restart cut off
the cut-off notice is its own event now rather than an error: nothing is broken
and nothing is lost but the turn, so the row says what happened and offers the
one action that fixes it. the conversation is already durable — the claude
session id is written through to disk and passed back as resume: — so retry
just resends the prompt on a session the fresh agent picks up with full
context. read back out of the transcript, so a second window on the same
session can offer it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:00:16 +00:00
pastilhasandClaude Opus 5 876b39b301 end a turn whose agent has gone
restarting officer-agent takes every persistent session with it and nothing
downstream notices: the browser's socket is healthy, officer's subscription is
a bus filter, and there is simply never another event. the spinner ran forever
and a refresh didn't help, because the transcript has no ending to read.

keyed off the agent *registering*, not disconnecting — a disconnect fires on
every `pm2 restart officer`, when the turn is fine. a registration socket dies
with its process, so an agent appearing on it is a new one. covers the sitting
tab; the reconnect path covers the rest, with the client now sending its belief
that a turn is in flight and officer checking it against the agent over a new
claude:is-generating. the check fails toward alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:43:01 +00:00
pastilhasandClaude Opus 5 d8ee678ec4 let members sign in to the platform, which is the entire point
signin refused any non-owner arriving through the web or mobile platform
origin: "This account can only sign in through its app." so a member could
hold a gitea grant and still never reach a page — verified as a live 403
before this change.

that rule was correct while single-user was the invariant. the only
non-owner accounts were music-app accounts, and there was no way to say
"this person may use the platform, but only these parts of it", so keeping
them out entirely was the honest answer. capabilities say exactly that now,
per feature, at both doors and on every request.

so superAdminOnly is retired rather than patched. the web origin and the
platform app get no path scoping — what their caller may reach is decided by
their role, not by their Origin. per-app path scoping stays for the
single-feature apps (music, vault, tail), where it still means something.

note this WIDENS who may sign in: any Active account can now authenticate
through the browser. that is the intended product change, and it is only
safe because the capability backstop runs on every request behind it —
which is why it lands after that, not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:36:07 +00:00
pastilhasandClaude Opus 5 f283ebcba3 code block copy button is always visible
hover-revealed means most people never find it, and touch has no hover at all.
70% white on the dark block, brightening on hover; the pre reserves right
padding so a long first line scrolls up to the button instead of under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:35:50 +00:00
pastilhasandClaude Opus 5 6a682fae98 copy button on every code block
the bubble's copy button copies the whole reply, which is the wrong unit when
the reply is prose ending in one command to run. fenced blocks get their own
button; inline code doesn't. text read from textContent at click time rather
than the markdown ast, trailing newline stripped so a pasted command doesn't
run itself. the positioned wrapper takes the vertical margin, or the pre's own
margin collapses through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:22:57 +00:00
pastilhasandClaude Opus 5 29fc9722c1 interrupted by user, not an error
pressing stop ended the turn with "Claude Code returned an error" — the agent
sdk reports interrupt() as an ordinary failed result, indistinguishable from a
real fault downstream. the sidecar now flags the session it interrupted and
rewrites that event to the existing durable 'stopped', which opencode already
emitted. escape stops the turn (bound to the chat subtree, not the document),
and the prompt comes back to the composer verbatim unless you've started typing
something else. history parity: claude files [Request interrupted by user] as a
user message, so the transcript reader maps those exact strings to the same
role instead of replaying them as something you typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:18:57 +00:00
pastilhasandClaude Opus 5 f3a3ae3b64 capabilities: send the denied routes too
the server half of the previous commit, which belonged with it. the frontend
guard needs both lists: absence from `routes` cannot tell a route this
account lacks from a route no capability claims, so without this the guard
permits everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:01:01 +00:00
pastilhasandClaude Opus 5 537a77d320 capabilities: the dock a member sees, and the screen the owner grants from
useCapabilities is the frontend's view of the model and explicitly NOT its
enforcement — hiding a dock icon is a courtesy, the 403 in origin-validation
is the lock. so it fails OPEN: if the request errors the full dock renders.
a member clicking through to a 403 is a bad minute; an owner locked out of
their own platform by a transient network error is an incident, and the
server refuses what it should refuse either way.

the endpoint returns held routes AND denied routes, because absence from the
held list cannot distinguish a route this account lacks from one no
capability claims at all — `/`, the settings shell — and a guard that cannot
tell those apart either blanks the app or guards nothing. i wrote the first
version without the second list and it silently permitted everything.

`can` and `canVisit` are memoised on the query data. a verb rebuilt every
render gets a new identity every render, which is how every playback report
in the jellyfin player was disabled for days; the dock filter puts one in a
useMemo dependency list, so it would have been the same bug.

the permissions screen is one role at a time, with an explicit save and a
dirty state, rather than a roles-by-capabilities grid — a grid invites
reading across rows, which is not a question anyone has, and makes revoking
gitea for every member one click among fifty. it also states plainly why
terminal, chat, files and the rest are absent, so their absence reads as a
decision rather than as a missing feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:00:47 +00:00
pastilhasandClaude Opus 5 7700e8b540 make the editable tab name survive refreshes
sessionStorage is the per-tab store — separate per tab, survives reload and
navigation, dies with the tab. The route title is derived rather than assigned,
so navigating no longer wipes a name you typed.

Duplicating a tab clones sessionStorage, so a `navigate` that arrives already
holding a name is treated as a clone and drops it; fails soft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:59:29 +00:00
pastilhasandClaude Opus 5 dbd471c32d capabilities: the api, and one honest exception
GET /api/user/capabilities is what the caller may reach, and every account
may ask — it is mounted on a core capability so an account granted almost
nothing can still find out what it has. the dock and route guards read it.
it is a courtesy, never enforcement: hiding an icon is not access control
and the 403 in origin-validation stays the lock.

GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path
is where the registry's authority over capability keys is applied, which is
why the column has no CHECK: unknown keys and non-app kinds are refused
rather than stored for the resolver to drop on read.

the exception is `selfService`. useAuth calls PUT /api/users to change your
own name and avatar, and that route has always lived on the same router as
the owner-only account administration around it — so declaring /users an
admin capability locked every member out of their own profile. moving it to
/api/user would be tidier and would break every shipped mobile client, so
instead the registry says out loud that this one route is not what the
capability around it is. exact method and exact path, so it cannot widen:
verified that PUT /api/users passes while GET /api/users, PATCH
/api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all
still refused.

23 unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:57:56 +00:00
pastilhasandClaude Opus 5 8e9d53b2d2 capabilities: both doors now read the same declaration
replaces the account backstop. it was two hand-written lists — NON_OWNER_PATHS
confining every non-owner to /api/auth + /api/music, and NON_OWNER_WS_PROVIDERS
doing the same for sockets. they were not wrong, they were unscalable in one
specific way: an allow-list answers "which paths" but never "why", so onboarding
anyone who needed anything other than music meant editing an array in a
middleware file and hoping the socket half got edited too.

now both doors resolve against the registry, so they cannot disagree about what
a role holds. terminal, chat, task-runner, pipeline and desktop are refused by
being `execution` capabilities rather than by being absent from a list somebody
maintains.

fail-closed everywhere: an unknown capability key, a missing row, a database
error or a deleted user all deny. the grant cache is keyed on role and has an
explicit invalidation contract — unlike the one super-admin.ts refuses to have,
this one has exactly one writer and it lives beside the reader.

seeded Member → music at WRITE, which is precisely what the old path-based
backstop allowed. granting `read` would have been a silent downgrade that broke
playlists for the three live member accounts overnight.

verified against the live database and real accounts: 27 http/socket cases, the
read/write split (personal sub-paths writable at read, /music/scan not), cache
invalidation after a revoke, and the borrowed test account's role restored.
20 new unit tests; full suite 362 pass 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:55:12 +00:00
pastilhasandClaude Opus 5 c57fefa75d capabilities: grants, keyed on role
role_capabilities (role, capability, level). the subject of a grant is a
role and never a user — the owner's call, and it keeps "what can a Member
do" a question with an answer, which per-user rows would not. when one
person genuinely needs something different, that person needs a role.

a missing row means no access. nothing denies; absence denies. an empty
table is a freshly installed server where members reach nothing but their
own account, which is the right starting state.

the capability key is deliberately unconstrained: a CHECK listing the keys
would put the registry in two places and turn adding one into a schema
change. the api validates against the registry instead. what the database
does enforce is shape — a legal role, a legal level, one grant per pair, and
no rows for Super Admin, since the owner bypasses this table entirely and an
inert row that looks meaningful is worse than no row.

uniqueIndex not unique().on() per databases/CLAUDE.md. verified: pushed to a
scratch db twice, second push planned only the two known-harmless
pk_music_now_playing lines, so the declaration is stable. applied to
officer_dev without --force and without a prompt. all six constraint cases
behave — bogus role, bogus level, a Super Admin row and a duplicate pair are
each rejected; a legal grant and the same capability on another role are
accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:50:20 +00:00
pastilhasandClaude Opus 5 705d2b3235 capabilities: a registry, and a server that refuses to boot without one
permissions are capabilities, not routes. a capability is a feature — the
unit the owner grants, the dock filters on and the acl enforces — declaring
the api prefixes, websocket providers and screens it expands to.

four kinds. core is every account and is not grantable because it is not
deniable. app is the grantable surface. admin is the platform administering
itself. execution is never grantable at any level: terminal, chat, tasks,
files, desktop and browser all run as the owner's os user in the owner's
home, so granting one is co-ownership of the machine rather than a feature.

the part that matters is assertCapabilityTotality. the websocket hole fixed
in 2873948 was not a wrong rule — it was a door added without telling the
rule, because bun's route table matches /api/terminal/ws before the /api/*
catch-all that reaches hono's middleware. so the server now refuses to start
unless every mounted prefix and every user-facing socket maps to exactly one
capability. hono.ts mounts from a table and exports it, so the check reads
the real surface instead of a copy that can drift from it.

verified: passes against the live surface, and refuses all four ways — an
ungated router, an ungated socket, a claim on a deleted router, a claim on a
deleted socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:48:08 +00:00
pastilhasandClaude Opus 5 47894702ac merge a /clear chain into one conversation
The list collapses each chain to its newest link — the only one that can be
resumed — carrying the root's title and start time, the summed message count
and a part badge. The detail splices the chain's transcripts oldest-first with
a divider between parts, server-side, so the client's index-window pagination
needed no change.

The divider says "context cleared — nothing above this is in memory", because
the whole risk of merging is that the history reads as continuous when the
agent's context is not. Delete cascades the chain and the confirm says how many.

Supersedes the "continues X" line from the previous commit: there is nowhere to
link to once the parent is scrolled up above you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:44:33 +00:00
pastilhasandClaude Opus 5 3e01de100e retire a leftover ?cwd= from chat urls
nothing reads it and nothing writes it any more, but a refresh re-requests the
address bar verbatim, so one left over from before the path-based groups sits
there indefinitely looking like it means something. on a bare /chat it still
says which group you wanted, so upgrade it to /chat/g/<path> rather than
dropping it — an old bookmark keeps working. anywhere else the session decides
its own directory, so it is just removed. other params untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:27:41 +00:00
pastilhasandClaude Opus 5 942cc2b61d link cleared sessions back to the conversation they continue
/clear starts a new claude session and the list showed it as an unrelated
conversation. claude records no parent link anywhere — not in compactMetadata,
logicalParentUuid, summary.leafUuid, the per-session slug, or the live process
registry — so infer it: a cleared transcript opens with /clear, and /clear
happens inside one process, so the parent is the conversation in the same group
that was writing to disk at the instant this one began (4ms apart, measured).

matching is on per-minute activity rather than updatedAt, because resuming a
parent moves its end time past its child's birth and lost the link entirely for
two of the three cleared sessions here. the window is symmetric because clearing
makes claude summarise the conversation it is ending, so the parent's final
record can land after the child's first. ambiguity fails closed — the wrong
parent also renames the conversation.

read-only: nothing is written back to claude's store, and an explicit title
always wins.

also: cleared sessions were titled "<command-name>/clear</command-name>" because
claude does not set isMeta on slash commands; and the chat header was hardcoded
to undefined, so it read "New chat" above every conversation you opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:22:28 +00:00
pastilhasandClaude Opus 5 236541fa2a chat urls: a group is a path, a session decides its own directory
the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.

the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.

loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.

the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.

also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.

walkthrough doc gains item 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:59:39 +00:00
pastilhasandClaude Opus 5 203f65d03f add a walkthrough for the chat ui changes
twelve items in click-through order, each with where to look and what the old
behaviour was — several are only visible if you know what was broken. states
plainly at the top that none of it has been rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:34:44 +00:00
pastilhasandClaude Opus 5 9eb8fa1c17 say something when the chat fails silently
read-aloud swallowed every error: the spinner stopped, the speaker icon came back, and a
TTS service that was simply down looked identical to a button that did nothing. Both the
synthesis failure and the playback failure now say so.

errorText moves to helpers — useClient rejects with a plain { status, message } object
rather than an Error, so the reflex instanceof check reports every API failure in the app
as "unknown error". Two callers now, and it is the wrong thing to reimplement per file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:33:10 +00:00
pastilhasandClaude Opus 5 9daf42036d fix the dark-mode palette, the task tray, and the dead attach menu items
--duck-dark inverts to near-white in dark mode, so every place the chat used
it as a text or border colour was drawing light-on-light: the composer's own
text, the question prompts, the launcher textarea, the session detail bar, the
"send a message to start" placeholder. all of it moves to the semantic tokens,
along with the raw red-500s, which had no dark story at all.

the background task tray had seven labels below the 12px floor, including the
live log itself, and hardcoded green-600/red-600/amber-500 where the shared
tones exist. its detail panel was capped at a flat max-h-64 while the composer
it docks in is shrink-0 and the transcript above is flex-1 — so in a short
panel an open task could leave almost no conversation visible. capped against
the viewport too.

a failed background task drew the same CircleSlash as a stopped one: the two
outcomes you most need to tell apart were one glyph.

the attach menu offered four things and did two — "Text File" and "PDF" had no
onSelect at all. text files now inline into the composer as a fenced block
with the filename, size-capped and rejected if they turn out to be binary. PDF
is removed rather than faked: nothing in the platform extracts PDF text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:28:59 +00:00
pastilhasandClaude Opus 5 306def14f3 rebuild the chat session list on the shared data primitives
the list drew its own border per row on top of nothing, so every boundary
between two rows was a double hairline, and it emphasised two things per row
where the design language allows one. DataRow/DataList settle both.

loading and empty were the same grey sentence, which made a slow transcript
read look like an account with no history; they are now LoadingBlock and
EmptyBlock, and a failed read gets an ErrorBlock with the actual message
instead of rendering as "no sessions".

rename and delete swallowed their failures whole — useClient only raises a
dialog for 401, 403 and 5xx, and the likely error here is a 404 from a
transcript that vanished under you. both toast now, as does a deep link to a
session that cannot be read, which used to open an empty pane and say nothing.

the New Chat button was duck-teal filled with duck-yellow text: duck-teal is
a bright cyan in dark mode and duck-yellow has no dark override, so the pair
sat near 2:1 contrast in both themes.

active-row highlight now comes from the route rather than the selection
channel, so it is right on a deep link before any panel has published, and
deleting the open session navigates out of it instead of leaving a dead route.

deletes SessionBar and SessionContextMenu: the first was exported through two
barrels and imported nowhere, the second was never imported at all and typed
its session id as a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:24:24 +00:00
pastilhasandClaude Opus 5 8c2790184a render edit and write tool calls as real diffs
the most-looked-at panel in the chat was the worst rendered: an Edit showed
old_string and new_string as two flat monospace blobs, so the only question
you had — what changed — was the one thing you had to work out by eye.

adds a small LCS line diff rather than a dependency; jsdiff would ship to the
browser to do a textbook algorithm that is shorter than its own integration.
no line numbers, deliberately: Edit fragments carry no file position, and an
invented line number is worse than none.

the collapsed row now carries +n −m, so the size of an edit is legible without
expanding it, and copy yields the resulting text rather than a key: value dump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:21:03 +00:00
pastilhasandClaude Opus 5 d7f5cd5443 fix the chat's dark mode, dead air and overflow
the provider label was bg-duck-dark/80 text-white, and --duck-dark is
near-white in dark mode, so it was white on white for every session that
had already started. same class on the active provider tab.

the streaming bubble bailed on empty text, so the wait between send and
the first token — tens of seconds with thinking on — rendered nothing at
all. it now shows the bubble with pulsing dots.

also: tool status tones onto tone.ts instead of a third copy of the same
green/red/amber ternary, sub-12px labels up to text-xs, break-all to
break-words so shell commands stop breaking mid-identifier, and
break-words on the user bubble so a pasted url stays inside the pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:09:47 +00:00
pastilhasandClaude Opus 5 f4be4fd431 write the claude session id through instead of debouncing it
the sessionKey to claude uuid map is what --resume needs to reattach a
conversation after the agent sidecar dies, so a 30s debounce put exactly
the wrong state behind a window. flushAndSave on SIGTERM covers a pm2
restart but not a crash or SIGKILL, which is the case resume exists for.
an equality guard keeps onSessionId from thrashing the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:49:32 +00:00
pastilhasandClaude Opus 5 143a6453d3 carry the design language through gitea's remaining views
Commits, branches, cross-repo issues, notifications and organizations all move
onto DataRow, which is what makes them agree with each other — five lists that
were each hand-assembled from the same flex/gap/truncate parts now genuinely
share one row.

The repo header gets the same owner-muted / name-semibold split as the list row,
so the two screens agree about what a repository is called. Its description goes
from 12px to 14px, and the tab strip with it — those are read, not scanned, and
they were the smallest text on the busiest screen.

DataRow gains `href` for destinations outside the app. Notifications needed it:
Gitea's subject URL can usually be parsed back into an in-app route and
sometimes cannot, and the row should stay clickable either way rather than
becoming a dead div on the payloads that do not parse.

Empty states across the dashboard now say what the search actually covers.
"No open issues" was hiding that Gitea's cross-repo search only ever looks at
issues you created, are assigned, or are mentioned in — which is the difference
between a quiet week and a misconfigured token.

Typechecks clean and prettier is clean. Still not rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:22:57 +00:00
pastilhasandClaude Opus 5 9877a1d8e0 adopt the design language across gitea's lists
Gitea goes first because it is the first Officer surface anyone outside this
machine will see.

GiteaBits is now a thin adapter over components/Data rather than its own set of
styles: it translates Gitea's vocabulary into the five tones and gets out of the
way. Export names are unchanged so the ten call sites did not all have to move
at once.

The one judgement call worth flagging is CLOSED ISSUE -> neutral instead of a
colour of its own. Most issues in any list are closed, so giving them a tone
paints most of the screen and leaves the open ones no quieter than the rest;
neutral is what makes "open" findable, which is the only thing anyone scans an
issue list for. A closed PULL request keeps danger — that one was rejected, and
rejection is a real outcome. Merged is info, because a merged PR and an open one
were previously the same green.

Repositories and issues now build on DataRow. Repo rows put the owner prefix in
muted and only the name in medium, so one weight per row survives; issue rows
truncate rather than wrap so every row keeps one height and the list can be
scanned down its left edge. Timestamps are RelativeTime, so "2 months ago" now
carries the exact date as a hover title instead of losing it.

Swept the whole app for drift: emerald/amber/sky/red palette numbers to
success/warning/info/destructive, and every text-[10px] and text-[11px] to
text-xs. ring-black/5 and text-black/50 are gone — both were invisible in dark
mode. Nothing below 12px remains anywhere in Gitea.

Empty states say why they are empty now. "No open issues" on a repo with fifty
closed ones was technically true and useless.

Typechecks clean. Still not rendered in a browser — needs pm2 restart officer
and a hard refresh. text-xs is still 65 uses against 21 text-sm; the remaining
pass is per-case judgement about which of those are meta and which are content
that should be readable, and it is not a sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:20:15 +00:00
pastilhasandClaude Opus 5 88652910d7 add the interface half of the duck suite design language
DuckSuite_Design_Language.md covers icons only — mascot geometry, lighting,
one-idea-per-icon, readable at 64px. It says nothing about type, density or
data, which is why the chrome looks considered and every data view does not.
This is the companion document plus the primitives that enforce it.

The diagnosis it is written against, from grepping two apps: 11 uses of
text-[10px], 7 of text-[11px], 52 of text-xs and 3 of text-base, with no rule
about which meant what; six radius values; hand-picked emerald/amber/red/purple
next to unused --success/--warning/--destructive tokens; text-black/50 and
ring-black/5, which are invisible in dark mode. None of that was a bad decision,
it was the absence of one thirty times over — so the fix is to remove the choice
rather than to have better taste.

docs/design-language-interface.md sets four type ranks with a 12px floor, one
focal point per row, five state tones, three radii and a spacing rhythm. The
principles are lifted from the icon language rather than invented, because
"one focal point, reads instantly, no unnecessary decorations, if it needs
explanation it is too complicated" is already the right rule for a dense list.

components/Data/ is how you spend that vocabulary: DataRow/DataList/RowMeta,
StatusPill/StatusIcon, LoadingBlock/ErrorBlock/EmptyBlock, RelativeTime. Rules
you have to remember are rules thirty views already broke, so the shape encodes
them — DataRow takes exactly one title and everything else is meta, RowMeta
puts separators between items so a trailing dot cannot appear, RelativeTime
carries the absolute timestamp as a hover title.

Adds --info (violet) as the fifth semantic tone, light and dark. "Merged" and
"in progress" are neither good news nor bad, and painting them with --success
makes a merged PR and an open one look like the same thing.

Entirely additive: nothing imports these yet, so no existing view changes and
there is no collision with the other agent working in this tree. Typechecks
clean. Not yet rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:16:42 +00:00
pastilhasandClaude Opus 5 1e3b64c6c2 gitea: correct which endpoint mints which origin
The previous commit's explanation of retargetUrls was inverted, in the message and in the comment.
It said /user and /repos build from ROOT_URL and /contents from the public host. It is the other
way round: this instance's app.ini has ROOT_URL = https://gitea.pastilhas.dev/, which is what
/contents stamps, while /user and /repos echo the REQUEST host — loopback, because Officer dials
http://localhost:9004 directly instead of going through the reverse proxy.

Behaviour is unchanged and was already right: rewrite only a private/loopback URL, only onto a
public target. Which half of the response is wrong does not affect that rule, which is why the bug
did not show up in testing. But a comment that names the wrong cause is how the next change
re-breaks it, so this corrects the record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:19:32 +00:00
pastilhasandClaude Opus 5 10ff23c5dd gitea: the app — repos, code, issues, pulls, notifications
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.

Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.

Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.

retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.

Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.

Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:10:36 +00:00
pastilhasandClaude Opus 5 4892441ee2 gitea: clear every cached config when the owner's row changes
the config cache is keyed by user, but a member's entry holds the owner's
base — resolved at read time, since a member's row stores no url. dropping
only the caller's entry was correct while each row was self-contained; once
rows inherit, the owner moving or disconnecting the instance left every
member cached against the previous host until the sidecar restarted.

also returns the resolved instanceUrl from connectionState. members not
being able to set it is the invariant; not seeing it never was and could
not be — every avatar_url the instance hands back is on that origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:57:54 +00:00
pastilhasandClaude Opus 5 c9cc5d4a58 gitea: one instance, a token per person
Gitea is the first service where the server is shared but the account is not.
The owner connects the instance; every other user supplies only their own access
token and sees their own repositories, notifications and issues.

The plumbing was already per-user — createSidecarProxy injects the authenticated
caller's id as X-Officer-User, the sidecar refuses a request without it, and
service_connections is UNIQUE (user_id, service). What was wrong is that url and
token lived in the same row and a save demanded both, so a member would have had
to type the instance URL. That is worse than inconvenient: a member who can name
the URL has a per-user SSRF hop behind a settings form, and the sidecar would
dutifully attach their token to it.

`url` is now nullable, and NULL means "inherit the instance". The owner's row
carries the URL and IS the instance; everyone else's row is a credential. A
member's URL is therefore not stored rather than merely hidden — which is what
makes "members never see the instance URL" a property of the schema instead of a
filter somebody has to remember on every response.

Resolution lives in one place (getResolvedServiceCredentials / getServiceInstanceUrl)
rather than in each sidecar, so there is a single answer to "where is this
service" and no sidecar can accidentally trust a member-supplied URL.

Rules, all enforced in the sidecar rather than the UI, because a form that hides
a field is a suggestion and these are rules:

  owner PUT /_config    { url, token }, as before
  member PUT /_config   { token } only; a url in the body is REJECTED, not
                        ignored — ignoring it would leave someone debugging a
                        screen quietly talking to a different server
  member, no instance   409, "the server owner has not connected a Gitea
                        instance yet"
  member GET /_config   has no url to return
  owner disconnects     members keep their tokens and resolve to nothing; no
                        instance, no service

GET /_config also now answers `instanceConfigured` and `isOwner`, which is what
lets the UI tell "you have not connected yet" from "there is nothing here to
connect to" — different screens.

memos, slskd and transmission front a single daemon and always store their own
URL; they now treat a null as a malformed row rather than reaching for somebody
else's instance.

Verified on a scratch database: pushing twice adds no diff churn beyond the known
pk_music_now_playing pair, and the resolution behaves — member GET returns a null
url, member credentials resolve to the owner's base with the member's own token,
and deleting the owner's row leaves the member's token intact but resolving to
nothing. All four live rows have a url today, so the column change applies
without touching data.

Not reachable by a real member yet: the account backstop still confines
non-owners to /api/auth + /api/music. This works the moment the capability model
lands, and until then is testable only by minting a token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:08:54 +00:00
pastilhasandClaude Opus 5 e54d71da71 api: five reads that were declared as writes are now GET
The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.

  /api/file-browser/video-info         POST {url}  -> GET ?url=
  /api/file-browser/video-playlist     POST {url}  -> GET ?url=
  /api/server-settings/ocr/models      POST {url}  -> GET ?url=
  /api/transmission/_officer/port-test POST        -> GET
  /api/jellyfin/_config/:id/test       POST|GET    -> GET only

The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.

Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).

So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.

Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.

docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:38:49 +00:00
pastilhas 33ecfa989d carry the invite's device name in the link so the phone can prefill it 2026-08-06 13:24:45 +00:00
pastilhasandClaude Opus 5 2873948f98 close the websocket hole: authenticate was never authorise
A Member account could open a terminal. Demonstrated, not inferred: on 2026-08-06
a token for dingoshf@gmail.com (role Member) was refused GET /api/tasks with 403
and, in the same minute, opened /api/tasks/pipeline/ws with 101 Switching
Protocols.

The account backstop lives in originScopeMiddleware, which is a Hono middleware.
Websocket upgrades never reach Hono: Bun's route table in server.tsx matches
'/api/terminal/ws' and its siblings before the '/api/*' entry that hands off, so
NON_OWNER_PATHS was enforced on HTTP and nowhere else. upgradeWs verified the
token and the blacklist, then upgraded — it proved WHO was calling and never
asked what they could reach. None of the handlers behind it checked either;
terminal, chat and desktop have no authorisation code at all.

What was reachable with any valid token: a shell as the owner in the owner's
home, the agent with --dangerously-skip-permissions, arbitrary script execution
through task-runner and pipeline, and the owner's physical screen and keyboard
over the VNC mirror.

upgradeWs now refuses any provider a non-owner has no business on. cliamp and
cliamp-audio stay open to them — those are the socket half of /api/music, which
is what a music account exists for. An unrecognised provider denies.

NON_OWNER_WS_PROVIDERS is declared beside NON_OWNER_PATHS on purpose. They are
one rule at two doors, and the whole failure was that only one door had it;
splitting them across files is how that happens again.

The vault socket was already gated — it verifies isSuperAdmin in `open` and
closes 4001 — so upgradeWs was the only gap.

Not yet verified against the running server: platform TS does not hot-reload, so
the 101 above still reproduces until `pm2 restart officer`. The provider table
was checked in isolation: the five execution providers and an unknown name all
deny, cliamp and cliamp-audio allow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:13:06 +00:00
pastilhas 1eb2140f95 log every auth attempt that names an identity the platform does not know 2026-08-06 05:31:10 +00:00
pastilhas b6b9b01aa6 keep the headscale server form on screen when the list query fails 2026-08-06 03:09:38 +00:00
pastilhas 217d5ed29a headscale: describe an acl change in english, read the diff, then save
The policy editor now opens read-only behind an Edit button, and edit mode brings up an assistant:
describe the change, get a complete revised document back, read a line diff of it, and only then press
Save. Nobody writes an ACL from memory — it is HuJSON in Tailscale's grammar keyed to names only the
server knows — and that, not typing speed, is what made this screen unusable.

The model never touches Headscale. It proposes text, the text lands in the editor's draft, and the
existing Save button is still the only thing that leaves the browser. A model that could write the ACL
directly is one that can partition the network the owner is connected through.

The sidecar calls officer-anthropic-proxy on loopback for one request with a timeout — no session, no
agent, nothing persisted. It sends the draft on screen plus the tailnet's vocabulary (user, node and tag
names) and no credentials of any kind. It does not validate the reply either: Headscale owns the only
parser that counts, same argument as policy.ts.
2026-08-06 01:23:54 +00:00
pastilhas 08948bc4aa headscale: give the server picker its own panel 2026-08-06 01:11:17 +00:00
pastilhas 5f6377ac33 headscale: share the invite's https link, drop the deep link 2026-08-06 00:48:45 +00:00
pastilhas 91ed90893d todo: headscale invites are live on all four servers 2026-08-05 19:02:32 +00:00
pastilhas 9dd3f76e56 headscale: read the invite list out of whatever envelope the companion sends 2026-08-05 19:02:20 +00:00
pastilhas 864140998f remove stray companion probe script 2026-08-05 18:49:52 +00:00
pastilhas 3ca5968605 headscale: invites live under the companion's /api/v1 mount 2026-08-05 18:49:41 +00:00
pastilhas 4cee0335c2 headscale: device invites — admin surface for offscale enrollment 2026-08-05 18:10:44 +00:00
pastilhas 55abaa4042 headscale: probe every server when the list opens 2026-08-05 17:07:18 +00:00