Commit Graph
754 Commits
Author SHA1 Message Date
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
pastilhas 2e2eebc9fd headscale: compare node tags as a set, not a sequence 2026-08-05 17:03:40 +00:00
pastilhas 75d6b65119 headscale: key the server form to the server it edits
editing one server then another without closing in between reused the same
mounted form, which seeds its fields at mount — so the second server showed
the first one's name, url and ssh host. submit diffs that stale state
against the new server prop, so saving would have written them to it.
2026-08-05 17:02:41 +00:00
pastilhas e8b19229cb headscale: acl policy editor, node owner and tags
policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.

nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
2026-08-05 16:48:27 +00:00
pastilhasandClaude Opus 5 d6d405100c headscale: diagnostics from the officer companion
Reads the per-server Officer Companion (COMMS/HEADSCALE_COMPANION_API.md): container health with its
own diagnosis, a log snapshot, a live SSE tail, and restart/stop/start.

The companion sits at ${server.url}/officer-api behind the same admin key we already store, so there
is nothing new to register — but only the sidecar can decrypt that key, so the sidecar proxies it and
the browser never talks to the companion directly. That also rules out EventSource for the stream
(no Authorization header), which is why the tail is fetch() + a hand-rolled SSE reader.

Two inversions the code is built around: /health is always HTTP 200 and must be read by verdict, and
an absent companion is a state to render rather than an error — an HTML 502 is nginx, a JSON 502 is
the companion reporting a failed docker op, and the admin API on the same domain is independent
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:17:01 +00:00
pastilhasandClaude Opus 5 208f26ad89 headscale: an ssh console for when the api cannot answer
Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.

Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.

The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).

The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.

Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:10:33 +00:00
pastilhasandClaude Opus 5 2f92f9b15c note the headscale gaps in the todo
Found while reading the app, not while fixing anything. The policy editor is the
only one that costs an ssh session today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:48:38 +00:00
pastilhasandClaude Opus 5 acff072f5c a resting mouse is not the user
The chrome came back for a couple of seconds every few minutes during a film with
nobody touching anything. Only three things wake it, and none of them is periodic —
so the culprit is a stray pointermove. A mouse sitting on a desk still emits the odd
one-pixel event, a bumped table emits a few, and the browser synthesises a zero-delta
move of its own when the cursor style changes, which this player does every single
time it hides the cursor.

A move now has to travel eight manhattan pixels from wherever the pointer last
genuinely woke it. The anchor only advances on a real wake, so a slow deliberate
drift still accumulates past the threshold — it is jitter around a fixed point that
stops counting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:01:04 +00:00
pastilhasandClaude Opus 5 541b2a509b the player chrome can actually hide now
The auto-hide timer was there and correct; `position` was in its dependency list.
That advances about four times a second while a video plays, so the effect re-ran
and cleared the pending timeout every ~250ms and the 2.5s never elapsed. The chrome
could only hide while playback was stopped — which is exactly when the code
deliberately keeps it up — so in practice it never hid at all.

Not a fullscreen bug, though fullscreen is where a permanent scrubber is
impossible to ignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:41:57 +00:00
pastilhasandClaude Opus 5 e1cc3a2cd5 the task tray shows the newest first, and forgets the oldest
Three things about the background-task strip, all the same complaint: the chip you
want is the one you cannot see.

Newest first, because the strip scrolls horizontally and the far end is off-screen —
chronological order put every task you just started exactly where you had to scroll to
reach it. Finished chips are capped at five, so a long session stops turning the tray
into a history; running ones are never counted or dropped, since watching them is the
whole point. And the default horizontal scrollbar is ~15px tall and laid out inside the
row, which is what made the chips read as squeezed — it is thin now, and the row and
chips have the padding back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:18:37 +00:00
pastilhasandClaude Opus 5 ddc982ce5b a rescan can be told how deep to look, and a node wallet cannot be deleted blind
the gap limit was 1000, hardcoded, with the comment noting that raising it costs
node CPU and not correctness — and no way to raise it. it is the one thing about
a scan only the owner can know: how many addresses their old wallet handed out
and never had paid. RescanOptions threads from the POST body through the backend
and the source to utxos/scan, capped at 100k because past that the scan takes
longer than anyone waits. the card gets a "search depth" field beside the button,
blank meaning the default.

deleting a wallet with no seed took one unconfirmed request. the dialog asked for
the wallet's name and then threw the answer away, so the check existed only for
whoever went through the dialog — a node wallet still holds the credential, the
labels and the freezes. confirmName now travels with the request and the route
enforces it. a bodyless DELETE is told which field is missing rather than that
its JSON did not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:00:24 +00:00
pastilhasandClaude Opus 5 a61d5c3a91 frozen coins are never spendable, and a rescan already running is adopted
four wallet defects, none urgent, all cheap:

selectCoins let an explicit coin-control pick override a freeze. naming an
outpoint now overrides only the confirmed-only default; frozen is absolute.

balances counted frozen coins in onchainConfirmed, so Send showed a figure a
max-value spend could not reach. Balances gains onchainFrozen — a component of
confirmed, not a deduction — filled at the route layer, because freezing is
Officer policy in Postgres and no backend can see it. The route only reads utxos
when something is actually frozen. Send subtracts it under "Spendable";
Overview lists it beside unconfirmed.

a rescan in flight upstream was invisible after a sidecar restart, and a second
POST would have queued behind it (scantxoutset is single-threaded node-wide).
adoptRescan polls an existing NBXplorer scan instead of starting one, and the
GET route falls back to it when local state is gone.

the per-variant scan deadline counted queue time, so a variant that sat behind
another wallet's scan timed out without ever having run. the deadline now
refreshes while the status reads Queued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:56:37 +00:00
pastilhasandClaude Opus 5 e0f6a469aa rate-limit every passphrase check, and stop address issuance outrunning the scan
two holes found auditing the wallet sidecar after the frozen-utxo fix.

the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".

nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:41:46 +00:00
pastilhasandClaude Opus 5 bc06fbb2a5 honour frozen coins in automatic selection, not just coin control
freezing a coin promised it would not be spent, and the promise only
held when the caller named outpoints explicitly. an ordinary send picked
its own inputs from a snapshot in which every utxo said frozen: false —
the chain has no idea what officer froze — so selectCoins, which has
always filtered on that flag, never saw one set. sendAll was the worst
case: "send everything" swept the frozen coin too.

the frozen list now travels with the request, set by the route and
overwritten if a caller supplies one. it can only ever restrict what is
spendable, so smuggling a value in gains nothing. the backend still
reads no officer table.

three tests pin it, including a control that sends successfully once the
coin is unfrozen — without it the other two would pass on a wallet that
could not spend at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:25:49 +00:00
pastilhasandClaude Opus 5 1d9a648ff7 stop the generic wallet PATCH from being able to replace the seed
the route typed its body as {name, defaultBip, config} and passed the
parsed object straight to updateWallet, which also accepted sealedSeed
for the passphrase change. a TypeScript annotation strips nothing at
runtime, so any authenticated caller could send a sealedSeed key and
overwrite the encrypted seed — no passphrase, no unlock. encryptSecret
encrypts nonsense happily, so the damage would have surfaced at the next
unlock, not at the write.

updateWallet can no longer touch the seed at all; resealing moves to
replaceSealedSeed, whose only caller has already proved knowledge of the
old passphrase. the route rebuilds its patch field by field as well, so
the next field added there cannot re-open it.

verified against the test wallet: the envelope is byte-identical after
the same request that previously would have replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:20:12 +00:00
pastilhasandClaude Opus 5 d2290baa56 hide the rescan control on a source that cannot rescan
the card keyed off `sync.rescan` being null, which is what esplora
reports — and equally what nbxplorer reports before its first scan. so
the guard could not tell "unsupported" from "not yet run" and resolved
it the wrong way: every esplora wallet got a button that 501s.

canRescan carries the distinction explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:08:43 +00:00
pastilhasandClaude Opus 5 22c0e83b49 recover an imported wallet's coins with an nbxplorer utxo scan
registering an xpub only indexes it from that moment on, so an imported
seed with history read as a confident zero: every call succeeded, the
coins were simply absent. scantxoutset walks the node's current utxo set
directly and finds them regardless of when the account was registered.

runs all four script variants sequentially — the funds could be on any
one — and surfaces progress through the existing SyncState channel so
the balance says "scanning" rather than nothing. auto-fires on an
imported mnemonic only; a generated seed has no history to look for.

recovers spendable coins, not spent history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:06:10 +00:00
pastilhasandClaude Opus 5 82aa39a05f chat: pin background tasks above the input and let you look inside them
Background tasks already had a row in the transcript, but a row scrolls away — and a
task started ten minutes ago is precisely the one you want to keep an eye on. The same
`role: 'task'` rows now also drive a tray docked above the chat input: a chip per task,
running ones pulsing, finished ones dismissable.

Clicking a chip opens what the task is actually doing right now. Nothing about that
crosses the wire between `task:started` and the notification, so it is read from the
file Claude Code streams the task into:

  $TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output

For a backgrounded shell that file IS the log; for an agent it is a symlink to the
subagent's own transcript, which is ordinary session JSONL and so parses with the
reader we already had. Both kinds are therefore reachable from one directory.

Resolution is by task id alone, deliberately: the client learns a task id from
`task:started` and nothing else — officer's per-connection session key is not Claude's
session uuid, and the uuid only arrives with the turn result, long after the tray needs
to show the task. A task that has not written anything yet answers 200 `{kind:'pending'}`
rather than 404, because that is the ordinary first second of a task's life.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:40:26 +00:00
pastilhasandClaude Opus 5 6fde7afd7f docs: refresh the pm2 process list
it said fourteen; there are nineteen. photos, notify, caldav, memos and jellyfin have landed
since it was last written. also names the two mybiblepal entries that share this pm2 daemon
and belong to a different project, and points at `pm2 jlist` as the source of truth so the
next reader does not trust a list that goes stale every time a sidecar ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:27:33 +00:00
pastilhasandClaude Opus 5 4b094aa3a5 jellyfin: actually report playback progress to the server
nothing the player did ever reached jellyfin, so nothing ever appeared in continue watching
or in the currently-playing list. the proxy was fine — a progress POST through the sidecar
moves the resume point upstream and answers 204. the client was the problem.

useClient() rebuilds its verbs on every render, so `report` had a new identity every render,
so `sendReport` did, so the effect whose cleanup reports the final position re-ran on every
render — and that cleanup nulls planRef. planRef went null a few milliseconds after the
stream opened and every report after that returned early. the ten-second heartbeat never
fired either: its interval was cleared and restarted on every render, and timeupdate renders
about four times a second.

both are now held in refs, and the two effects have honest dependency lists. same treatment
for the negotiate mutation, which react query also rebuilds per render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:16:37 +00:00
pastilhasandClaude Opus 5 7884bd3290 jellyfin: subtitle tracks, audio picker and real player controls
the sidecar now describes every audio and subtitle stream on the chosen source. a subtitle
jellyfin can deliver as an external file gets a /_jf webvtt path with the embedded ApiKey
stripped; one it cannot gets the reason instead, so the picker can show it disabled rather
than pretend it does not exist. colliding labels get their stream index — two tracks called
"English - ASS" are a coin flip otherwise.

quality is a named rung rather than a number. a bitrate the owner picks maps onto the rung
at or below it, which is what stops jellyfin's ResolutionNormalizer from inventing a
resolution from an unrounded number. auto stays absent so a copyable source still copies.

the control bar is ours in all three transports. progressive has no timeline the browser can
render, and an audio-track or quality change is a re-negotiation with the server rather than
something a <video> knows how to do — one bar for all three is what keeps those from being
three different players. the menus are hand-rolled because a radix dropdown portals to
document.body, which is outside the fullscreen element and would be invisible exactly when
the player is most likely to be used.

no scrubber thumbnails: they come from trickplay tiles and this server generates none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:10:44 +00:00
pastilhasandClaude Opus 5 7732ca1da5 vendor hls.js and play transcodes over hls
a live transcode has no length and no byte ranges, so the progressive mp4 the player
used could not be seeked — a scrub restarted ffmpeg at a new offset. jellyfin's own
HLS playlist is VOD and spans the whole runtime, so seeking it is a segment request.

hls.js is checked in rather than installed. installs are frozen so that adding a
package is a reviewed act, and a committed file also has no install-time hook, which
is the vector the 2026-08-04 npm worm used. provenance, hashes and the update recipe
are in vendor/README.md; the tarball sha512 matches the registry's published integrity.

the sidecar now overrides VideoBitrate and MaxWidth on the TranscodingUrl jellyfin
hands back, for the same reason progressivePath computes them: jellyfin resolves that
bitrate from MaxStreamingBitrate (~119 Mbit, the ceiling that exists to let a stream
copy through) and sets no width, which asks a CPU-only container to encode 4K.

safari is deliberately not given the m3u8 — segment URIs are relative and would not
carry the ?token=, and it cannot set an Authorization header the way hls.js can.
progressive stays as the fallback for any browser without MSE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:52:15 +00:00