Reported after the revert: the app loads, the old layout is back, history lists — and the socket
never reaches connected.
The two doors disagreed. `createClient` accepts a token from seven places: window.officerBearerToken,
two body datasets, an `?officerToken=` query param, PERTENTO_EDITOR_AUTH_TOKEN, localStorage and
sessionStorage. The chat socket url read exactly one of them, `localStorage.BEARER_TOKEN`, so a
token held anywhere else authenticated every HTTP request and left the WebSocket with a bare
`?token=`.
That failure is silent and reads as a dead server: verified here, an empty token closes with 1002
"Expected 101 status code", and the hook's retry loop repeats it forever. Nothing logs a missing
credential, so the app looks fine in every way except the one that matters.
Resolution is now one exported function, `resolveBearerToken`, used by both. The point is that it
cannot be re-spelled: this bug is the second spelling drifting from the first.
Predates the tabs work and survived reverting it, which is the evidence it was never a panes bug.
Not fixed here, same shape, left alone deliberately: Terminal, Desktop, AudioStreamPlayer, the
pipeline and task runners, JobDetail and EmailList all build socket or fetch urls from
`localStorage.BEARER_TOKEN` directly and will fail identically for the same user.
Typecheck clean. 600 pass, 2 fail — cliamp and pty, unchanged and unrelated. Not verified in a
browser; Andre has the only client that reproduces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from
"tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the
per-server directory picker, the render-loop fix, pane transcript resolution, the send queue,
the two socket fixes from the other session, the pane-socket notes, and my own socket-set change
from tonight. He is rebuilding from here.
Deliberately KEPT: dc6b623, "talk to two officers at once from one browser". That was a separate
ask that predates the tabs one, and the multi-server client, the server chips and the connections
store stand on their own without panes. Reverting it too is one more command if that was the
intent.
Collateral, worth naming: cb7ab55 carried an unrelated MusicPlayerHost change alongside its
socket instrumentation, so that came out with it.
Reverts, not a reset — every one of these is pushed and a second session is live in this repo.
Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing
identically before this and unrelated to chat.
What is NOT explained by this revert: the browser symptoms tonight. The server was verified good
throughout — two real turns streamed back through the public URL on both models, and the full
2,281-message history came through nginx intact. Whatever the client fault is, it is still
unfound, and the pre-tabs code is where it now has to be looked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things.
The music now-playing restore is disabled on the web. The music sidecar is not running on
every machine that serves this app, so every page load fired /music/now-playing and logged a
503 in the console of a browser that was not there for music. Restoring a paused track is a
nicety; a permanent error on every load of every screen is not. The player is untouched — it
simply no longer asks what WAS playing.
And the chat socket now logs its own lifecycle: create, open, close with code and whether it
was stale or tearing down, every message received, and every message sent or queued with the
socket readyState. window.__officerWs = false turns it off.
This is instrumentation I should have added two rounds ago. A pane connects and then sits
silent, and I have now reasoned from this hook source three times without explaining it — the
browser says a socket closed and never says who closed it or whether the message left. The
handover doc says instrument before theorising and I did not follow my own note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pane on a remote server never connected: the console showed the socket closing before the
handshake finished, over and over, and the pane sat on Disconnected.
The stack named it — commitPassiveUnmountEffectsInsideOfDeletedTree plus
doubleInvokeEffectsOnFiber. The pane subtree is deleted and remounted, and the cleanup closed
the socket each time, while it was still CONNECTING. The replacement was then closed in turn.
React dev StrictMode double-invokes every effect on mount, so a fresh pane could churn
forever and never hold a connection.
The cleanup cannot tell a remount from a real unmount at the moment it runs, so it no longer
tries: the close is deferred a tick and cancelled if the effect re-runs. A remount reclaims
the live socket and the handshake completes; a real unmount has nobody to cancel it and
closes a frame later, which costs nothing.
Ruled out beforehand, by direct test: alpha accepts that exact key over wss on the first try,
with and without a browser Origin. The server was never involved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the mac: the alpha pane opened and read fine, and sending produced nothing at
all. The console showed the socket closing before it was established.
send dropped the message — readyState !== OPEN returned, silently, no error and no retry — so
enter did nothing and no turn ever started. Alpha was never at fault: the same key opens that
socket from outside the browser on the first try.
The window is not rare. React dev StrictMode double-invokes effects, so every socket is
created, closed and recreated on mount, and a reconnect reopens it again; with three chat
panes there are three sockets doing it at once, and one is always briefly not OPEN. One pane
with one stable socket is why this never bit before.
Queued and flushed on open, in order, after the resume/attach handshake rather than in front
of it. Bounded at 50 so a socket that never returns cannot grow it without limit, oldest
dropped first because the newest message is the one being waited on.
The mobile chat app has had this queue all along, for this exact reason. I read it this
morning, wrote the reason down, and did not port it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat app on the iPad does this already and this is its model, not the music app one.
Music keeps its active server — one library at a time is the right question there. Chat is
the exception: two panels side by side, one on the laptop and one on alpha, both live, no
switching.
The mechanism is one string. A panel holds a serverId; that same string picks the base URL,
the credential, the websocket host and the tail of the react-query key. Nothing global is
consulted when it is named, which is exactly why two can be live at once — there is no
active server in connections.ts at all, because there is nothing to switch.
THIS ORIGIN IS NOT IN THE LIST. It is represented by null, so every existing useClient()
call is untouched and adding a connection cannot break the app you are already signed into.
That property is what makes this shippable before anyone has tried it.
A second server is reached with an ofk_ API key minted there, verified against /api/auth/me
before it is stored — a URL typo and a key from the wrong machine are otherwise
indistinguishable from an empty conversation list an hour later.
Copied deliberately from the mobile code: the base URL is derived per call rather than
memoised (a cached one hands back whichever server was asked for first), the row stamps its
server onto the selection BEFORE navigating (or the resolver reads the transcript from this
origin, where two officers can hold the same uuid), and changing server clears the cwd and
the open conversation, because a path from the machine you left names nothing on the one you
arrived at.
Not yet opened in a browser. Typecheck and 602 tests pass, and the cross-origin request with
an API key is verified by curl, but no human has clicked any of this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maximize could only ever stop below the nav header — the content region is an `absolute z-2` stacking
context and the header a `fixed z-10` sibling, so that was the deepest a panel could get on its own.
Full screen reaches the rest by asking the shell to stand its header down, which leaves the shallow
version as a state nobody picks on purpose.
So it goes, and the two lights with it. `MaximizeMode` and the `{ id, mode }` session value collapse
back to a bare `fullscreenPanelId` — renamed because "maximized" would now be a lie about what it does
— under a new `FULLSCREEN_PANEL:` key, so a tab open across this reads nothing rather than an object
where a string belongs. `MaximizeButton` is gone; locked screens keep only the fullscreen toggle, which
writes no layout and so was always the one control the lock could permit.
Red stays. The amber used to REPLACE it while maximized so the way out could never be a way to delete;
with amber gone that guard would have cost the close button entirely, and red is already absent exactly
where it should be — locked screens render no traffic lights at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also found uncommitted in the shared tree; unrelated to agent panels, so it lands
on its own.
`reset` closed over `initialValue` from the first render, and its `useCallback` dep
list deliberately omitted it — with an eslint-disable to silence the warning that was
correctly pointing at the bug. Any caller whose default is computed (derived from
props, from a fetch, from another piece of state) got reset to whatever that default
happened to be on mount, which after the first render is the wrong value.
Reads through a ref instead, so reset always sees the current default. The
eslint-disable goes away because there is nothing left to suppress — the dep list is
honest now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adding an email account failed with a bare "failed to add account" toast. three
defects stacked, each hiding the next.
the email sidecar's http.ts reconstructs what the platform's middleware used to
provide, but only did two of three — bodyParser was never remounted, so every
write route read ctx.get('body') as undefined and POST /accounts threw on
body.provider before ever reaching the credentials.
its onError then read `.status` off the thrown custom-error, which carries
`statusCode`. every deliberate 4xx fell through to the 500 branch and had its
message replaced with "internal error", so a rejected IMAP login and a genuine
crash looked identical. it also answered JSON where the rest of the api answers
errors as plain text. now mirrors hono.ts's handler rather than inventing a
second shape.
useClient threw a plain object, so the ~33 sites narrowing with
`err instanceof Error ? err.message : <fallback>` always took the fallback and
discarded the server's message. now throws an ApiError subclass keeping both
status and message, so those sites start surfacing real errors.
only email reads ctx.get('body'); every other sidecar is a pure proxy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four channels were bare string literals repeated across files, with the payload type supplied by each
caller. Neither hole errors: a typo yields a different, empty channel — publisher publishing into nowhere,
subscriber waiting forever — and a publisher and subscriber can simply disagree about the payload with
nothing to check them. defineChannel(name, initial) returns the hook, officerdev/src/channels.ts declares
the four, and every usePanelChannel call site in the repo now passes a shared constant.
files:refresh-signal was bumped two different ways: Date.now() at the Chat sites, setSignal((n) => n + 1)
at the FileViewer ones. The increment is wrong — useGlobal's functional setter applies against the value
captured at render, so two bumps in one render window both compute snapshot + 1 and the second writes the
same number as the first. Nobody re-reads and the file that was just written stays stale. Date.now() has
the same flaw at millisecond scale, and the four FileViewer sites (save, delete, extract, transcribe) sit
close enough to hit it. useFilesRefresh's bump is a module counter that never reads React state, so it is
right however many times it is called between renders, and it is identity-stable through a ref because
useGlobal's setter is a fresh closure every render and this goes into dependency lists.
system-settings:run-command is deleted. It had a writer once — 7c0b11c wired the AI harness installer to
it — and when that install moved server-side to POST /server-settings/chat-providers/install the write
went with it, leaving a channel whose only remaining writes were clears, a terminal pane nothing could
open, and a second layout nothing could select.
PanelComponentEntry's component, header and provider are typed with { panelId: string }, which is what
PanelSlot has always rendered them with. A no-prop component is still assignable, so no screen changed.
chat:active-session and preview:refresh are declared but still have no subscriber. preview:refresh has no
plausible one — the PreviewProvider that read it is gone from the repo — but both are published by the
chat panel, and that is not this branch's to change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`<AppRegistry />` and `<WidgetRegistry />` seeded through `useGlobal`'s `initialData`, which is not a
write: it applies only to whichever component reads the slot first. They worked entirely by sitting
above `<App />` in frontend.tsx — any WorkspaceView that rendered first would have created the slot as
`{}`, with no second chance, and drawn every panel on that screen as an empty box.
Both are now plain functions taking the QueryClient, called before createRoot().render(). They take the
client rather than running as a module-scope side effect because the app list imports every panel app
and every panel app imports the Workspace framework; keeping the call in frontend.tsx, the one module
that is nobody's dependency, is what stops that being an import cycle. Making useAppRegistry default to
the static list was the obvious fix and is exactly that cycle.
registerApp and registerWidget go with the components. Nothing ever called either, and a registry that
can be added to at runtime is a registry whose contents depend on what has mounted so far.
Separately: officerdev/file-viewer was a registration for a provider fed by a `file-viewer:<panelId>`
channel that nothing writes, with availableOnPanel: false so it could not be picked either. The file
viewer users actually see is an ephemeral panel from useFileViewerPanels, which supplies the body and
header itself and reads the path from the URL. No stored layout referenced the key — zero rows across
dashboards, screens, dashboard_defaults, user_state and user_settings — so the meta and its wrapper are
deleted rather than repaired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Every verb ended in an unconditional res.json(), which throws
"Unexpected end of JSON input" on 201/204 responses that carry no body —
so a request that actually succeeded still surfaced as an error (e.g. a
sent chat message toasting a failure). Read text first and only parse
when non-empty, otherwise resolve undefined. Non-empty JSON is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the turn/session decoupling so nothing is lost across disconnects.
Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
to the queue (even while the client is disconnected) and delivered live with
its seq. Streaming deltas stay ephemeral (live-only, never persisted).
Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
the (still-live) session (cancels idle-GC via attachWs) and replays every event
since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
the max seq and, on every (re)connect with an established session, sends
resume-cursor — so a dropped connection self-heals with no manual navigate
away/back, and background task notifications that landed while offline replay.
Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.
Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every verb ended in `return data as T | any`, and `T | any` collapses to `any` —
so client.get<Foo>() handed back `any` and no annotation downstream meant
anything. That was the source of most of the implicit-any errors: the callbacks
had nothing to infer from.
Returning `as T` drops the whole class (19 errors to 9) rather than annotating
each parameter. Two calls in useAuth were relying on the looseness and now
declare their response shapes.
signin also no longer stores `undefined` as the bearer token when the server
withholds one; that path returns early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.
Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.
Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.
Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.
getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>