- Tile removal runs as webui_tile_remove_with(ops), host-tested with a
scripted console: after a restage, only a successful unregister lets
anything be hidden or deleted; WEBUI_TILE_DIR is never deleted while a
nullfs is bound on it; the marker goes last.
- Every successful ensure forgets the icon keeper's entry and sets the
tile visible, so a registered tile is never left hidden.
- The outside-removal check runs before webui_start.
- register_appdb_has_title uses content_db_has_title: one bound WHERE
titleId = ? lookup per title table instead of reading 2048 rows.
- backHistory: Escape/Backspace/gamepad ○ on a fresh first screen (no
guard) call onRoot once, as ○ does.
- removed_outside tests list each scenario explicitly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoFXdUvVCDAG8xRjtp52qt
Tile removal (payload):
- Judge removal by app.db, which the home screen draws, not by the PC
installed list. 7.1.9 deleted the tile's files even when Sony's uninstall
failed, leaving a greyed-out row; 7.1.10 could still delete them while a
row stayed (one uninstall removes one of two install records).
- While a row remains: ask Sony again; a row whose staged files are gone is
restaged and registered so the uninstall has an app to remove; a row Sony
keeps is hidden (tbl_iconinfo visible = 0, kept hidden by the icon keeper).
Files are deleted only when the icon is gone or hidden; otherwise the tile
stays working instead of greyed out.
- Turning the tile on again shows a hidden icon again.
- A marker records that the helper registered the tile; at helper start a
tile app.db no longer has (deleted on the PS5 itself) is not registered
again and the setting turns off.
Console page: WebKit closes a page it did not open by script only while its
back/forward list has one entry; the PS5 guard pushed at load made both ○ and
window.close() unable to leave from Home. No guard on the first screen while
history is a single entry; after a level, the old close attempt remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoFXdUvVCDAG8xRjtp52qt
- Our folder/SMP registration (mount.lnk) whose teardown worked is done
even when Sony refuses, unless the title is still listed; the ACK
carries Sony's code for logging and the engine's
sony_uninstall_refused now means "the refusal stands". The appmeta
copy is removed in that case too.
- Presence follows the engine's installed list exactly: an appmeta
folder, or an app.db row whose folder exists under /user/app,
/mnt/ext0/user/app or /mnt/ext1/user/app. A folder registration's
kept /user/app/<id>/sce_sys is no longer a false "still present".
- remove_our_appmeta removes subfolders (bounded depth, lstat, never
follows a link, unlink/rmdir only).
- Library unregister note and the dump swap honour stillPresent; the
Installed confirm text change is reverted.
- Tests: ours + Sony non-zero, presence cases, appmeta with a subfolder
and a symlink (host self-test); refusal_stands (Rust); vitest.
Hardware (Phat, FW 13.60): registered a small folder game through
AppInstallAll, added an appmeta subfolder, unregistered: still_present
false in 1.4 s, no /user/app/<id>, no appmeta, no app.db row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
The Installed screen's Uninstall ran Sony's AppUnInstall and the helper
toasted "Unregistered <id>" whatever Sony answered; the client ignored
the reply, dropped the game from its list, and the next refresh listed
it again. A refusal (sony_uninstall_refused) was carried in an ok reply
that no caller checked.
Sony's return code alone proves nothing either: on FW 13.60 it is 0 for
a title id that was never installed (measured on the Phat).
- helper: after the uninstall, check the places the app lists a title
from (/user/appmeta/<id>, app.pkg on internal/ext0/ext1), waiting up
to 10 s. A refusal is an ERROR frame (uninstall_refused:0x...), so
every engine version reports it; a title still present is an ACK with
still_present:true. The toast says Uninstalled / Unregistered /
refused / still there. Decision logic in unregister_outcome.h with a
host self-test.
- engine: pass still_present through and log it at WARN so bug reports
carry it.
- client: appUnregister rejects a refusal (also an older helper's ok
reply with sony_uninstall_refused) and returns stillPresent; the
Installed screen keeps the game listed and says so. Plain-language
errors for uninstall_refused / uninstall_unavailable, translated.
The confirm dialog no longer calls a package install a folder
registration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
- Rollback restores the failing location too (a write torn across two
pages, or a read-back mismatch), then the earlier ones; undo failures
are reported in the error. The write loop, checks and rollback move to
cheat_plan_apply() with a memory-ops backend.
- FW split as in CheatRunner: <= 8.20 mdbg only (copy-on-write, private);
> 8.20 physical page only, no mdbg write fallback.
- A physical write must land inside the game's own module (eboot, handle
0, or a module under /app0); Section / module_name / absolute addresses
in a system library are refused.
- Turning a cheat off leaves alone a code cave that overlaps another
enabled cheat of the same title.
- The game is held with SIGSTOP (not ptrace) while the writes land;
SIGCONT on every path. kill(pid, 0) before every write.
- state_set runs under g_apply_lock; the sidecar is read whole (no 4 KiB
cut) and an unreadable one is not rewritten.
- Self-test: fake memory backend failing on every Nth write, on the second
page of a page-straddling write (turning on and off), on the undo
itself, a mismatch, the game exiting mid-apply, and a shared cave.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
The console page's Cheats section only listed cheat files already on the
console. It can now find the running game's cheats in the community repos
(read by the PS5 browser itself; a port of cheats.rs's repo list, filename
parsing and content URLs), mark the ones for the installed version, and
download one: the browser fetches it and POSTs it to the helper, which
stores it atomically where the cheat engine looks.
Helper: CSP connect-src allows raw.githubusercontent.com and
api.github.com; new /api/cheats/version (appinfo CONTENT_VERSION/APP_VER)
and streamed /api/cheats/store (strict name check, <= 4 MiB, same-folder
temp + rename via wf_receive_file). cheats.c unchanged: the engine rescans
its folders on every list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Every Marvel's Wolverine cheat crashed the console; CheatRunner's worked.
Our engine PT_ATTACHed the game, set the protection byte of the vm_map
entry holding the address to RWX with kernel_mprotect, wrote with PT_IO,
and "restored" it to R-X. Measured on the Pro (FW 13.60): the game's
.text is one 95 MiB entry (0x400000-0x6324000), execute-only (0x04), so
each toggle made the whole executable RWX under a copy-on-write PT_IO
write and left it R-X. kernel_mprotect edits the entry byte directly (no
pmap update, no split); CheatRunner traced two kernel panics to that
call and moved to the page-table walk this commit uses.
- Read/write through kernel_proc_getpaddr + the direct map (mdbg as a
fallback), inside kernel_rw_lock; read back after every write. No
ptrace stop, no protection change.
- Apply a cheat as a whole: every location is read and checked against
its cheat and original bytes before anything is written; on a write
failure the earlier writes are undone. The old engine wrote whichever
locations matched, so a jump could land without its code cave.
- Write code caves before the jumps into them when turning on, and the
reverse when turning off (cheats_plan.c, host-tested on the real
Wolverine pack).
- Refuse a cheat the parser had to truncate, and a base of 0.
- Toggle state is keyed by the flat index across all of a title's files;
a toggle in the second file used to mark the first file's mods, which
the watcher then re-applied at the next launch.
Verified on the Pro with Wolverine running: God Mode, Max Resources/XP
(20 locations), Infinite Item Charges and an MC4 Infinite Health on and
off, 60 s each, game and console stayed up; a clashing MC4 cheat was
refused with nothing written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
- A text save is refused (409 busy) while an upload and a download both run,
so at least two of the four web workers stay free.
- Throughput floor: uploads, downloads and .tar streams are dropped below
1 KiB/s averaged over 60 s, on top of the 20 s idle timeout.
- Early refusals on streamed routes read off up to 64 KiB / 2 s first, so
the page gets the real error code.
- Leftover .ps5u-*.part files older than 24 h in the target folder are swept
at upload begin and text save (that folder only, lstat, links untouched).
- A Range header too long to keep is ignored (200, whole file), not a 400.
- The editor saves with the file's dominant line ending and says when lone
CRs will be normalised (new string in all 21 locales).
- Upload dialog free-space separator; Upload/Download hidden also when the
page is opened by the console's own LAN address (from /api/settings).
- The .tar walk starts from the canonical root and resolves the trust
store's forms once per archive instead of a realpath per entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
When PS5Upload Files is opened from a phone or PC on the LAN (not from the
console itself), Files offers Upload here and Download.
Upload: POST /api/upload/begin checks path policy, the folder, an existing
name (409 exists / not_a_file) and free space (507) before any byte is sent
and reserves the one upload slot (409 busy; forgotten after 30 s); the bytes
follow as POST /api/upload/data?id= (streamed, idle timeout 20 s), written
to a temp file beside the target and renamed only when complete, so a cancel
or a dropped client leaves nothing. /api/upload/cancel frees a reservation.
The page sends files (or a folder, where the browser can pick one) one at a
time with progress, Skip this file / Stop, and Skip / Keep both / Replace
for names already there.
Download: /api/download/begin hands out a /dl/<ticket>/<name> link (128-bit,
for that item only, 60 s after last use) that the browser saves with a plain
navigation: a file with Content-Length and Range, a folder as a .tar streamed
as it is read (pax for long names and > 8 GiB sizes, links and other mounts
left out). One download at a time; trust store and its ancestors refused.
Measured on the Phat (13.60, LAN), 1 GiB: upload 66-75 MiB/s, download
111 MiB/s, folder .tar 101 MiB/s. Strings in all 21 locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Info gets a Permissions editor (owner/group/other x read/write/run, octal,
"apply to everything inside" for folders; exFAT/FAT drives say permissions
are not kept). /api/fs/chmod takes an optional mode (<= 0777) for the item
alone (FS_CHMOD) or recursively (CHMOD_R job); without it, Fix permissions
is unchanged.
New file (toolbar + sheet) makes an empty file and opens it in the new text
editor; Edit / Edit as text opens any file up to 1 MiB that is valid UTF-8
without NUL. Saves go through POST /api/fs/save-text: the web server can now
hand a request to a streamed route after the token check (body read by the
route with an idle timeout, not the 5 s header deadline); the save is
refused over 1 MiB or with a NUL, checks the mtime/size (and mtime_ns, now in
/api/fs/stat) the editor read before writing and again before the rename,
and writes a same-folder temp file that keeps the old mode and is renamed
onto the file after a same-device check (webui_files.c, host selftest;
cross_device.h audit updated). CRLF files stay CRLF; a changed file asks
Save anyway / Discard; ↑/↓ stay inside the text box until its first/last
line. Phone width: tools wrap, rows tighten, dialogs fit (no sideways
scroll). Strings in all 21 locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Web threads get the 512 KiB mgmt stack (they run mgmt handlers); a recent
folder that is gone (404) is dropped via a new prefs "forget" op; a non-exFAT
image inside a .ffpfsc reads as unsupported, not damaged, and is logged to
stderr; text find shows its count after a pause in typing; free-name probes
use /api/fs/stat missing_ok (200 {"missing":true}) so no 404 noise. hw_info
comment updated for the page's 10 s HW_TEMPS poll on a web worker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
- Helper listener heals itself on the accept thread (no new thread): every
5 s it rebinds after a resume (10 s clock gap) or, with LAN access on,
when the console's addresses change (Host allowlist refreshed); a
listener whose poll/accept keeps failing for 5 s is rebound too.
- Long file names slide while their row is focused (text-indent marquee,
prefers-reduced-motion respected; one focusin/focusout pair).
- Continue on your phone: /api/settings carries the console's LAN address;
rename, new folder, go to path and bulk rename show a QR code and
http://<addr>:9180/ while LAN access is on. Tiny dependency-free QR
encoder (byte mode, v1-10, L/M), checked against segno symbols.
- Deferred: FS stat ids from stat() (a symlink to the destination is
itself); clipboard header; language switch keeps the shown language
until the new one loads, strings fetch times out after 10 s; own
Cache-Control detected in any case; cheats toggle right after the game
exits says closed at once (helper drops its 2 s cache, dead pid reads as
no game), a racing PC/page toggle says "another app is changing";
failed batch items stay selected; Fix permissions title counts items.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
- Offline copy: a service worker (/sw.js, network first for / and
/strings/*, never /api/* or POSTs) plus the appcache fallback
(<html manifest="/cache.appcache">) the PS5 browser honours for Payload
Manager. Both are emitted by the console build with the page's hash and
embedded in the helper (webui_offline.c), served with their own types and
Cache-Control: no-cache; the page is now no-cache (storable) instead of
no-store.
- Boot gate: the page reads /api/token first; no answer (or not the
helper's) shows "PS5Upload Files isn't running" with Try again / Close,
re-checked every 5 s, reloading once the helper answers.
- Error boundary: Reload, or Reset settings & reload (this page's
ps5upload.* keys, the tour mark kept), instead of a blank page.
- ○ on the first screen (PS5 browser only) calls window.close(); a refused
close leaves the next ○ to the browser. A popstate that pops nothing is
ignored, and within 1.5 s of load before any key/click a pop only puts
the guard back (WebKit's load-time popstate, tour-at-load).
- cw_* strings for F8 in all 21 locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Index name match is linear (iterative, one backtrack point; selftest bounds
the pathological pattern); star runs collapsed. INDEX_SEARCH filters an
optional "under" before its limit; This folder says when matches elsewhere
filled the reply. Text viewer draws a window around a find target with
memoized blocks and one scan per query. Hex reads only 64 KiB; end-of-list
buttons keep focus (hex First/Last added); D-pad left/right on the picture
changes image (nav hands arrows to [data-nav-arrows]); SFO key scan bounded;
folder size and disk usage share one walk; Search waits for the drives;
Disk usage starts at the folder shown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Helper: /api/index/start|status|search|cancel over INDEX_START / STATUS /
SEARCH / CANCEL (handlers unchanged; the engine never calls them). Their
bodies are scanned quote to quote, so every root and pattern is checked
for no '"', '\' or control character and < 256 bytes (codec, selftested).
Roots: 1-8, absolute, never the trust store (403). The page's last start
(roots, started_at, cancelled) is remembered so status says whether the
index there is the page's and what it covers. Search answers at most 500
results, filters by `under` (asking the helper's 5,000 for a folder) and
never returns a trust-store path; truncated when the helper's limit or
the page's is reached. JSON buffers on the heap.
Page: Search (toolbar) — name or * / ? pattern (a word matches anywhere),
all drives or this folder; the first search builds the index for that
scope with a running count and Cancel, then searches; a covering index
is reused; results (name, folder, size) open in Files at the item. An
older helper (404) is told. Disk usage (toolbar) — the drive's
subfolders as bars, biggest first, measured one after another with
folderSize's walker under one budget (500k entries, 3 min, 2,000
subfolders), cancellable, one at a time page-wide (refuses while a
folder-size walk runs); "›" looks inside (○ comes back up), ✕ opens in
Files. Strings cw_files_search_* / cw_files_usage_* in 21 locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Helper: files.prefs_get/prefs_set (148/149) reach files_prefs.c, the same
file the console page's /api/files/prefs uses; files.handoff_put/take
(150/151) keep one Show on TV / Show on PC slot per direction in helper
memory, gone after two minutes, emptied by a take (files_handoff.c, host
self-test). The page gets /api/files/handoff/take {for:"console"} and
/api/files/handoff/put {to:"pc"}; /api/jobs adds "uploading", the roots
of up to 8 running uploads, for the Files badge.
Engine: GET /api/ps5/files/prefs, POST .../prefs/set, .../handoff/put,
.../handoff/take. An older helper answers unknown method, reported as
{"supported":false} so the PC app hides the features.
console files: shared favourites and Show on TV / Show on PC
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
- Registered-only titles are added only when app.db could not be read in
full; the helper's registered list is every /user/app folder, so with a
complete read a folder app.db does not list is a leftover, not a game.
NPXS ids are excluded, and a candidate needs a mount.lnk source or a
param.json/param.sfo in its sce_sys.
- app.db pages carry the table's row count (SQL reader only); the engine
marks the list truncated when it changes between pages.
- A SQL walk that ends on anything but SQLITE_DONE reports truncated.
- FAQ: the phone guide is phrased as a question; references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
APP_DB_QUERY answered at most 256 rows, and fewer when its 64 KiB reply
filled, with nothing saying the list was cut. A title installed by a
homebrew installer (no /user/appmeta folder) that sorted past that point
disappeared from the installed list.
Payload: app.db.query takes an optional {"offset","limit"} window and
answers offset/next/more/truncated; a full buffer now ends the page with
more=true instead of closing the list. An empty request is page one, so
older engines keep working.
Engine: appdb_query follows the pages (at most 32) and marks the list
truncated when it could not finish (or an older helper returned a full
256). The installed list reads app.db once per request, adds registered
titles whose app folder exists, reports incomplete, and a truncated list
never proves a title absent for install verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
The page embedded all 21 languages' cw_* strings (174 KiB of the 220 KiB gzip
budget). Now it embeds English only; every other language is a separate
build artifact (dist-console/strings/<lang>.json) gzip'd into the helper and
served at GET /strings/<lang>.json (Host/Origin checked, no token, immutable
year-long cache; the page asks with ?v=<hash of the strings>). The page shows
English until the chosen language arrives, falls back to English per key, and
stays in English (logged once) when a load fails.
Page: 607.1 KiB raw / 174.4 KiB gzip -> 357.5 KiB raw / 112.5 KiB gzip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
Fix round 2. The helper refused a request under 500 ms after the last one (429),
which caught the final batch sent as Controls closes, right after a regular one,
so ○ was still lost. The gate is now a pure, host-tested function: one request
marked {"final":true} is admitted inside the window, once per window, each body
still at most 4 KiB. The page marks its final batch (keepalive, cut to fit with
the flag) and sends it only after a regular send still on the wire, so the two
never arrive out of order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
The console page gets a Cheats section (rail: Home, Files, Cheats, Settings; Home's
Cheats shortcut now shows). It lists the running game's cheats as switches: X or the
square button switches the focused one, rows are inert while a switch runs, and the
helper's state is read again after every switch. No game, no cheats, the game closing
while the page is open, and helper errors each say what to do next, with Refresh.
The helper adapts the cheat methods exactly as the PC engine calls them:
/api/cheats/status (CHEATS_STATUS), /api/cheats/list {title_id} (CHEATS_GET, every
entry re-escaped, a cut reply keeps its whole entries) and /api/cheats/toggle
{title_id,index,on} (CHEATS_TOGGLE, its free-text refusals mapped to tokens).
Downloads and deletes stay in the PC app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
The PS5 browser drives an on-screen pointer and ✕ clicks at it, so the page
now treats hover as focus: the ring and what ✕ activates are the same
element. After a key move the pointer is parked (drift under 6 px is
ignored) and a click there activates the focused control instead.
nav.ts's button table covers every DualSense button with its action, the
keys/keyCodes it may arrive as and verified:false until measured: L2/R2 page,
L3 top, R3 rail, OPTIONS help, Create refresh, touchpad full screen, PS/Mute
system. The hint bar shows ✕/○ always, the rest once verified.
POST /api/input-log appends compact, privacy-neutral event lines to a 64 KiB
ring at /data/ps5upload/webui-input.log (same-directory rewrite, bounded
body, 429 under 500 ms); the page sends only while setInputLogEnabled(true).
Full screen on the first activation (standard, then webkit-prefixed), with a
Settings switch hidden where unsupported. Type scales with the viewport
(26 px at 1540x700). Raw page budget 640 KiB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
- /api/jobs releases the page's finished jobs after replying
(ava1_jobs_release_seen), so batches cannot fill the shared table.
- list_registered_titles_json leaves out WEBUI_TILE_TITLE_ID; a PC's
APP_UNREGISTER of the tile is Settings -> tile off (saved tile:false,
tile thread removes it) so the next start does not re-register it.
- Now running: the focused app is never the tile; the fallback names
only a game title id (PPSA/CUSA/... prefixes), never the browser, the
tile or homebrew. Codec selftest covers it.
- /api/settings carries tile_busy/tile_failed (webui_tile_state); the
tile request is made before the reply.
- put_str/r_status clamp `at` after every escape/printf; send_error and
r_fs_delete clamp too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
The page the helper serves on the PS5 itself: client/src/console/ built by
vite.console.config.ts into one self-contained dist-console/index.html
(script and styles inlined, safari13, 253 KiB raw / 84.5 KiB gzip; the
build fails over 450 KiB raw or 150 KiB gzip). payload/Makefile gzips it
into build/webui_page.c and the helper serves it at GET /, with
GET /api/token for the per-start token.
- Shell: top bar (logo, console chip from /api/status), rail of the
implemented sections (Home, Files, Settings placeholders), hint bar
showing only verified bindings, reload banner on a 401.
- nav.ts: spatial focus (aligned neighbours first), the R11 default input
table (keyboard + Gamepad "standard", all unverified), D-pad repeat.
- api.ts: token fetched once, X-PS5U-Token on every POST, 401 → expired.
- strings.ts: cw_* keys from all 21 locales via build-console-strings.mjs
(vm-eval like the i18n gate), English fallback.
- CI: client npm ci before every make payload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS