Commit Graph
13 Commits
Author SHA1 Message Date
pastilhas 5afa2d832e step 3/4: sidecar routing keys become handles — NOT YET VERIFIED LIVE
Committed before restarting, deliberately: this changes the registration wire,
and the restart that proves it also bounces officer-claude-code, which is what
the session doing the work runs on. An uncommitted 25-file protocol change is
worse to inherit than one marked unverified.

`capabilities: ['music']` → `handles: ['music']` on the registration message,
across 20 sidecars, both plugins, the connector, the registry and the protocol
type. findSidecarByCapability → findSidecarHandling, waitForCapability →
waitForHandler.

The name: a sidecar already has `handleCommand`, so the list is literally what
it handles. `provider` was rejected — 390 existing uses and it already means
websocket door. `serves` was rejected — collides with HTTP serving, which
sidecars also do.

THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting
`handles` reads `undefined` from a sidecar sending `capabilities`, registers it
with an empty list, and every sendCommand finds nobody — chat, terminal and
music all fail with "No sidecar handling X is connected". So the whole estate
has to restart together; there is no rolling upgrade.

If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is
in-memory and nothing about this touches the database, so a revert is complete.

Found a fifth meaning of the word on the way, correctly named and untouched:
the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences).
That is now five — permissions, the item store, routing keys, Lightning wallet
features, and terminfo.

tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar.
2026-08-15 16:23:31 +00:00
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00
pastilhasandClaude Opus 5 02e049cae8 terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not
natural".

── the replay typed into the shell ──

the pty buffer was stored raw and replayed verbatim on every re-attach. anything
in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP,
the OSC colour queries — got asked again, and xterm answered correctly by writing
the reply to its input. the pty receives that as a keystroke nobody typed.

stripped on the way IN, since the buffer is the thing that gets replayed and a
live client already answered them once when they were legitimately asked. only
questions are removed; everything that draws is untouched. where a control shares
its final byte with one that draws, the parameter is enumerated rather than
wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and
stripping the second would change what a replay renders. 36 tests, both
directions, because both fail silently.

── two sockets on one session ──

handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus
whenever readyState was CLOSED — which is exactly what a pending timer leaves.
both ran. every keystroke went twice, two replay frames fought over the screen,
and only one socket was ever cleaned up because __terminalCleanup is overwritten
by whichever connect ran last. connect() is now the single guard, and a stale
socket's close no longer speaks for the session.

── the keyboard ──

alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow
sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D
(verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing
bound it. so it broke on a dependency bump, with no shell config changed.

bound in zsh rather than translated in the browser, deliberately: tmux.conf
claims M-Left/M-Right for pane switching, and a client-side rewrite would send
^[b to tmux and break it. the real sequence lets tmux handle it inside a session
and zsh outside.

ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the
OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is
assembled in machine-setup and never got them. the owner had a strictly worse
shell than the accounts they provision. confirmed with `zsh -i -c bindkey`
before and after.

also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and
every Escape, which is most of what "not natural" felt like.

applied to this host by hand — setup only runs at install. cmd+arrow is left
alone: xterm emits nothing for it, so there is no sequence to bind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:27 +00:00
pastilhasandClaude Opus 5 571d0a62ff delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports
still land in DATA_PATH/bug-reports — the disk write always happened first and
the webhook was only a ping about it, so nothing about the report is lost.

It was a personal notification channel living in deployment config, on a platform
whose owner is the only person who files reports. It was also never in
.env.example: the setup script wrote a variable nothing documented, which is the
same drift as PORT, in the other direction.

Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own
channel — and is untouched.

Then a parity sweep of setup / .env.example / what the code reads, which turned
up two leftovers from earlier today:

HOME_DIR was still read in six files, each with its own `?? homedir()` fallback.
Dead since nothing sets it, but a dead read is worse than none — it reads as a
supported override. They take homedir() directly now. user-instance.ts gets a
comment on why its line stays where it is: it sits above `process.env.HOME =
homeDir`, and homedir() reads $HOME, so a read moved below that assignment would
return whichever member was last spawned into. Two of the six had fallback chains
ending in process.cwd() and '' — the second would have silently disabled whatever
consumed it rather than failing.

VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes,
though it is a plugin variable setup has never written. Commented out with the
other plugin entries.

The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and
POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which
are required by code and deliberately unwritten until the secret store lands.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes three variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:23:32 +00:00
pastilhasandClaude Opus 5 3c7f52ab77 PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.

It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.

It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.

It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.

server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:43:39 +00:00
pastilhasandClaude Opus 5 e72cae4830 one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one
question, each defensible where it was written and none of them visible from the
others:

  server.tsx and 19 sidecars   5000    a default from before there was an installer
  user-instance.ts             9010    what scripts/setup-old/setup.sh really wrote
  .env.example                 9000    what we told people to write

5000 goes first because macOS binds it — AirPlay Receiver has owned it since
Monterey, so a dev server there fails to bind or gets shadowed by something that
answers.

All 22 sites moved together, which is the point. Changing the app alone would have
turned a consistent-but-wrong default into a split one: the app on 9000 while
nineteen sidecars still dialled 5000.

9010 was the interesting one. It was the only value that ever matched a real
machine, because it is what the old installer wrote — and it was in the single
file whose disagreement would have broken chat alone, with nothing else looking
wrong. Its own comment records the same bug being fixed once already, within the
file, by a change that left it disagreeing with everything outside it.

Note what these defaults actually are: the sidecars bind nothing. user-instance.ts
has no listener at all — it builds ws:// and http:// URLs that both address the
app's single listener. So every one of these numbers is a guess at where the app
is, for a value that .env always supplies. Worth removing rather than aligning,
which is a separate change.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; the pm2 profile loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:38:15 +00:00
pastilhasandClaude Opus 5 71589aee99 a member's terminal looks like the owner's
A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu
is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no
completion, no colour. "Their own account" should not mean a worse terminal than the
owner's.

src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather
than copied: setup.sh already deploys it for the owner, so one file serves both audiences
and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies
it to accounts that already exist — no delete-and-recreate.

The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each
used only if present, and every path is $HOME-relative — the owner's own .zshrc has three
absolute /home/pastilhas paths in it, which is exactly what a template must not inherit.
Without starship it falls back to a zsh prompt showing the same information, because a
shell that opens with a broken prompt reads as a broken machine.

Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and
never written, so there is somewhere to put your own config that no future template can
reach.

Three fixes found by running it:

- install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came
  out root:root — readable but not writable by its owner, which would have surfaced weeks
  later as one tool mysteriously failing. The parent is now created explicitly.
- useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was
  launched from. A member's shell depended on how the server happened to be started. Now
  chosen from what is installed: zsh, else bash.
- the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd
  shell via sh -c, so the login shell in /etc/passwd is the one they get.

starship moves out of the light-profile skip. The light profile exists to serve a file
browser, a terminal and chat — the terminal is one of its three reasons to be, and it is
what every member gets. Leaving starship out meant the fallback prompt on exactly the
installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only.

Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed
ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +00:00
pastilhasandClaude Opus 5 4d4a253f72 terminal runs as the member; chat is grantable and still refused
TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it
through sudo setpriv as their own account, in their own home, with the platform's
environment cleared. Verified end to end against the sidecar's own socket:

  id -u                    1001, not 1000
  file the shell wrote      owned by ptyprobe
  ps -o user=,args=         ptyprobe /bin/zsh -i
  env | grep -c POSTGRES    0

osUser and home are resolved in upgradeWs from the authenticated account, and whatever
the browser sent under those names is DELETED first. The bridge forwards the query string
to the sidecar untouched and the sidecar starts a shell from what it finds there, so
trusting the client for either would let a member ask for the owner's uid in a query
parameter.

node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they
set the ids without applying the account's groups or resetting the environment, so the
shell would keep the owner's groups and everything Bun loaded from .env.

Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list
and kill scope to the caller, and re-attaching to a session belonging to another account
is refused — otherwise a member resumes someone else's shell by guessing an id that
travels in a query string. Measured: member killing the owner's session -> ok:false,
owner killing it -> ok:true.

CHAT is confined so the owner can grant it and the route resolves, and both execution
doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent
has not moved — the SDK spawns claude itself with nowhere to put a uid, and every
transcript path resolves through the owner's home, so a member would read the owner's
session list and run an agent as the owner. Reads are refused too, because
listClaudePwds returns the names of the owner's projects.

A deliberate, temporary gap at the owner's request: permission and route now, function
when a turn can be spawned under runAs with the member's own HOME. Both guards say so,
and the registry test names them so a future edit cannot move one without the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:03:00 +00:00
pastilhasandClaude Opus 5 96ceb3aca6 delete the claude-done hook and its unauthenticated endpoint
The chain was: a Stop hook in Claude's settings curls POST /api/hooks/claude-done,
the platform POSTs /_officer/panel-refresh to the pty sidecar, the sidecar sends a
`panel-refresh` frame to every attached terminal, and the Claude Code panel bumps
`preview:refresh` and `files:refresh-signal`.

It has never fired. generateClaudeSettings writes settings.json into the MANAGED
home under DATA_PATH, but HOME_DIR points terminals at the owner's real login home
— which is where Claude reads its settings from. Verified on this machine: no
claude-done hook exists in ~/.claude/settings.json, and DATA_PATH/*/home/.claude
does not exist at all.

Deleting rather than repairing it, because the Chat panel already does exactly this
job from onTurnComplete — in-process, conditioned on the turn having made tool
calls, with no hook, no HTTP round trip, and no endpoint. The chat UI is where agent
work happens; the terminal TUI is not the destination.

Also removes /api/hooks/claude-done, which was mounted above protectedRouter and so
was the one unauthenticated write-ish endpoint on the API surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:36:35 +00:00
pastilhasandClaude Opus 5 7129cd82e6 pty: the sidecar owns its own transport
Terminals were a set of commands the platform drove. Officer sent pty:init / pty:input /
pty:resize / pty:close / pty:list over the registration socket, subscribed to ONE global
output stream, filtered every frame down to a session and rewrapped it — double
JSON-encoded — on the way out. That is terminal knowledge living in the process whose job
is authentication, and it made officer part of the data path for every keystroke.

The sidecar now serves its own loopback HTTP + WebSocket listener and announces the port
as `pty:server`, like every other HTTP sidecar. Officer authenticates the upgrade and
relays frames without reading them.

Split into three files, because "the sidecar" was one:
- sessions.mjs — the shell store. Spawn, attach, detach, resize, kill, scrollback, the
  OSC-title scrape. Clients are a Set per session, so two panels can watch one shell.
- server.mjs — the listener. /ws speaks the browser's existing contract unchanged
  ({input,resize} in, {output,replay,exit,panel-refresh} out), plus /_officer/sessions,
  DELETE /_officer/sessions/:id and POST /_officer/panel-refresh.
- index.mjs — the registration socket, and nothing else. It carries a port now.

On the platform side /api/terminal/* becomes createSidecarProxy, deleting the hand-rolled
router from two days ago, and websocket.ts drops from a translating bridge to a byte relay
modelled on the vault one. The whole PtyCommand/PtyEvent/PtyInitConfig/PtySessionInfo
vocabulary is gone from protocol.ts, connect.ts and sidecar-registry.ts.

broadcastPanelRefresh is now a POST to the sidecar: officer no longer holds terminal
sockets to loop over. Fire-and-forget — a missed refresh is a stale panel, not a failure.

The frontend did not move. The sidecar speaks what the browser already spoke.

The integration test was rewritten against the new shape, and tests something stronger than
before: officer is stopped mid-session and the shell keeps streaming, because officer is
not in the path at all. It also covers re-attach replay, the session list and kill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:54:35 +00:00
pastilhasandClaude Opus 5 fccf212fe5 tmux that actually persists, and a running-shells panel
The Tmux panel typed bare `tmux`, which starts a NEW session every time — so the panel
forgot your windows whenever the shell behind it went away, including on a
`pm2 restart officer-pty`. It now runs `new-session -A -s <name>`, which attaches if the
session exists and creates it otherwise, named per panel from panelId (stable in the saved
layout). The tmux server outlives the pty, so this survives what a pty session cannot.

CommandTerminalWrapper had the same unmount bug TerminalWrapper did — it deleted the panel
-> session mapping on unmount, so every layout change abandoned the shell AND re-ran the
command from scratch. Kept across unmounts now, same as the plain terminal.

Two things the seeded .tmux.conf needs that were missing:

- COLORTERM=truecolor in the pty env. TERM only advertises 256 colours, and COLORTERM is
  what programs check before emitting 24-bit — so we were throwing away colour depth for
  tmux, neovim, bat, delta and any modern TUI. xterm.js renders it fine.
- macOptionIsMeta. On macOS Option is a compose key, so Alt bindings never reach the shell
  — silently breaking the M-arrow and M-hjkl pane switching the config leans on. No effect
  on other platforms. Also rightClickSelectsWord, so right-click stops opening the browser
  menu over the terminal.

Adds a Running Shells panel: every shell the sidecar holds, with the title it set for
itself (usually the running command), pid, size, idle and uptime, and a kill button.
That is the other half of keeping session mappings across unmounts — the leak stops being
invisible, and stops needing curl to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:40:11 +00:00
pastilhasandClaude Opus 5 875f240e1c terminals: re-attach on reopen, and make orphaned shells findable
Closing a terminal panel abandoned its shell. TerminalWrapper deleted the panel -> session
mapping on *unmount*, so any layout or route change generated a fresh uuid on the way back
and left the old shell running: alive, unreachable, and never killed, because nothing has
ever sent pty:close. The mapping now outlives the mount, so reopening a panel re-attaches
to the shell you left — which is also what finally makes the sidecar's replay buffer worth
having. It is persisted dashboard state, so this survives a reload too.

That trades an invisible leak for a visible one: a panel deleted for good still leaves its
shell behind. So `pty:list` now enumerates live sessions, and GET /api/terminal/sessions +
DELETE /api/terminal/sessions/:id expose them. pty:close finally has a sender.

Each session carries createdAt, lastActivityAt, pid, and the title the shell sets for
itself via OSC 0/2 — usually the running command, which is what turns "some uuid" into
"the one running claude" when you are deciding what to kill.

Killing on unmount is still not an option: it needs the panel system to distinguish a real
close from an incidental remount, which it cannot currently do.

Also raises the sidecar replay buffer from 50KB to 512KB — 50KB was about one long agent
turn, so reconnecting mid-task showed you the tail and nothing before it — and cuts the
buffer on a line boundary rather than a byte offset. A blind slice can land inside an
escape sequence, and the replay then opens with the tail of a colour or cursor-move code,
which xterm renders as garbage or applies as a real instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:30:02 +00:00
pastilhasandClaude Opus 4.8 ab261cf52d move the pty sidecar under src/servers/sidecar
it was the only sidecar living outside src/servers/sidecar/ — it sat in
api/terminal/ next to the bridge that talks to it, which is the one place a reader
looking for "the sidecars" would not check. now src/servers/sidecar/pty/index.mjs,
matching every peer, with a note on the pm2 entry about why this one is node and
.mjs (node-pty is a native addon) rather than bun and typescript like the rest.

the templates/ directory went to api/users/, next to provision.ts:seedShellConfigs,
which is now its only consumer — the sidecar's duplicate seeder went with the
sandbox branch in the previous commit. api/terminal/ is left holding exactly one
thing: the websocket bridge.

no behaviour change. the pm2 entry's script path changed, so `pm2 restart
officer-pty` is not enough — pm2 remembers the old path until the entry is deleted
and started again. commands are in SIDECAR_WORK_LOG.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:03:03 +00:00