be259f813a603c13a4b420b1ee3d4e4e04b71fe9
939
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
035a1ba8f6 |
add photos, an immich-backed library behind its own sidecar
officer-photos owns the whole Immich contract: the instance URL and the API key live there and nowhere else, and the platform side is an auth-gated forwarder holding no credentials. The route surface is an allow-list keyed on the first path segment, so admin, auth, api-keys, sessions, jobs, system-config and libraries are unreachable by construction rather than by enumeration. The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums, people, favorites, sharing, archive, trash — because the point of a sidecar screen is to reproduce what the upstream already ships, then extend it. The timeline reads Immich's columnar time-bucket format directly; selection lives in the URL per docs/navigation-audit.md. Two things worth knowing for anyone touching this later: - `duration` is an integer count of milliseconds in Immich 3.0. It was an HH:MM:SS.mmm string before, and every stale example still shows that form. - the map container is sized with h-full/w-full, never `absolute inset-0`. maplibre's stylesheet sets `position: relative; overflow: hidden` on the element it is given, and an unlayered vendor rule beats Tailwind 4's layered `.absolute` regardless of source order — so the div collapses to height 0 and clips its own canvas away. Nothing errors: the GL context is healthy, tiles download and pixels are drawn into a buffer nobody ever composites. maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file from import.meta.url, which Officer's index.html fallback answers with HTML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4f4e0c5dbc |
music: drop the fs watcher, and let the incremental reindex self-heal
Bun's recursive fs.watch takes one inotify watch per ENTRY, files included — ~92k for this library against a 65536 ceiling — so the watch could never be established. The ENOSPC came back asynchronously as an FSWatcher 'error' event with no listener, which rethrew and killed the sidecar 17k times, draining the per-UID watch pool for every other process on the machine along the way. Reindexing is triggered instead (the browser button, the phone's pull-to-refresh, the nightly full); an incremental over 6273 folders measures 1.8s. Three index defects the nightly full had been papering over: - outputsExist verified meta.json/cover.jpg/discography.json but neither lyrics/ nor posters/, so a lost lyrics file kept a matching v and a passing check and the album was skipped on every incremental forever — only a full restored it. Record both counts in the manifest and compare them (CACHE_VERSION 2 -> 3). - walk() read a failed readdir as "the folder is gone", and runBuild prunes whatever is missing from next — so one transient EIO on the library disk deleted that folder and its whole subtree from the index. Carry the previous entries forward for every error but ENOENT/ENOTDIR. - a from-scratch build has no previous entries to carry, so it now refuses to publish a slot when any folder was unreadable, leaving the live index alone. A disk that hiccups during the nightly costs a skipped night, not a hole. reindexNow builds in place, so a cache-format upgrade is handed to the staged path rather than rewriting 6k albums underneath live readers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
00117206d9 |
vnc: reclaim port 5900 before mirroring, and do not trust a foreign listener
Re-applies to the mirror what
|
||
|
|
25da4dc31d |
vnc: back to mirroring the physical screen, at 1920x1080
Reverts the Xvnc virtual-desktop work ( |
||
|
|
5ba89ae74d |
desktop: restore scaleViewport — disabling it doubled every pointer coordinate
Reverts the scaleViewport=false half of
|
||
|
|
4cf39a6ed3 |
desktop: show a dot cursor when the server sends no cursor shape
The pointer was invisible, not misplaced. noVNC hides the browser's own cursor over the canvas and draws the remote cursor in its place, but initialises that image to RFB.cursors.none — so until the server sends a shape, nothing is drawn AND the real cursor stays hidden. The pointer simply vanishes. Measured before changing anything: against a 1109x715 session the pointer covered x 5..1108 and y 11..714, a clean 1:1 map with no clamping or scaling. Clicks were landing exactly where they were aimed the whole time; the cursor just could not be seen, which is indistinguishable from a desync when you are the one trying to click something. showDotCursor renders a dot in exactly that case. It does not mask a real problem: when the server does supply a shape, the shape still wins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
69c750dd72 |
desktop: resizeSession without scaleViewport, so the cursor lands where you click
Both were enabled. They are alternative strategies, not complementary ones: resizeSession asks the server to become the container's size, scaleViewport scales whatever the server sends to fit. Running both means noVNC resizes AND then applies a scale factor, and any gap between the size requested and the size actually granted leaves a fractional scale that every pointer coordinate is mapped through. The visible symptom was a cursor offset from the real pointer, with clicks landing somewhere else. It only started mattering with the move to Xvnc. x11vnc could not resize, so resizeSession was inert and scaleViewport did all the work; Xvnc implements RandR SetDesktopSize, so now both are live and they interfere. Scaling is redundant now the session genuinely becomes the container size: pixels are 1:1 and there is no coordinate arithmetic left to get wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f22c667502 |
vnc: reclaim the port before starting, and do not trust a listener we did not spawn
Closes the TODO item about orphaned/duplicated VNC servers across sidecar restarts. The diagnosis there was correct and outlived the move off x11vnc, because the shape of the bug is in the lifecycle, not the server: the running desktop lives in module state, a sidecar restart forgets it while the process keeps running, and waitForPort accepted ANY listener on 5900 as proof of a healthy start. The next start would then spawn a server that could not bind the port, see the ORPHAN listening, and report success — leaving the platform convinced it had started a desktop the browser was not looking at. Two changes. reclaimPort frees the port before spawning: TERM whatever holds it, KILL after two seconds. waitForPort now also fails when the process we spawned has exited, so a foreign listener cannot be mistaken for our own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
83bf746ab8 |
vnc: a dedicated virtual desktop instead of mirroring the screen
The TV runs at 4K so it can play 4K video; a usable remote desktop wants about 1080p. One framebuffer
cannot be both, so mirroring meant every remote session was a scaled-down 4K desktop — dense to read
and expensive to encode. This gives remote its own display at 1920x1080 and leaves the TV alone.
Xvnc (TigerVNC) rather than x11vnc: it is the X server AND the VNC server in one process, so nothing
polls or scales — the server knows which rectangles changed and encodes them directly, where x11vnc
had to diff a framebuffer it did not own. It also implements RandR SetDesktopSize, so the client's
existing resizeSession makes the desktop resize itself to the browser panel. No scaling on either
side at any panel size, which removes the density problem rather than trading it for blur.
XFCE rather than GNOME, and NOT because it is lighter. Ubuntu's GNOME is managed by per-USER systemd
units — org.gnome.Shell@x11.service, gnome-session-manager@ubuntu.service and the whole
org.gnome.SettingsDaemon.* set all sit under user@<uid>.service, and gnome-session@.target is marked
RefuseManualStart. A second GNOME session for the SAME user collides with every one of them. That is
almost certainly what
|
||
|
|
51b249b90a |
desktop: load noVNC as one bundle instead of 42 modules
public/novnc is unbundled noVNC source. The dynamic import pointed at rfb.js, so the browser walked the module graph natively: fetch a file, parse it, discover its imports, fetch those, repeat. The graph is 42 modules and six levels deep, so opening the desktop cost six SEQUENTIAL round trips and 42 requests before the VNC handshake could even start — and a hard refresh pays it in full every time. Over the tailnet that is the "takes a long time to load", not the pixels. Bundled with bun: 52 modules to one 190 KB file, 56 KB gzipped, one request. The regeneration command is in a comment next to the constant so a future noVNC bump does not silently keep serving a stale bundle. The source tree stays: it is what gets bundled, and keeping it makes the diff of a version bump readable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
794420810c |
vnc: count enabled outputs, not connected ones
A screen that is plugged in but switched off still reports "connected" to xrandr while contributing nothing to the framebuffer — which is exactly the state this machine is now in, with the unused KVM output disabled. Counting those made the mirror take the multi-output path and clip to the primary when there was only one live screen; harmless here because the clip equalled the whole framebuffer, but wrong, and it would have masked a real single-screen case. Only an enabled output carries a WxH+X+Y geometry, so requiring that in the pattern is what tells the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2ddefa000c |
vnc: stop disabling XDAMAGE
-noxdamage was set in
|
||
|
|
c14cce6376 |
vnc: clip the mirror to the primary output
X composes every attached output into one framebuffer, so with a 4K monitor at +0+0 and a 1080p TV at +3840+0 the framebuffer is 5760x2160 and mirroring it whole sent BOTH screens side by side, then halved them for being over the scale threshold. The remote desktop showed a squashed double-width image with the second monitor hanging off the right — correct, and useless. Clip to the primary output instead: 3840x2160+0+0 here, which then scales to a clean 1920x1080. Only clips when a primary is actually marked AND more than one output is connected. With a single output the framebuffer already IS that screen, so clipping would add a failure mode for no gain. The scale decision now keys off what is really being served — the clip when there is one, the whole framebuffer otherwise — instead of a framebuffer width that may span screens. Never showed up under LightDM because only one output was ever live there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3b941f4d1f |
vnc: find the display instead of assuming :0
The mirror hardcoded :0. That held under LightDM, which gave the user session :0. GDM does not — it keeps :0 for its own greeter and starts the user's Xorg with -displayfd, letting the number be picked at runtime; on this machine the session lands on :1. So after migrating to Ubuntu Desktop the mirror failed on every attempt with "Can't open display :0", with a healthy session sitting one number over. Resolve by socket ownership: /tmp/.X11-unix/X<n> is owned by whoever runs that X server, so the socket owned by us is the owner's session and anything else is the greeter's. Falls back to the lowest socket (a root-run Xorg, as LightDM had) and finally to 0, so it is never worse than the constant it replaces. The display is now threaded through rather than read from a module constant, so getFramebufferWidth measures the display actually being mirrored and both log lines name it. Found while verifying the XFCE-to-GNOME migration on this machine — the session was up and x11 with the cookie in the GDM path, and only the display number was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
525557a952 |
data-path: the agents item type and run directory the router needs
Second half of |
||
|
|
c69cda480d |
add the agents router files that 3f22a80 referenced but never committed
|
||
|
|
7d5e73cba8 |
soulseek: let a filtered result expand back to its whole folder
Filtering a big search is how you find an album, not just how you hide files: you type one track you know, and the folder that comes back is the one you want. But the filter had also stripped that folder down to the one track, and "download folder" then queued only that track — so finding the album and losing it were the same act. A folder now keeps its whole self alongside its matching files, and says "3 of 24". Expanding shows the album, keeps the matched track highlighted, and widens every download button in it to the full folder. A peer whose other folders matched nothing can be expanded the same way, since the album you found usually sits next to the rest of the artist. Nothing queues what isn't on screen: each button downloads exactly what is shown under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
93bf617151 |
soulseek: open a search's results when you start it
A history row is only as fresh as the last list load, and the list was only read on mount — so a search you had just started sat at 0 responses looking stalled until you navigated away and back. slskd's own web UI goes straight to the search when you submit, and that view already polls while the search runs, so follow it. Which search is open now lives in ?search=<id> rather than local state: rows are real links, Back returns to the history, and a reload keeps your place. The list also keeps re-reading while any search is still running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
82290bb3e0 |
add /qr-transfer — offline file transfer over animated QR
TEMPORARY / EXPERIMENTAL, at the owner's request, after deedy/qr-data-transfer (QRFerry).
Two panels: one loops a file as QR frames, the other scans them through the camera and
rebuilds it. Entirely client-side — nothing about a transfer reaches the server, which is
the point of the technique.
NOT RaptorQ, and that is the one real design decision here. QRFerry carries RFC 6330
fountain-coded symbols so a receiver can rebuild from ANY sufficient set of frames. This
uses a plain indexed carousel instead, for two reasons — the second being the deciding one:
1. RFC 6330 is days of work and unpleasant to debug.
2. The sender and receiver are being reimplemented on iOS and Android. A format one person
can re-derive from protocol.ts in an afternoon is worth more here than optical
efficiency. Every frame is independent, self-describing, and parses with a string split.
The cost is honest and written down: without fountain coding you must eventually capture each
specific frame, so a miss waits for the next pass rather than being covered by surplus. Fine
for a few hundred KB on a steady camera; it degrades where RaptorQ would start to pay for
itself.
Details that matter for the phone implementations:
- base64url, no padding — ':' and '/' would collide with the field separator.
- CRC-32 of the whole file in the meta frame, checked after reassembly. The test pins the
reference value for "123456789" (cbf43926) so any stock implementation will agree.
- The meta frame repeats every 12 frames, so a receiver joining late learns the filename and
total without waiting a full cycle.
- Error correction level L: frames are short-lived and repeated forever, so QR capacity is
better spent staying sparse enough to scan than on recovery.
16 tests over the protocol, which is the spec the other implementations should match.
The receiver needs a secure origin for camera access; over the tailnet with HTTPS that holds,
and it says so plainly rather than failing silently on plain http.
Adds qrcode and jsqr. @types/qrcode was already present and orphaned — its runtime package had
been removed with the chat-channel cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
dc1b0636bf |
notify: a temporary test button on /jobs
The first producer, so the pipe can be exercised from the UI before any real event is wired
to it. Marked TEMPORARY in the source and meant to be deleted when a real producer replaces
it.
POST /_officer/notify now takes the user from the X-Officer-User header the proxy injects
when the body omits it. A producer inside the tailnet says who to notify; a browser reaching
this through /api/notify cannot know its own id, and the platform has already authenticated
whoever sent it. Body still wins where present.
The button sends { type: 'test' }, which fans out to every configured channel — Discord
today, APNs and FCM the moment their credentials exist — and toasts what each one reported,
so "no channels configured" is distinguishable from "sent".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
93796d882a |
notify: the FCM channel
Step 4, and the last transport. Google directly via FCM HTTP v1 — no Expo, no firebase-admin. Unlike APNs the signed JWT is not the credential: it is an assertion exchanged at oauth2.googleapis.com for a 1-hour access token, so there are two things to cache and a network round trip on the cold path. Concurrent pushes share one in-flight exchange rather than each starting their own, and the token refreshes five minutes early so a request cannot race the expiry. The detail that breaks most first FCM integrations: `message.data` values must all be STRINGS. A number or boolean is rejected with a bare INVALID_ARGUMENT that does not say which field. Everything is stringified on the way out — id, ok and count included — and a test walks every value asserting its type rather than trusting the code that wrote it. Also v1-specific: there is no multicast (the /batch endpoint is deprecated), so N devices is N requests, which happens to match the APNs shape anyway. Errors are read for `error.status` only. FCM's `message` field can echo the device token, so logging the whole body would put device addresses in the logs. UNREGISTERED / INVALID_ARGUMENT / NOT_FOUND delete the row; anything else counts a strike. 21 tests across both channels, and verified against the real endpoint: a throwaway key gets 400 invalid_grant "account not found" from Google, meaning the endpoint, form encoding, grant_type, RS256 signature and claim structure were all accepted and only the account is missing. A malformed assertion would have failed earlier, with a different error. The doorbell rule is asserted on this channel too: a producer passing subject/from cannot get either into the serialized message. Still needed for a real send: FCM_SERVICE_ACCOUNT, a Firebase project, and google-services.json in the Android build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
707a2a5ba3 |
notify: the APNs channel
Step 3. Apple directly over HTTP/2 — no Expo, no library, just node:http2 and node:crypto. Three things here are the difference between working and a silent failure: - The provider JWT must be signed with dsaEncoding 'ieee-p1363'. Node's default is DER, which is a perfectly valid ECDSA signature that Apple rejects, and the rejection says nothing about why. A test asserts the signature is 64 bytes rather than trusting the flag. - Apple rejects a token minted more than once per 20 minutes and any token older than 60, so it is cached and refreshed at 40 — between the two walls, not per request. - APNs expects ONE long-lived HTTP/2 session carrying many requests. A session per push gets throttled, so sessions are kept per host and re-created only when they die, with an error handler so a transport failure cannot take the sidecar down as an unhandled rejection. Sandbox and production are separate hosts and separate token namespaces, so devices are sent per their stored `environment` — a debug-build token against production fails with BadDeviceToken and no other symptom. Dead tokens (BadDeviceToken, Unregistered, DeviceTokenNotForTopic) delete their row immediately; everything else counts a strike. Going direct means Apple answers inline, so none of Expo's deferred receipt-polling is needed. The doorbell rule is now enforced by a test, not just by convention: a producer that passes subject/from/body — which the type forbids but JavaScript permits — cannot get any of it into the serialized payload. `aps.alert` carries a generic title composed from the category, and the custom `officer` key carries ids the app fetches by. 12 tests, 100% of the JWT and payload paths. Verified against the real endpoint too: a throwaway key gets 403 InvalidProviderToken from api.sandbox.push.apple.com, which means the connection, path, apns-topic, push-type and JWT structure are all accepted and only the credential is missing. Still needed for a real send: APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, and a device token from the app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f22a808d6 |
notify: the sidecar shell, with Discord as the first channel
Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple
or Google credential exists, so the pipe is proven before the hard part.
officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and
proxied at /api/notify. It is a sidecar rather than platform code because the producers are
spread across sidecars — the queue, email, the agent — and a platform-owned notifier would
force every one of them to call back into the platform. That is the inversion just removed
from email; this avoids recreating it.
Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather
than replacing anything. Each is awaited with its own error boundary and the dispatcher
always resolves: a job that finished has finished whether or not a banner appeared, so a
channel must never be able to break its producer.
text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to
render a banner, so "send nothing" was never available — what we control is that the string
is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and
the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to
put one.
Device registration lives behind X-Officer-User, trusted because the listener binds loopback.
Platform and environment are validated rather than defaulted: an iOS token from a debug build
fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that
never receives anything and never says why. GET /_officer/devices returns only the last 8
characters of a token — enough to identify a row, not enough to push to it.
Verified end to end against a fake webhook: /_health reports configured channels, a test
notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as
{"content":"3 new emails"}, and every validation path returns its own error.
Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
15f262539f |
push notifications: design, and the device registry
Design in docs/push-notifications.md. The short version: Apple and Google are unavoidable — iOS suspends apps so only APNs can wake one, and Android only accepts pushes from FCM — but Expo is not. Its push service is a relay in front of both and does not remove either credential, so we talk to Apple and Google ourselves. Both protocols were verified in Bun before designing around them: node:http2 works as a client (APNs is HTTP/2-only), and ES256 signing produces the raw 64-byte r||s form Apple requires rather than Node's default DER, which is silently rejected. No push library is needed for either channel. The load-bearing decision is the payload: a push is a doorbell, not a message. Apple and Google see metadata regardless, so they must not also see content — a notification carries a category and an id, never a subject, sender or error, and the app composes the visible text locally and fetches the real thing over the tailnet on tap. This commit is the registry: push_devices, holding native APNs/FCM tokens. environment is a column because APNs sandbox and production are different hosts AND different token namespaces — a debug-build token fails against production with a silent BadDeviceToken, so guessing is not an option. Registration upserts on (token, bundle_id) because tokens rotate and the app re-registers every launch. Failure counting prunes dead tokens; a hard rejection deletes at once. Nothing sensitive lands here: a token is useless without the APNs key or FCM service account, both of which stay in the sidecar's env. NOTE: `bun db:push` will fail until the telegram/whatsapp/discord rows are deleted from server_integrations — the CHECK constraint added earlier refuses while they exist. That is the enforcement working, not a problem to route around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cffb99b9d0 |
docs: untrack the migration test checklists
nav-test-checklist.md and email-migration-checklist.md moved to the workspace root, out of version control. They are working notes for a migration in progress — which checks have been clicked through on this machine and which have not. That is state about one host at one moment, not something a clone of the repo should carry, and it goes stale the instant the migration finishes. The reference docs they were sitting next to are the opposite: they describe the system and should travel with it. The root CLAUDE.md, which pointed at both by their tracked paths, now points at the new location and records the convention so the next one is put in the right place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a83f9cd378 |
delete the orphaned build script, and fix the second copy of the memo rule
scripts/build/runtime.ts had no caller after the build:editor scripts went. Its own usage text gives away where it came from: --experiments, --tracking, --editor-setup, the same phantom domain as the docs and examples cleaned up earlier. Nothing else references it — the `./runtime` export in src/workspaces/types/package.json points at a different file, which is untouched. helpers.ts stays; dashboard.ts and web.ts import it. APP_CONVENTIONS.md carried its own copy of "React 19's compiler handles memoization. Never use useCallback or useMemo." Correcting only CONVENTIONS.md would have left the two contradicting each other, which is worse than either. Both now say the same thing and one points at the other for the reasoning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aac20bf112 |
drop eight package.json scripts that point at nothing
Same origin as the phantom docs in the last commit — this architecture was based on another project and some of its scripts came along without their targets. - `build:editor` and its three variants run `scripts/build/editor.ts`, which has never existed here. CLAUDE.md carried a note saying exactly that, which the note now outlives. - `start:sidecar` / `stop:sidecar` / `restart:sidecar` / `logs:sidecar` drive `systemctl … officer-pty-sidecar`, a systemd unit replaced by PM2. That unit still exists on this host, enabled, pointing at a `monorepo/` directory that is gone, and has been failing to start ever since — these scripts are how it stayed reachable. CLAUDE.md's "Sidecar control" line pointed at the four systemd wrappers; it now says PM2 and points at working-on-officer.md for which process a change needs restarted. Verified afterwards that every remaining script's target exists. The rest of package.json is untouched — the edit is nine lines out, no reformatting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
829b034d13 |
docs: fold the workspace-root docs into the repo, with honest status
The root held four Markdown files that were not in any git repo and were being read as current. CLAUDE_SIDECAR_ISOLATION.md is the one that prompted this: it describes, in the present tense, an agent that dies whenever officer restarts. That was true when it was written and has not been true for two days. Rather than delete analysis that version control was not holding, the two substantial ones moved into platform/docs/ with headers that say what has since happened: - claude-sidecar-isolation.md — stages 0-2 are done and running (R1, R2, R4, R5 all satisfied); stages 3-5 are the only live part. - sidecar-audit-2026-07.md — a snapshot audit, largely executed. Email, pty, music and vnc have been done since; claude, opencode and the cross-cutting notes are still open. The vault stays off-limits. MUSIC_IMAGES_SPEC.md is deleted outright: the sidecar serves /image and /poster, so the spec is the feature. The workspace root now holds one Markdown file, CLAUDE.md, which is where cross-cutting operational reality belongs. Also corrected, in the same pass: - root CLAUDE.md listed email as "the big one, and untouched" and pty stage 4 as outstanding; both are done. It now names what actually remains (claude stages 3-5, the terminal orphan leak) and what landed. - docs/sidecar-topology.md said "nothing built yet". Two sidecars now serve their own transport; what has NOT happened is the part the document is about — fixed ports, the shared table, .env toggles — so it says exactly that rather than implying the design is underway. Migration order updated: pty and email went first, not music. - docs/navigation-audit.md is cited as authoritative but still planned work on Projects, a feature since deleted. A status header marks H3 void, H1/H2 done and H4 the one open item, so nobody follows it into a directory that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
db9d17d6fe |
docs: the convention docs were describing a different codebase
Second pass. These three are the ones a new contributor reads first, and all three were teaching things that are not true here. src/databases/CLAUDE.md claimed "Three PostgreSQL databases", listed two, and there is exactly one. Its type examples were Screenshot / Experiment / Company / GanOauth — none of which have ever existed in this repo; it had been carried over from another project wholesale. Rewritten against the real schema, queries and types, and it now carries the two things that actually bite: push-not-migrations, and the rule that the schema is the source of truth for what the database may CONTAIN, not just its shape — with the sql.raw trap in check() written down, since getting it wrong breaks push for the whole schema. src/apps/CLAUDE.md had the same problem in its examples (useExperimentsList, ExperimentCard, a state/ directory layout that does not exist), listed a `useWebsockets` hook that is not there while omitting useChatWebSocket, usePanelChannel and useJobs, and closed with links to three app docs that have never existed. Examples now use real hooks, and it points at the navigation audit — a frontend doc that did not mention the one rule the platform CLAUDE.md calls authoritative was a real gap. CONVENTIONS.md said, in bold, that useMemo and useCallback are "strictly prohibited" because "React 19's compiler handles memoization automatically". Wrong twice: the React Compiler is an opt-in build plugin that is NOT installed here, so React 19 memoizes nothing on its own — and roughly 40 files use each hook regardless, including code added this week. Replaced with guidance that matches both reality and the actual tradeoff, and says plainly what it used to claim. A rule that is false and universally ignored makes every other rule in the file look optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
04c0d89057 |
docs: bring the live docs back in line with the code
First pass of the documentation audit. Every doc was read against what the code actually does now; this commit fixes the ones worth keeping and deletes the ones that were only describing a past. Corrected: - CLAUDE.md — said seven WebSocket providers (there are eight, and terminal/vault are byte relays now, not translating bridges), listed channels/ as "Telegram / WhatsApp / Discord bridges" (they are gone; what remains is how /chat drives an agent turn), missed officer-wallet in the PM2 list and notify/ in the layout, and described the per-account email SQLite stores without saying they are the sidecar's and that nothing in the platform opens them. Further Reading pointed at four files that no longer exist and missed the four newest. - docs/working-on-officer.md — PM2 list was four sidecars short, and it still explained the officer-claude rename as news. Replaced with the thing a reader actually needs: which process to restart for which change, and why restarting officer no longer costs you a terminal or an agent session. - TODO.md — the "dead username plumbing" item was mostly resolved by deleting the channels, and two email items pointed at api/email/email-db.ts, which is sidecar/email/store.ts now. - AGENTS.md — trailing paragraph listed the design notes being deleted here. - MUSIC_API.md — playlists were entirely undocumented: seven endpoints the phone app has no reference for. Added from the sidecar's own contract. - docs/jobs-unification.md — phases 1-3 shipped, so it now says so at the top. Phase 4 (push notifications) is the only reason the file still exists, and email sync is explicitly no longer part of it. Deleted, all superseded rather than merely old: - PHONE_APP.md — a February plan for apps that now exist, with their own repo and README. - MARKETING_WEBSITE.md — a plan for a site this repo does not contain. - SECURITY_AUDIT.md + SECURITY_FIXES.md — a February audit of a codebase since restructured; it still cites queue/handlers, which is now empty. - docs/DOCKERIZATION_PLAN.md — cites pty-sidecar, whatsapp and projects, all deleted. - SETUP_GUIDE.md — documents systemd units and setup scripts replaced by PM2 and `bun setup`. Not harmless: /etc/systemd/system/officer-pty-sidecar.service is still enabled on this host, pointing at a `monorepo/` directory that no longer exists, and has been failing to start ever since. That guide is how it got there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
066c118723 |
fix db:push emitting bound parameters in check constraints
bun db:push failed for the whole schema with "there is no parameter $1" (42P02), after
"Pulling schema from database" succeeded. It was not caused by any recent change — pushing
with the new wallet table stashed reproduced it identically on HEAD.
The two provider CHECK constraints built their allowed-value lists with sql`${p}` over
JavaScript strings. Interpolating a JS value into a sql template binds it as a parameter, so
the constraint was emitted as
CHECK ("provider" IN ($1, $2))
and Postgres rejects a parameter reference inside a CHECK. sql.raw renders the literals
instead. Safe because both lists are compile-time const tuples, not input.
Push now generates valid SQL. It still stops on server_integrations, where three rows
(discord, telegram, whatsapp) predate the constraint and violate it — real data, and the
owner's to decide about, since their config holds bot tokens.
Unrelated but worth recording: drizzle wants to name a user_integrations foreign key at 65
characters and Postgres truncates identifiers at 63, so the name it reads back never matches
the name it asked for and that constraint is proposed for drop/recreate on every push.
Cosmetic churn, not addressed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2eda855551 |
persist the wallet's view of the chain
Opening a wallet meant waiting for a full gap-limit scan before any number appeared, and a restart threw that work away. Worse, an unreachable Esplora rendered identically to an empty wallet — as a zero balance — which is alarming for the one case where it is not true. The backend now keeps a snapshot and serves it stale-while-revalidate: a snapshot inside the TTL is served as-is, an older one is served immediately with a refresh started behind it, and only a wallet that has genuinely never been read blocks on the network. wallet_chain_cache holds one row per wallet so a refresh is a single atomic upsert. The snapshot, not the endpoint, is the unit of caching. Balances, UTXOs and history were three fetches over a shared scan, so the three queries a wallet screen fires on mount could each observe a different moment; building them together costs the same requests and fixes that incidentally. Only the chain's own facts are stored. Addresses, scripts and pubkeys are re-derived from the account xpub on load — cheaper than persisting them, and it means a restored snapshot cannot disagree with the wallet's actual keys. Stored coordinates are validated rather than trusted, and a snapshot at an unknown version is discarded, not migrated. Two reads deliberately opt out. sendCoins takes a fresh snapshot because selecting coins from a cached UTXO set builds a transaction spending outputs that may already be gone, and that failure arrives as a broadcast rejection after signing. nextUnused does too, because handing out an address whose stale record says "unused" is silent address reuse — a privacy leak the owner cannot see or undo. Receive-address generation is therefore the one read that stops working while the upstream is down, on purpose. Failures are recorded alongside the last good snapshot rather than replacing it; wiping data on failure would reproduce the exact bug this exists to fix. Every cache operation is best-effort, so a database problem degrades to a slow load and can never fail a wallet request. A sync block on balances, transactions and utxos carries the age to the UI, which now distinguishes "empty" from "never read". Verified against three live mainnet wallets: snapshots persisted and reloaded, and two wallets kept their data and age through a real Esplora rate-limit failure while recording the error separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bed8854206 |
stop wallet chain reads hanging on an unreachable esplora
A wallet whose Esplora endpoint was slow or down rendered as a wallet with no data at all, rather than as an error. The cause was a timeout inversion: Bun.serve's default idleTimeout is 10s, which is shorter than the Esplora client's own 20s per-request timeout. Bun killed the response before the scan could either finish or report why it had not, so the failure never reached the handler that would have surfaced it. The ceiling has to sit above the whole gap-limit walk, not one request — a scan is many sequential rounds of address queries. Raised to 255s, Bun's maximum, which is what every other sidecar already uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2647510965 |
import lnd aezeed cipher seeds alongside bip39
An LND seed is not a BIP39 mnemonic. It shares the 24-word shape and the English wordlist,
which is exactly why it fails confusingly: the words validate as plausible input, the
checksum does not, and the user is told their own seed is invalid.
aezeed is a different construction — a 19-byte payload (version, birthday, 16-byte entropy)
sealed with AEZ under scrypt(passphrase, salt), with the salt carried in the mnemonic
itself. So it needs its own decipher, not a flag on the BIP39 path. The vendored aez/aezeed
implementation under sidecar/wallet/aezeed does that, and the recovered entropy becomes the
BIP32 root the same way BIP39 output does.
The seed envelope gains a kind ('bip39' | 'aezeed') so an unlock knows which derivation to
run rather than guessing from word count, which cannot distinguish them.
Also note the aezeed passphrase is not a BIP39 passphrase: it decrypts the seed rather than
salting the derivation, so a wrong one fails the checksum outright instead of silently
producing a different wallet. The UI can therefore tell the user they typed it wrong, which
is not possible for BIP39.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fb70c83ae7 |
auth: survive a request that carries no origin
Signing in from a server-to-server client returned 500. originMiddleware leaves `origin` undefined when a request has neither Origin nor Referer — exactly what such a client sends — and signin.ts took it as a string, passed it into getPasskeysByUserIdAndOrigin, and Postgres rejected the undefined parameter. It would have thrown on origin.startsWith() a few lines later too. It used to be unreachable: originValidationMiddleware rejected origin-less requests before the handler ran, so the value was always a string by the time anything touched it. Turning the origin checks off removed that gate without the code behind it ever having needed to cope. This is the second thing that flag has surfaced rather than caused. The two call sites want different answers, so they get different ones: - signin normalises to ''. No passkey is registered against the empty origin, so an origin-less caller gets an empty list and falls through to password auth, which is what it is asking for. - the four passkey routes now refuse with 400 "Passkey operations require an Origin header". WebAuthn is defined in terms of an origin — a passkey is registered against one and is only verifiable against the same one — so substituting '' there would be quietly wrong. Also flips ALLOW_ANY_ORIGIN to default ON: the checks are off unless it is explicitly 'false'. A deliberate inversion of fail-closed, safe because of where this runs — the perimeter is the tailnet, devices are admitted by hand, and every protected route still requires a valid token. Verified both directions: unset lets a foreign origin through, ALLOW_ANY_ORIGIN=false rejects it. The origin test suite pins the flag off, so it still asserts the checks reject things rather than passing vacuously. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f9a07bb8ee |
schema: constrain integration providers to the ones the code supports
`provider` was free text on both integration tables, which is how rows for telegram, whatsapp and discord went on holding bot tokens long after the code that read them was deleted: nothing structural said they had stopped being legal, so nothing noticed. A CHECK constraint on each table now lists what may exist — google/apify for the server, google/browser-relay for the user. Adding an integration means adding it to the list, which is the point: the schema is the source of truth for what the database may contain, so a provider the code no longer supports cannot sit there unnoticed. This does NOT delete the existing rows, and no schema change can — drizzle-kit push diffs structure, never data. What it does instead is refuse: push will fail to add the constraint while violating rows exist. That is the enforcement working rather than a problem to route around; the three rows have to go first, and then the structure guarantees they cannot come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c9d680b133 |
remove the telegram and whatsapp channels, reduce discord to notifications
All three existed to drive the platform from a chat app. The phone app does that now, so they are dead weight — three bot gateways, three command parsers, account pairing, admin config screens and bot tokens sitting in the database. Gone entirely: telegram/ and whatsapp/, discord/'s bot and command handler, the shared channel plumbing they were the only users of (pairing.ts, send-and-await.ts, types.ts, routes.ts and its 20 config/pairing/status endpoints), the six settings components, and their sections in the integrations screen. What Discord keeps is the one piece worth keeping — pushing a message out — as notify/discord.ts, configured by DISCORD_WEBHOOK_URL in the env. No UI, no pairing, no stored credential, and it never throws: a notification that fails to send is logged and dropped. Unset means notifications are silently skipped, which is the default state. Kept deliberately: send-claude-code.ts and send-opencode.ts. They live under channels/ but have nothing to do with chat apps — they are how /chat and the pipeline executor drive an agent turn. Also drops four dependencies with no remaining importer (discord.js, node-telegram-bot-api, whatsapp-web.js, qrcode), and the /sync-now route added to the email sidecar an hour ago, whose only consumer was the channel handlers. The "Channel Models" settings section stays, with its description corrected — it is keyed off the general access policy rather than anything channel-specific, so it governs non-owner accounts, not chat apps. Whether that whole class still earns its place is the open question already noted against origin-validation. Not touched: the telegram, whatsapp and discord rows in server_integrations, which still hold their bot tokens. Deleting rows is a different kind of decision and the SQL is in the handover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9e144962d |
email: the sidecar schedules its own syncs
Stage 2, and the end of the inversion. The two sync handlers (1,093 lines) ran in the platform's queue, which meant the sidecar reached back over its registration socket to ask the platform to enqueue work, and the credentials travelled through Postgres job metadata to get there. Option (A) from the plan: they run here now, and the Jobs screen is left to the things it actually describes. The handlers moved almost unedited. Their bodies were already a list of steps taking a context, so sync-runner.ts synthesizes that context and runs them; what went away is the JobHandler wrapper and the registration. `job.userId` is the OWNER'S EMAIL rather than a numeric id — the queue's naming — and it resolves the mail store path, so it is called out in the type. That is the same field whose absence made the mailbox read as empty two commits ago; it is set from user.email and checked this time. Deliberately not a queue: one run per account, no persistence, no retry. A failure is picked up by the ten-minute cron like any other, and a sync interrupted by a restart resumes from the stored cursor rather than the beginning. PermanentError survives as a local class — it signalled "do not retry" to the queue and now just carries its message to the sync state. accounts.ts asks the runner whether an account is syncing instead of scanning job rows, and the queue-over-WS shim in index.ts is gone: enqueueViaWs, listJobsViaWs, the pending-response map and the queue branch in the command handler. Nothing but a port crosses that socket now. The three chat channels stop opening the mail store directly. They each carried their own copy of count-rows / enqueue / poll / count-again, coupling three chat bridges to the mail schema — and they enqueued `gmail-sync` unconditionally, the OAuth path, for an app-password account that syncs over IMAP, so the command was already broken. One shared helper calls a new POST /sync-now on the sidecar, which syncs and reports what arrived. queue/handlers/ is now empty; both handlers there were email. The queue is untouched and still serves the Jobs screen. Not moved, and fine where they are: scripts/migrate-emails-to-sqlite.ts and scripts/seed-imap-uids.ts are one-off maintenance scripts that open the store directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
06cd6bdca3 |
email: give the sidecar the whole user, not just the id
The mailbox read as empty after the migration — "No emails synced yet", and Sync Now
reporting no new mail against 18,430 messages that were sitting on disk untouched.
The store is keyed by the owner's email: DATA_PATH/<owner>/email_accounts/<account>/emails.db.
The platform's userMiddleware put the entire user row on the context, so `user.email`
resolved. The proxy injects only an id, and the middleware I wrote to replace it set
`{ id }` and nothing else — so `user.email` was undefined, the path never resolved to the
real mailbox, reads found nothing, and the sync compared against an empty set and concluded
there was nothing new. Nothing was written to the wrong place and no data was touched.
The sidecar loads the full row via getUserById now, matching what the middleware provided.
Cached: it runs on every request and single-user is a hard invariant.
Worth keeping in mind for the sidecars still to be extracted — moving routes across a
process boundary silently changes what is on the context, and it type-checks either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7d1212765b |
docs: test checklist for the email sidecar migration
Written to be run later rather than now, so it says what changed underneath and where a failure will land: the routes moved verbatim, but the transport (browser → proxy → sidecar HTTP) and the source of user identity (X-Officer-User instead of userMiddleware) did not, and that is where breakage will cluster. Includes the attribution table — 503 vs 502 vs 401 each point at a different half — and flags the two things that are expected rather than wrong: sync still runs platform-side on purpose, and the channel handlers' "sync emails" was already broken before this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
af56eb36ff |
email: move the mail store and every route into the sidecar
Email was the one sidecar built inside out. The platform held ~1,800 lines — the per-account
SQLite store, all 14 HTTP routes, account CRUD, resync, IMAP validation — while the 314-line
sidecar was a scheduler that reached BACK into the platform to do anything
(`import { performResync } from '../../api/email/resync'`).
The sidecar now serves its own HTTP listener and announces `email:server`, and
/api/email/* on the platform is createSidecarProxy like every other one: 1,801 lines down
to 22, with no mail knowledge left in it — not a message, not a folder, not a credential.
The routes moved verbatim, Hono and all. http.ts only reconstructs what the platform's
middleware used to provide: `user` on the context, from the X-Officer-User header the proxy
injects (trusted because this server binds loopback), and an error handler that turns
custom-errors into status codes.
The /email/events SSE stream went with them, which removes a whole round trip: the IDLE
watcher used to send `email:new` over the registration socket so the platform could push to
its SSE clients. Those clients are here now, so it calls broadcastEmailNew in-process and
`email:new` is gone from the wire protocol.
DELIBERATELY NOT DONE YET, and left backwards on purpose rather than half-moved:
- The two sync handlers (email-sync 381 lines, gmail-sync 712) still run in the platform's
queue and now import the store from its new home — a platform → sidecar import, which is
the wrong direction and is temporary. Moving them is option (A) from the plan: the sidecar
schedules its own syncs, independent of the platform Jobs list.
- accounts.ts still imports queue/init to enqueue a sync and to report sync status, and
index.ts still carries the queue-over-WS shim that inversion needs.
- The three channel handlers still open the mail store directly rather than asking over HTTP.
Two things worth knowing while testing: a from-scratch sync holds a proxied request open
well past the 60s idle default, hence timeoutSeconds on the proxy; and `gmail-sync` is
hardcoded in all three channel handlers even though the only account is provider=gmail with
auth_type=password, which routes to IMAP — so "sync emails" from a chat channel is
almost certainly already broken, and folds into the next stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
aaf0161620 |
put every http sidecar on the proxy factory
createSidecarProxy arrived with the wallet but nothing else moved onto it, so five sidecars still carried their own copy of the same two files: a sidecar-server.ts that remembered a port announced as `<name>:server`, and a router.ts that forwarded the subpath. Byte for byte identical once the app name was normalised away — which is exactly what the factory's own header said it existed to end. headscale, transmission, invoiceshelf, slskd and music are now wallet-shaped: create the proxy, export the router and the URL getter. 386 lines deleted against 163 added, and the five feature directories go from ~70 lines each to ~18. Two deviations were real and moved INTO the factory rather than being dropped, because both are HTTP concerns rather than app knowledge: - Range and If-None-Match are now forwarded for every sidecar. music needed both (seeking, and ETag revalidation returning a cheap 304 instead of a cover image) and slskd needed Range. Forwarding them everywhere costs nothing and removes the reason to hand-roll. - timeoutSeconds, used only by music at 1800. A from-scratch reindex holds the proxied connection open for minutes with no bytes flowing, which the 60s idle timeout would drop. It applies to the whole prefix — the proxy must not know which of a sidecar's routes are slow. The five side-effect imports in hono.ts are gone with them: the port listener now registers when createSidecarProxy runs inside the router this file already imports. Vault keeps its hand-rolled pair and its side-effect import — it is off-limits by standing instruction, and is the one sidecar this commit deliberately does not touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
dfb20a734e |
terminals: resize with the panel, and five xterm addons
The terminal was xterm with one addon (fit) and a single fit() call at connect, so the shell kept whatever cols/rows it was born with. Drag a panel wider and the PTY never heard about it — anything drawing a full screen (an agent TUI, top, vim) rendered into a box that no longer matched the one you were looking at. A ResizeObserver now refits and sends pty:resize, coalesced to one fit per frame because dragging an edge fires continuously and each fit reflows. Scrollback goes from xterm's default 1000 lines to 10,000 — one long agent turn was enough to lose the start of it. Addons, all off the shelf and none previously loaded: - webgl — the renderer, and the reason fast repainting feels smooth rather than syrupy. Guarded twice: construction can throw where there is no GL context, and the context can be lost later, so both paths fall back to the DOM renderer instead of a dead canvas. - unicode11 (+ allowProposedApi) — correct widths for emoji, CJK and box-drawing. The default unicode 6 tables are why agent TUI frames sit a column out. - web-links — URLs in output are clickable. - search — with a find bar on Ctrl/Cmd-Shift-F. Not plain Ctrl-F: that is forward-one-char in readline and emacs, and swallowing it would break every shell in the app. - serialize — installed for exact-state replay, not yet wired. Also reports the shell's own title (OSC 0/2) and the bell through new optional callbacks, so a panel can show what is running in it and flag a finished turn you weren't watching. Nothing consumes them yet. Unrelated but found by this run: three origin-validation tests were failing. Not from this change — ALLOW_ANY_ORIGIN=true in .env, added last night, and Bun loads the host .env into tests, so the kill switch had silently turned the assertions into no-ops. The test now pins both escape hatches off, since its whole job is asserting the checks reject things. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
188b45c113 |
remove projects and apps end to end
deletes the last of the projects/apps cluster: the published-app store (/api/apps + /api/app-serve), the project dev-server and its websocket proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite they were the only consumers of, and their frontend — the Preview panel, the UserApp panel/header, useUserApps and the /settings/apps screen. also drops getUserProjectsDir and getUserAppsDir, the ProjectType and ProjectDefinition types, and the 'dev-server' websocket provider from server.tsx. nothing on disk is touched. 1440 deletions, 31 insertions. tsgo clean. |
||
|
|
13b7f56b0f |
add a factory for sidecar proxy routers
eight sidecars hand-rolled the same port capture — byte-identical once the app name is normalised — and six repeated the same auth-and-forward router. createSidecarProxy collapses both into one call and covers the variants the others need: a ws:// url for music and vault, an onRegister hook for opencode. the wallet adopts it first: two files become one, 54 lines of router become 16, and hono no longer needs a side-effect import to capture the port. the no-body-parsing, no-body-logging rule moves into the factory with its rationale, since that restraint is what keeps unlock passphrases and macaroons out of the platform process. costs 31 net lines today and pays back from the second adopter on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b442084618 |
document the wallet key custody model
covers both encryption layers, what each one does and does not protect against, the watch-only-while-locked property and the unlock session rules. records the known limits: the heap cannot be reliably wiped, the storage key is derived with a plain sha-256 rather than a kdf, and rotating VAULT_STORE_KEY has no migration path. also corrects the changePassphrase doc comment, which claimed rotation never touches the dek. it mints a fresh salt, dek and ivs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8826e4c24 |
add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|