449 Commits
Author SHA1 Message Date
Alex SouthwellandSAPHID 9e9b25e9f4 Add local OBS headset view source with output audio (#170)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:29:23 +00:00
Alex SouthwellandSAPHID f7153b3ab1 Guide emulator setup and clarify ROM storage limits (#171)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:28:49 +00:00
Alex SouthwellandSAPHID ae63fa584c Add read-only Windows Wireless Adapter doctor (#166)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:44:24 +00:00
Alex SouthwellandSAPHID 337ac99b3e Add a read-only Linux Wireless Adapter doctor (#165)
* Add read-only Linux PC Wireless Adapter doctor

* Cross-reference coordinated SteamOS compatibility notes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:30:08 +00:00
Alex SouthwellandSAPHID aada257325 Handle Frame OS 0.5.6 discovery and SteamOS name changes (#164)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:24:04 +00:00
Alex SouthwellandSAPHID 8bc3d63126 Add local Linux helper for Steam PipeWire desktop sharing (#163)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:18:08 +00:00
e3fcd3eef8 Use gamescope input for Frame trackpad and keyboard (#66)
* Live view: a Desktop view that stays still, and Control to tap on the Frame

The live view gets a second source and a way to use the Frame from it:

- Desktop: the app panel in use in the headset, streamed from its own window
  (x11grab of gamescope's redirected window), so it doesn't move as the
  wearer looks around. A picker shows any other panel, view only.
- Control: on the Desktop view a tap or click lands exactly where you put it;
  drag is a mouse drag, press and hold right-clicks, two fingers scroll, and
  on a computer the mouse, wheel and keyboard work directly. On the headset
  view the view is a trackpad. A text field and key row type from a phone.

Input goes through gamescope's own EIS socket (the way Steam feeds Remote
Play input) with the libei already on the image: ui/frame_touch.py, over
the same long-lived ssh machinery as the keyboard agent, nothing to
install. It reaches the panel that has focus on either X display, which
the KDE Connect route can't. Verified on the Frame and from the iPhone app
in the Simulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from review

- Keys held on the Frame are released with buttons when Control stops or
  the view loses focus; keys for the Frame no longer trigger Frame Control's
  own shortcuts.
- Taps only act when the picture on screen is the panel in use; positions,
  presses, keys, text and scrolls name their panel (display and window: ids
  repeat across :0 and :1, told apart by pid), and the Frame drops them if
  focus has moved on. Releases always go.
- While connecting, a tap keeps its position; on an error only releases wait
  and retries back off; trimming a long queue never drops a release.
- Lifting one of two scrolling fingers ends the scroll; a cancelled touch
  isn't a tap; clicks and holds on the bars around the picture do nothing.
- A capture loop from before a Live restart can't stop the new video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: close the targeting gaps from the second review

- The focused panel's display comes from GAMESCOPE_FOCUS_DISPLAY (gamescope
  packs ":1" into the first value), so a window id repeated across :0 and :1
  can't be mistaken; the pid is only the fallback.
- Presses, keys, text and scrolls read focus afresh on the Frame; only moves
  use a reading up to a second old.
- A gesture remembers the panel it started on and does nothing more if that
  stops being the one in use; a press with no panel to aim at isn't sent.
- Opening a screenshot clears the panel Control would act on; switching to
  another app releases held keys and buttons.
- Trimming keeps a click with its position; the error backoff holds for new
  input too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from the SWE-2 Max review

- The Frame side tracks keys as well as buttons and lets go of both when the
  session ends.
- A stale tap tells the page, which re-reads the panels at once.
- A paused input device waits instead of ending the session; only a
  disconnect does. An OS error on one event skips it.
- Presses check focus with two property reads and do the full lookup only
  when it changed.
- Writes to an agent's stdin are serialized, so two devices sending at once
  can't tear a line (the keyboard agent too).
- Connecting gives up with a message after 15 s instead of hanging on
  "Connecting…"; text goes in 100-character pieces so releases don't wait
  behind a long paste; a cancelled mouse gesture releases what's held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: stale clears, pauses reconverge, pastes split per request

- The Frame says it has caught up as soon as an aimed event lands after a
  stale one, so the page stops re-reading the panels.
- After a device pause it lets go of everything it holds (releases that
  arrived while paused were dropped), and waits for the device once per
  batch, not once per event.
- The quick focus check no longer freshens the panel geometry's age.
- Each request carries at most about 100 characters of text.
- Turning Control off while it connects doesn't report an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: build the socket path on the Frame, so Windows can import it for tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: any event that goes through clears the stale flag

A trackpad move names no panel, so waiting for an aimed event could leave
the page re-reading panels for the rest of the session; a release still
aimed at the old panel doesn't count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Trackpad and key row go through gamescope's input; KDE Connect only for accents and emoji

The pad reaches whichever panel has focus (Steam's menus too), needs nothing installed, and no longer clamps to 1280x720. Characters that aren't on a US keyboard start KDE Connect the first time they're typed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Touch: a bare release no longer clears the stale-focus notice before the page has seen it

The page leaves the panel off releases, so a release says nothing about focus.
Found in review of the stale-latch fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: keep typing in order and acknowledge it once, with accents through one KDE Connect queue

Review found accent-only input repeated forever (sent was false), text split
across two transports out of order, accents stuck until the next keypress,
duplicated on retry, and racy. A batch with an accent now sends its whole
keyboard through KDE Connect; later keys follow while any are queued; the
server owns one ordered, bounded queue drained by a single thread; sent means
the whole batch is taken.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: drain the KDE Connect queue without losing, stranding or reordering input

Second review found the queue could be trimmed under an in-flight send (deleting
unsent events), the worker could retire as a request enqueued, and ASCII could
overtake an accent still being typed (or the reverse). The worker now takes its
batch out while sending and puts it back on failure, retires under the lock, and
each way waits out the other's estimated typing time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: count gamescope's real typing time, with the settle margin added once

Review found the estimate used 12 ms a character where the agent takes 16 ms
(32 shifted), and added the margin on every request so it piled up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Document gamescope pad verification and shared-device limits

* Prevent Unicode input startup from deadlocking a headset switch

* Record real focused-panel click and ASCII key delivery

* Retain accepted Unicode input under queue saturation and failure

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 11:54:36 +00:00
1f7615b312 Keep theatre playback running without optional surround (#51)
* Never end playback over the cosmetic surround; always shut down OpenVR

Review (SWE-2 Max): a non-standby error re-sending the theatre surround
could end a healthy still, and a non-RuntimeError during DestroyOverlay
skipped VR_ShutdownInternal. drain() now drops a surround that fails for
other reasons, and close() shuts down in finally. Fake overlay handles no
longer depend on call order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Play without the theatre surround if SteamVR refuses it

Review (SWE-2 Max): a non-standby error creating the cosmetic surround
at startup still ended playback before the screen appeared. Theatre now
logs it and plays without the surround. close() treats any per-overlay
teardown error as best effort (signals are already ignored there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Verify media surround fallback and guard server startup ownership

* Record exact-head real Frame theatre playback smoke

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:45:48 +00:00
Alex SouthwellandSAPHID aead570b5d Clarify Return to Game Mode switch help (#158)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:47:24 +00:00
Alex SouthwellandSAPHID 21dacfcbe7 Distinguish unreadable panel discovery from an empty list (#152)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:34:56 +00:00
Alex SouthwellandSAPHID eb181d8996 Add opt-in desktop low-battery comfort alerts (#150)
* Add opt-in desktop low-battery comfort alerts

* Fix desktop battery alert test harness and Windows encoding

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:22:56 +00:00
Alex SouthwellandSAPHID a148e02d39 Add bounded read-only power and heat reports to desktop Tools (#144)
* Add bounded desktop power and heat observations with offline downloads

* Fix desktop power tests on Python 3.9

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:58:19 +00:00
Alex SouthwellandSAPHID 0fb9174c23 Fix Return to Game Mode detection in full Plasma (#143)
* Fix Frame session detection for full Plasma Desktop Mode

* Document real Frame Desktop Mode return verification

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:46:17 +00:00
Alex SouthwellandSAPHID cd7ab2d9f7 Import standalone APK/OBB bundles through the current backup flow (#136)
* feat(android): inspect standalone APK and OBB bundles safely

* test: decode Android page fixture as UTF-8

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:01:32 +00:00
SAPHID c728e64fbd Merge remote-tracking branch 'origin/main' into coord/resolve-133
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:11:32 +00:00
SAPHID b9b4a00485 Merge remote-tracking branch 'origin/main' into coord/resolve-132
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:00:26 +00:00
SAPHID 7e53fe32cb Show Android compatibility reports without blocking search 2026-10-24 03:58:37 +00:00
SAPHID ebae409632 Fix overlapping display-name redaction in optional logs 2026-10-24 03:56:45 +00:00
SAPHID 08cb267ce4 Fix remaining host-key recovery clipping and tooltip punctuation 2026-10-24 03:52:38 +00:00
SAPHID ec4086db69 Probe capture properties in initialized native adapter 2026-10-24 03:25:28 +00:00
SAPHID 5a7c3541f8 Merge current main into Windows capture fix 2026-10-24 03:20:54 +00:00
SAPHID 525040162e Fix Windows PC screen capture with DXGI-only GStreamer bundles 2026-10-24 03:17:11 +00:00
SAPHID bb9236b2b7 Merge main into storage and retain release notes 2026-10-24 03:08:15 +00:00
SAPHID 63ba25ffdd Merge main into doctor and retain both changelog entries 2026-10-24 02:58:52 +00:00
SAPHID 9e3c3cd31d Add privacy-safe read-only Frame doctor diagnostics 2026-10-24 02:45:44 +00:00
SAPHID 537754ebce Add read-only headset game storage inventory 2026-10-24 02:44:45 +00:00
SAPHID 3120a3cacf Add read-only Frame battery health and capacity monitoring 2026-10-24 02:40:06 +00:00
SAPHIDandClaude Opus 5.5 361bcf8a1b Merge origin/main into feat/windows-pc-in-headset
ui/index.html: main added the Back up tool link beside the Mac one;
both links and both host checks are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:34:09 +00:00
SAPHIDandClaude Opus 5.5 7a7dca70de PC in the headset: Windows only, Experimental and off until turned on
host_view now picks the PC host on Windows only; Linux and macOS keep
the Mac view as on main, so #38's Linux adapter is never offered. The PC
host starts nothing, loads no native library and lists no windows until
the person ticks "Turn on PC in the headset" on the card (action
"enable" on /api/macview). Turning it off ends streams, viewers and the
helper. The choice is a client preference, like mvSeparate.

Tools lists "PC in the headset" on Windows only, beside the macOS-only
Mac link, with an Experimental tag and a short note on what hasn't been
tried. The Mac card's wording and Linux-portal tweaks from #38 go back
to main's, apart from the shared "your computer" hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
SAPHIDandClaude Opus 5.5 71fe208fea Keep the Mac helper on its own Swift rate controller
#38 swapped the Mac agent's Swift RateController for a binding to
desktop/controller.c. That is a change to a shipped, headset-verified
feature that this Windows-only PR can't test on a Mac, so the Mac agent,
its build script and the staleness check go back to main. The C port
stays for the Windows host and is still checked against the frozen trace
of the Swift original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:17:05 +00:00
SAPHIDandClaude Opus 5.5 8402a671ef Merge origin/pc-in-headset (PR #38) into feat/windows-pc-in-headset
Brings in #38's native PC host. The only conflict was ui/index.html: main
moved the macview card under Tools, so this keeps main's layout; #38's
loadMacView changes applied cleanly. Scoping to Windows and the
Experimental opt-in follow in separate commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:14:24 +00:00
SAPHID 310e778d00 Merge remote-tracking branch 'origin/main' into feat/backup-recovery-kit
# Conflicts:
#	ui/index.html
2026-10-24 00:13:26 +00:00
SAPHIDandClaude Opus 5.5 a909beb39e Return to Game Mode: recognise gamescope-wl, and switch only when Desktop Mode is seen
On a Frame (build 20261015.6141207) gamescope runs as gamescope-wl, so the session
check said unknown, and the button would have restarted a headset that was in
Game Mode. Game Mode now also counts gamescope-wl and start-gamescope-session, and
the switch runs only when Plasma is running without gamescope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:13:17 +00:00
SAPHID 96502e6e4d Merge remote-tracking branch 'origin/main' into fix/record-stop 2026-10-24 00:03:53 +00:00
Alex Southwell d5f26efc1a Merge pull request #119 from saphid/feat/auto-sync
Copy new screenshots and Steam clips to the computer automatically
2026-10-24 00:03:43 +00:00
SAPHIDandClaude Opus 5.5 eac9557286 Recording: stop ffmpeg with q on a FIFO, not SIGTERM, which SteamOS's ffmpeg ignores
On a Frame (SteamOS 0.5.6, build 20261015.6141207) Stop left ffmpeg recording:
ffmpeg n7.0 there catches SIGTERM and SIGINT and carries on. The recording shell
now gives ffmpeg a FIFO as stdin, types q into it on Stop or a dropped channel, and
SIGKILLs it if it's still there 10 s later. Tested end to end on that Frame: an
8 s 1080p H.264 + AAC .mp4 saved 1.3 s after Stop, nothing left on the Frame.

Also records the read-only charge-limit checks run on the same Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:02:39 +00:00
Alex Southwell 4aa9f18fd1 Merge pull request #117 from saphid/feat/force-quit
Force quit a running game from Games and the Control Center
2026-10-23 23:47:42 +00:00
SAPHIDandClaude Opus 5.5 fa40e30f3c Force quit never lists or quits SteamVR: on the Frame it's the headset view, not a game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:38:14 +00:00
SAPHIDandClaude Opus 5.5 26206e85b4 Copy new screenshots and Steam clips to the computer automatically
Settings → Copy to this computer, off by default, with a switch each for
screenshots and Steam clips. While a headset is connected, a worker in the
server looks about once a minute and copies what's new with save_shots and
frame_clips.export_clip: only what's newer than turning it on, never what it
copied before (remembered per headset in auto-sync.json, so deleted files stay
deleted), never Steam's background recording or a clip written to in the last
30 s. It waits for other work on the headset, backs off to 15 minutes while
the headset is unreachable, and pauses when this computer is out of space.
Copy everything now copies the rest once.

The Frame-side clip listing now reports each recording's idle time, by the
Frame's clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:33:37 +00:00
SAPHIDandClaude Opus 5.5 d7a887b98c Force quit's unit tests run on Windows too: no SIGKILL or getuid there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:29:10 +00:00
SAPHIDandClaude Opus 5.5 30cbe1c901 Backup and recovery kit: saves, settings and Android data off the Frame and back
Tools → Backup & recovery copies game saves (Proton prefixes' AppData,
Documents and Saved Games, Unity and Godot saves), Steam controller layouts,
desktop settings and Android app data (the existing backup-data helper) to
~/Documents/Frame Control Backups/<time>/ with a manifest of every file's size
and SHA-256 and the headset's BUILD_ID. Backing up only reads the headset;
sign-in files, keys and keyrings are refused by name wherever they are.

Restore checks each part against the headset first and skips, rather than
fails, games not installed yet, prefixes Proton hasn't made, a missing Steam
account and closed or missing Android apps. It asks before replacing anything,
writes only inside each part's folders and keeps every replaced file under
~/.local/share/frame-control/restore-previous/.

Return to Game Mode reads the SDDM session config and runs the switch Valve's
devkit tools use (steamos-session-select / holo-session-select gamescope),
only when the headset isn't already in Game Mode and only after confirmation.

Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:28:35 +00:00
Alex Southwell f4efefa958 Merge pull request #116 from saphid/feat/clips-record-export
Record the headset with its sound, and export Steam clips of any length
2026-10-23 23:23:33 +00:00
SAPHIDandClaude Opus 5.5 a5980d8f98 Force quit a running game from Games and the Control Center
Running now, at the top of Games, lists what's running on the Frame (Steam
games, sideloaded titles, non-Steam shortcuts, Android apps), with Force quit
on each, on a game's library tile and in a sideloaded title's menu. The
Control Center gets a Force quit a game card. Each asks first: unsaved
progress will be lost. Nothing running says so.

ui/frame_running.py finds games by Steam's `reaper SteamLaunch AppId=<id>`
process and its tree, never by name. It asks Steam to quit
(SteamClient.Apps.TerminateApp), then sends SIGTERM and SIGKILL to the tree,
skipping init, the SSH session, other users and Steam, gamescope, SteamVR,
sshd and the session. Android apps also get the Apps tab's container stop.

The fake Frame runs games under a stand-in reaper and lists them in
SteamUIStore.RunningApps, so tests/e2e/test_running.py force quits real
process trees. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:17:59 +00:00
SAPHIDandClaude Opus 5.5 e179616ad0 Record the headset with its sound, and export Steam clips of any length
Record on Capture & Live runs ffmpeg on the Frame (x264 from /dev/video99, the
default output's monitor through PipeWire's PulseAudio server), stops it when the
SSH channel closes, remuxes to .mp4 and copies it to ~/Videos/SteamFrame. If the
audio can't be read, it records video only and says so. 20-minute limit, free
space checked on both ends.

Steam clips lists Steam's game recordings (DASH sessions under
userdata/<account>/gamerecordings) and Export joins each stream's init segment
and chunks, then has the Frame's ffmpeg copy them into one .mp4, so clips longer
than Steam's own 60 s export come out whole.

The logic is in ui/frame_clips.py for reuse. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:13:16 +00:00
SAPHIDandClaude Opus 5.5 810ef1822d Type on the headset: keyboard and clipboard in one place, and the headset's clipboard back to the PC
Home, Tools and the Control Center now call the keyboard "Type on the
headset", and T opens it from anywhere. The clipboard moves from Send to
Frame to sit beside the keyboard, with a new Copy the headset's clipboard
button: the server reads Klipper's getClipboardContents on the headset
desktop's bus (base64 back, 1 MB limit, empty and image clipboards say so),
and the desktop app, the iPhone app or frame_host.set_clipboard_text puts
it on the clipboard here. Clipboard text is never logged.

Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:04:41 +00:00
saphidandClaude Opus 5.5 3f377d8f56 Cast to this screen shows Capture once too, through the shortcuts' showNow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:15 +11:00
saphidandClaude Opus 5.5 48e9ce58ee Home shortcuts show their tool once: pushState instead of a second showPage on hashchange
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:21:39 +11:00
saphidandClaude Opus 5.5 9a5d697579 Merge main (0.4.8: Android game backups) into the four-tab structure; Home's shortcuts focus on the first tap
Main's Import Android game panel joins Quest and Meta under Apps > Browse,
and its Import button (androidBackupOpen, which lived in the old Android
store header and would have thrown at startup here) is now an Add / Install
choice. docs/android-backups.md points to the new places.

Home's shortcuts (Type on the Frame and the rest) now show their tool before
focusing it, so typing works on the first tap and phones open the keyboard
(Sol review P3). A tool panel already in its workspace isn't moved again, which
also dropped focus on the follow-up hashchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:12:49 +11:00
saphidandClaude Opus 5.5 be9e7b5cda Merge feat/android-store-redesign (with main) into the Apps / Games / Tools / Settings structure
Keeps the four primary destinations and ports main's newer work into them:
the Home "Right now" panel (volume, cast, quick actions) replaces the old
device block, whose readings now live in the header's headset popover; the
install queue sits above the Apps views; Import from my Quest and My Meta
library sit with Browse; the pad, panel switcher, Mac view and media panels
take main's markup. Scrolling follows main's #scroll container. Settings
stays a tab, so main's header gear is not added. The old .home-status
toggle is gone (main's startup test caught it touching a missing node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:49:13 +11:00
saphidandClaude Opus 5.5 5c44d56f53 Sentence-case headings and tabs, the headset named once on phones, sideloaded games get Play and a menu
Section headings, the tabs, the brand and the activity bar drop small capitals
for sentence case at one heading size. On a phone with a headset picker, the
connection pill keeps only its status dot so the name shows once. Sideloaded
titles get Play and a ⋯ menu (Add artwork, Remove) like installed apps. Manage
sources moves under Source & compatibility. Library artwork is a Settings
page, so Tools no longer lists it.

Refs #101, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:20:38 +11:00