diff --git a/poc/README.md b/poc/README.md
index 32e98f1..b558316 100644
--- a/poc/README.md
+++ b/poc/README.md
@@ -15,7 +15,7 @@ pnpm install
| Command | Purpose |
| --- | --- |
| `pnpm poc:qr` | QR login. Writes the token file. |
-| `pnpm control:bridge` | Start the daemon. It listens on `http://127.0.0.1:8788/mcp`. |
+| `pnpm control:bridge` | Start the daemon. MCP on `http://127.0.0.1:8788/mcp`, web UI on `http://0.0.0.0:8789/`. The web UI can start/stop the client and run QR login. |
| `pnpm control:mock` | Start the mock core. Test the daemon without Electron. |
| `pnpm test:trigger` | Inject a trigger event into the mock core. |
| `pnpm poc:dm` | Send one test DM to zzib. |
@@ -36,9 +36,11 @@ core-side guardrails. Use it for debugging only.
## Pacing
-Every command gets a random delay of 500 to 2800 ms. One queue serializes the commands. The queue
-coalesces identical concurrent reads. State reads use the cache. `DEBUG=1` logs each command and
-its delay.
+Outbound sends use a human-emulation policy: the first send of a burst waits 30 to 200 ms, later
+sends wait 500 to 2800 ms, and a burst ends after a random 5 to 20 seconds of quiet. Read queries
+are serialized with a 100 to 300 ms gap and do not reset the burst. One queue serializes
+everything. The queue coalesces identical concurrent reads. State reads use the cache. `DEBUG=1`
+logs each command and its delay. See [../docs/daemon.md](../docs/daemon.md).
## Live test target
@@ -50,12 +52,25 @@ The only permitted live-send target is user zzib. See
- QR login works end-to-end. The token came from a phone scan.
- The core bridge works live. `get_state` returned the real account and 7 guilds. `send_dm` reached
zzib. A synthetic owner message reached the daemon log with the command parsed.
-- The mock path works. The daemon handles state, sends, commands, tiers, continuation, polls,
- context, and the action log.
-- Pacing stayed inside the 500 to 2800 ms window.
+- The mock path works. The daemon handles state, sends, commands, tiers, continuation, user search,
+ voice state, context, and the action log.
+- The web UI API and the SSE stream work against the mock core.
+- The web UI follows the Nocturne design handoff (`WebUIMockup.zip`). The 7a shell, the
+ locked/unlocked composer, the console drawer, and the Settings slide-over were verified by
+ rendering the page headless in Electron under Xvfb.
+- Core process control works live: the web UI start spawned the client under auto-Xvfb, the bridge
+ connected, `state.get` returned the real account and guilds, and stop exited with code 0.
+ Electron needs `libasound2`; Xvfb is auto-started when `DISPLAY` is unset.
+- The QR login session reaches `pending` with a scannable QR against the real gateway, and cancels
+ cleanly. A full approval was not run in this environment.
+- Pacing follows the burst policy: 30-200 ms for the first send, 500-2800 ms afterwards.
-Open live item: `reaction.add` and the message ID return are mock-verified only. The first live run
-must confirm Discord's internal `addReaction` lookup.
+Open live items:
+
+- `reaction.add` and the message ID return are mock-verified only. The first live run must confirm
+ Discord's internal `addReaction` lookup.
+- `user.search`, `voice.overview`, and `VOICE_STATE_UPDATE` are mock-verified only.
+- Voice join, leave, and move are not implemented.
## Findings
diff --git a/poc/WebUIMockup.zip b/poc/WebUIMockup.zip
new file mode 100644
index 0000000..d74016d
Binary files /dev/null and b/poc/WebUIMockup.zip differ
diff --git a/poc/package.json b/poc/package.json
index 219b9e2..3af3073 100644
--- a/poc/package.json
+++ b/poc/package.json
@@ -19,6 +19,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"express": "^5.2.1",
+ "qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0",
"ws": "^8.19.0",
"zod": "^4.6.2"
@@ -26,6 +27,7 @@
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^22.19.0",
+ "@types/qrcode": "^1.5.6",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"tsx": "^4.23.12",
diff --git a/poc/pnpm-lock.yaml b/poc/pnpm-lock.yaml
index 6aa71f1..bc89e06 100644
--- a/poc/pnpm-lock.yaml
+++ b/poc/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0
@@ -30,6 +33,9 @@ importers:
'@types/node':
specifier: ^22.19.0
version: 22.20.2
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@types/qrcode-terminal':
specifier: ^0.12.2
version: 0.12.2
@@ -238,6 +244,9 @@ packages:
'@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -268,6 +277,14 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
@@ -284,6 +301,20 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
@@ -321,10 +352,17 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -332,6 +370,9 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
@@ -388,6 +429,10 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -404,6 +449,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -447,6 +496,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -462,6 +515,10 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -504,10 +561,26 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -519,6 +592,10 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -527,6 +604,11 @@ packages:
resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==}
hasBin: true
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
@@ -539,10 +621,17 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -558,6 +647,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -589,6 +681,14 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -618,11 +718,18 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -638,6 +745,17 @@ packages:
utf-8-validate:
optional: true
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
@@ -782,6 +900,10 @@ snapshots:
'@types/qrcode-terminal@0.12.2': {}
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 22.20.2
+
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
@@ -815,6 +937,12 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
body-parser@2.3.0:
dependencies:
bytes: 3.1.2
@@ -841,6 +969,20 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
+ camelcase@5.3.1: {}
+
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -866,8 +1008,12 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
depd@2.0.0: {}
+ dijkstrajs@1.0.3: {}
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -876,6 +1022,8 @@ snapshots:
ee-first@1.1.1: {}
+ emoji-regex@8.0.0: {}
+
encodeurl@2.0.0: {}
es-define-property@1.0.1: {}
@@ -981,6 +1129,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
forwarded@0.2.0: {}
fresh@2.0.0: {}
@@ -990,6 +1143,8 @@ snapshots:
function-bind@1.1.2: {}
+ get-caller-file@2.0.5: {}
+
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1036,6 +1191,8 @@ snapshots:
ipaddr.js@1.9.1: {}
+ is-fullwidth-code-point@3.0.0: {}
+
is-promise@4.0.0: {}
isexe@2.0.0: {}
@@ -1046,6 +1203,10 @@ snapshots:
json-schema-typed@8.0.2: {}
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
math-intrinsics@1.1.0: {}
media-typer@1.1.1: {}
@@ -1076,14 +1237,28 @@ snapshots:
dependencies:
wrappy: 1.0.2
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
+ p-try@2.2.0: {}
+
parseurl@1.3.3: {}
+ path-exists@4.0.0: {}
+
path-key@3.1.1: {}
path-to-regexp@8.4.2: {}
pkce-challenge@5.0.1: {}
+ pngjs@5.0.0: {}
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -1091,6 +1266,12 @@ snapshots:
qrcode-terminal@0.12.0: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
@@ -1105,8 +1286,12 @@ snapshots:
iconv-lite: 0.7.3
unpipe: 1.0.0
+ require-directory@2.1.1: {}
+
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
router@2.2.0:
dependencies:
debug: 4.4.3
@@ -1144,6 +1329,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-blocking@2.0.0: {}
+
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
@@ -1182,6 +1369,16 @@ snapshots:
statuses@2.0.2: {}
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
toidentifier@1.0.1: {}
tsx@4.23.13:
@@ -1204,14 +1401,43 @@ snapshots:
vary@1.1.2: {}
+ which-module@2.0.1: {}
+
which@2.0.2:
dependencies:
isexe: 2.0.0
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrappy@1.0.2: {}
ws@8.21.3: {}
+ y18n@4.0.3: {}
+
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
zod-to-json-schema@3.25.2(zod@4.6.2):
dependencies:
zod: 4.6.2
diff --git a/poc/public/api.js b/poc/public/api.js
new file mode 100644
index 0000000..0f39954
--- /dev/null
+++ b/poc/public/api.js
@@ -0,0 +1,61 @@
+"use strict";
+
+window.VK = window.VK ?? {};
+
+const bus = new Map();
+
+window.VK.bus = {
+ on(type, handler) {
+ const handlers = bus.get(type) ?? new Set();
+ handlers.add(handler);
+ bus.set(type, handlers);
+ return () => handlers.delete(handler);
+ },
+ emit(type, payload) {
+ for (const handler of bus.get(type) ?? []) {
+ try {
+ handler(payload);
+ } catch (error) {
+ console.error(`[bus] ${type} handler failed`, error);
+ }
+ }
+ }
+};
+
+async function request(path, options) {
+ const response = await fetch(path, options);
+ let body;
+ try {
+ body = await response.json();
+ } catch {
+ throw new Error(`request failed (${response.status})`);
+ }
+ if (!body.ok) throw new Error(body.error ?? `request failed (${response.status})`);
+ return body.result;
+}
+
+window.VK.api = {
+ get: path => request(path),
+ post: (path, data) =>
+ request(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data ?? {})
+ })
+};
+
+window.VK.connectEvents = function connectEvents() {
+ const source = new EventSource("/api/events");
+
+ source.onmessage = message => {
+ let data;
+ try {
+ data = JSON.parse(message.data);
+ } catch {
+ return;
+ }
+ window.VK.bus.emit("event", data);
+ };
+
+ source.onerror = () => window.VK.bus.emit("sse-error");
+};
diff --git a/poc/public/app.js b/poc/public/app.js
new file mode 100644
index 0000000..3604f59
--- /dev/null
+++ b/poc/public/app.js
@@ -0,0 +1,1351 @@
+"use strict";
+
+const $ = (selector, root = document) => root.querySelector(selector);
+const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
+
+const SENDABLE_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
+const TEXT_TYPES = new Set([0, 5]);
+const VOICE_TYPES = new Set([2, 13]);
+
+const state = {
+ user: null,
+ guilds: [],
+ guildId: null,
+ channelId: null,
+ channel: null,
+ messages: [],
+ dms: [],
+ voiceOverview: null,
+ voiceSelf: null,
+ health: null,
+ eventCount: 0,
+ lastEventSample: null,
+ eventRate: 0,
+ events: [],
+ visibleGuilds: null,
+ composerChannelId: null,
+ takeover: localStorage.getItem("veskslop-takeover") === "1",
+ consoleOpen: false,
+ consoleTab: "events",
+ voiceReloadAt: 0
+};
+
+/* Drafts survive re-renders and reloads. Nothing else is persisted client-side. */
+
+const draftKey = channelId => `veskslop-draft:${channelId}`;
+const TTS_DRAFT_KEY = "veskslop-tts-draft";
+
+function isGuildVisible(guildId) {
+ return !state.visibleGuilds || state.visibleGuilds.has(guildId);
+}
+
+function visibleGuildsList() {
+ return state.guilds.filter(guild => isGuildVisible(guild.id));
+}
+
+function loadVisibleGuilds() {
+ try {
+ const raw = localStorage.getItem("veskslop-visible-guilds");
+ state.visibleGuilds = raw ? new Set(JSON.parse(raw)) : null;
+ } catch {
+ state.visibleGuilds = null;
+ }
+}
+
+function saveVisibleGuilds() {
+ if (!state.visibleGuilds || state.visibleGuilds.size === state.guilds.length) {
+ state.visibleGuilds = null;
+ localStorage.removeItem("veskslop-visible-guilds");
+ return;
+ }
+ localStorage.setItem("veskslop-visible-guilds", JSON.stringify([...state.visibleGuilds]));
+}
+
+/* Helpers */
+
+function el(tag, props = {}, ...children) {
+ const node = document.createElement(tag);
+
+ for (const [key, value] of Object.entries(props)) {
+ if (value === undefined || value === null) continue;
+ if (key === "class") node.className = value;
+ else if (key === "text") node.textContent = value;
+ else if (key === "html") node.innerHTML = value;
+ else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value);
+ else if (key === "disabled") node.disabled = !!value;
+ else node.setAttribute(key, value);
+ }
+
+ for (const child of children.flat()) {
+ if (child === undefined || child === null) continue;
+ node.append(child.nodeType ? child : document.createTextNode(String(child)));
+ }
+
+ return node;
+}
+
+function icon(name, size = 13) {
+ const span = el("span", { class: "vk-icon" });
+ span.innerHTML = VK.icon(name, size);
+ return span;
+}
+
+function hydrateIcons(root = document) {
+ for (const span of $$("[data-icon]", root)) span.innerHTML = VK.icon(span.dataset.icon);
+}
+
+function toast(message, kind = "") {
+ const node = el("div", { class: `vk-toast ${kind}`, text: message });
+ $("#toasts").append(node);
+ setTimeout(() => node.remove(), 5000);
+}
+
+function monogram(name) {
+ const clean = String(name ?? "?").replace(/[^a-zA-Z0-9]/g, "");
+ return (clean[0] ?? "?").toUpperCase();
+}
+
+const CDN_BASE = "https://cdn.discordapp.com";
+
+function cdnHashUrl(route, id, hash, size = 64) {
+ if (!hash) return null;
+ const ext = String(hash).startsWith("a_") ? "gif" : "png";
+ return `${CDN_BASE}/${route}/${id}/${hash}.${ext}?size=${size}`;
+}
+
+function defaultAvatarUrl(id) {
+ try {
+ return `${CDN_BASE}/embed/avatars/${Number(BigInt(String(id)) >> 22n) % 6}.png`;
+ } catch {
+ return null;
+ }
+}
+
+function userAvatarUrl(user, size = 64) {
+ if (!user?.id) return null;
+ return cdnHashUrl("avatars", user.id, user.avatar, size) ?? defaultAvatarUrl(user.id);
+}
+
+function guildIconUrl(guild, size = 64) {
+ return cdnHashUrl("icons", guild.id, guild.icon, size);
+}
+
+function dmIconUrl(dm, size = 64) {
+ if (dm?.icon) return cdnHashUrl("channel-icons", dm.id, dm.icon, size);
+ const recipient = dm?.recipients?.[0];
+ return recipient ? userAvatarUrl(recipient, size) : null;
+}
+
+function avatar(entity, { agent = false, size = "", url } = {}) {
+ const name = typeof entity === "string" ? entity : entity?.username ?? entity?.name ?? "?";
+ const resolved = url ?? (typeof entity === "object" && entity ? userAvatarUrl(entity) : null);
+ const node = el("span", { class: `vk-avatar ${agent ? "agent" : ""} ${size}`.trim() });
+
+ if (resolved) {
+ const img = el("img", { src: resolved, alt: "", loading: "lazy" });
+ img.addEventListener("error", () => {
+ img.remove();
+ node.textContent = monogram(name);
+ });
+ node.append(img);
+ } else {
+ node.textContent = monogram(name);
+ }
+
+ return node;
+}
+
+function dmAvatar(dm, opts = {}) {
+ return avatar(dmLabel(dm), { ...opts, url: dmIconUrl(dm) });
+}
+
+function guildMarkContent(mark, guild) {
+ const url = guildIconUrl(guild);
+ if (!url) {
+ mark.textContent = monogram(guild.name);
+ return;
+ }
+
+ const img = el("img", { src: url, alt: "", loading: "lazy" });
+ img.addEventListener("error", () => {
+ img.remove();
+ mark.textContent = monogram(guild.name);
+ });
+ mark.append(img);
+}
+
+function formatTime(timestamp) {
+ const date = new Date(timestamp);
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
+
+function formatUptime(seconds) {
+ if (!Number.isFinite(seconds)) return "up —";
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ if (days) return `up ${days}d ${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
+ if (hours) return `up ${hours}h ${String(minutes).padStart(2, "0")}m`;
+ return `up ${minutes}m`;
+}
+
+function currentGuild() {
+ return state.guilds.find(guild => guild.id === state.guildId) ?? null;
+}
+
+function findGuildChannel(channelId) {
+ for (const guild of state.guilds) {
+ const channel = guild.channels.find(item => item.id === channelId);
+ if (channel) return channel;
+ }
+ return null;
+}
+
+function dmById(channelId) {
+ return state.dms.find(dm => dm.id === channelId) ?? null;
+}
+
+function dmLabel(dm) {
+ if (dm.name) return dm.name;
+ const names = (dm.recipients ?? []).map(recipient => recipient.username);
+ if (!names.length) return "DM";
+ if (names.length <= 2) return names.join(", ");
+ return `${names[0]} + ${names.length - 1}`;
+}
+
+function channelLabel() {
+ if (!state.channel) return "No channel selected";
+ return state.channel.name ?? channelLabelFromDm();
+}
+
+function channelLabelFromDm() {
+ const dm = state.channelId ? dmById(state.channelId) : null;
+ return dm ? dmLabel(dm) : "Direct message";
+}
+
+/* Data loading */
+
+async function loadState() {
+ let data;
+ try {
+ data = await VK.api.get("/api/state");
+ } catch {
+ renderIdentity();
+ renderGuildPicker();
+ renderGuildSwitcher();
+ return;
+ }
+
+ state.user = data.user ?? null;
+ state.guilds = data.guilds ?? [];
+
+ const visible = visibleGuildsList();
+ if (!state.guildId || !visible.some(guild => guild.id === state.guildId)) {
+ state.guildId = visible[0]?.id ?? null;
+ }
+
+ renderIdentity();
+ renderGuildPicker();
+ renderGuildSwitcher();
+ renderVisibleGuilds();
+ renderRail();
+
+ const guild = currentGuild();
+ if (!state.channelId && guild) {
+ const first = guild.channels.find(channel => SENDABLE_TYPES.has(channel.type));
+ if (first) await selectChannel(first.id, guild.id);
+ } else if (state.channelId) {
+ renderComposer();
+ }
+}
+
+async function loadDms() {
+ try {
+ const result = await VK.api.get("/api/dms");
+ state.dms = result.channels ?? [];
+ } catch {
+ state.dms = [];
+ }
+ renderDirect();
+ renderRightDms();
+ renderRail();
+}
+
+async function loadVoice() {
+ try {
+ state.voiceSelf = await VK.api.get("/api/voice");
+ } catch {
+ state.voiceSelf = null;
+ }
+
+ if (state.guildId) {
+ try {
+ const overview = await VK.api.get(`/api/voice/overview?guildId=${encodeURIComponent(state.guildId)}`);
+ state.voiceOverview = overview.channels ?? [];
+ } catch {
+ state.voiceOverview = [];
+ }
+ } else {
+ state.voiceOverview = [];
+ }
+
+ renderVoiceRail();
+ renderRightVoice();
+ renderCallStrip();
+}
+
+async function loadMessages({ refresh = false } = {}) {
+ if (!state.channelId) {
+ state.messages = [];
+ renderMessages();
+ return;
+ }
+
+ const channelId = state.channelId;
+
+ try {
+ // refresh fetches the latest 50 from Discord and fills the client cache.
+ // Attachments stay CDN links; nothing is downloaded or cached locally.
+ const result = await VK.api.get(
+ `/api/messages?channelId=${encodeURIComponent(channelId)}&limit=50${refresh ? "&refresh=1" : ""}`
+ );
+ if (channelId !== state.channelId) return;
+ state.messages = result.messages ?? [];
+ } catch (error) {
+ if (channelId !== state.channelId) return;
+ state.messages = [];
+ toast(error.message, "error");
+ }
+
+ renderMessages();
+}
+
+let refreshing = false;
+
+/** Reload everything from the daemon without touching any draft input. */
+async function refreshAll() {
+ if (refreshing) return;
+ refreshing = true;
+
+ try {
+ await loadState();
+ await loadDms();
+ await loadVoice();
+ if (state.channelId) await loadMessages({ refresh: true });
+ } catch (error) {
+ toast(error.message, "error");
+ } finally {
+ refreshing = false;
+ }
+}
+
+async function selectGuild(guildId) {
+ state.guildId = guildId;
+ state.channelId = null;
+ state.channel = null;
+ $("#guild-menu").hidden = true;
+
+ renderGuildPicker();
+ renderGuildSwitcher();
+ renderRail();
+ renderRightVoice();
+
+ const guild = currentGuild();
+ const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type));
+ if (first) await selectChannel(first.id, guildId);
+
+ await loadVoice();
+}
+
+async function selectChannel(channelId, guildId) {
+ if (guildId && guildId !== state.guildId) state.guildId = guildId;
+
+ state.channelId = channelId;
+ state.channel = findGuildChannel(channelId);
+
+ if (!state.channel) {
+ const dm = dmById(channelId);
+ if (dm) state.channel = { id: dm.id, name: dmLabel(dm), type: dm.type, dm };
+ }
+
+ renderGuildPicker();
+ renderRail();
+ renderCenterHeader();
+ renderRightDms();
+ renderComposer();
+ await loadMessages({ refresh: true });
+ await loadVoice();
+}
+
+async function sendMessage() {
+ if (!state.takeover) {
+ toast("Composer is read-only — turn Takeover on to send", "error");
+ return;
+ }
+
+ const input = $("#composer-input");
+ const content = input?.value.trim() ?? "";
+ if (!content || !state.channelId) return;
+
+ try {
+ await VK.api.post("/api/messages", { channelId: state.channelId, content });
+ localStorage.removeItem(draftKey(state.channelId));
+ input.value = "";
+ autoGrow(input);
+ toast("Message sent", "ok");
+ await loadMessages();
+ } catch (error) {
+ toast(error.message, "error");
+ }
+}
+
+function autoGrow(input) {
+ input.style.height = "auto";
+ input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
+}
+
+/* Header and rail rendering */
+
+function renderIdentity() {
+ const footer = $("#agent-identity");
+ const name = state.user?.username ?? "not logged in";
+
+ footer.replaceChildren(
+ state.user ? avatar(state.user, { agent: true }) : avatar(name, { agent: true }),
+ el(
+ "span",
+ { class: "vk-right-name" },
+ el("span", { class: "vk-agent-name", text: name }),
+ el("span", { class: "vk-agent-sub", text: "agent · headless" })
+ )
+ );
+}
+
+function renderGuildPicker() {
+ const guild = currentGuild();
+ $("#guild-picker-label").textContent = guild?.name ?? "Direct messages";
+}
+
+function renderGuildSwitcher() {
+ const root = $("#guild-switcher");
+ root.replaceChildren();
+
+ for (const guild of visibleGuildsList()) {
+ const button = el("button", {
+ class: `vk-guild-mark ${guild.id === state.guildId ? "active" : ""}`,
+ type: "button",
+ title: guild.name,
+ onclick: () => void selectGuild(guild.id)
+ });
+ guildMarkContent(button, guild);
+ root.append(button);
+ }
+}
+
+function setGuildVisible(guildId, visible) {
+ if (!state.visibleGuilds) state.visibleGuilds = new Set(state.guilds.map(guild => guild.id));
+
+ if (visible) state.visibleGuilds.add(guildId);
+ else state.visibleGuilds.delete(guildId);
+ saveVisibleGuilds();
+
+ if (state.guildId && !isGuildVisible(state.guildId)) {
+ const next = visibleGuildsList()[0];
+ if (next) void selectGuild(next.id);
+ }
+
+ renderGuildPicker();
+ renderGuildSwitcher();
+ renderVisibleGuilds();
+}
+
+function renderVisibleGuilds() {
+ const root = $("#guild-visibility");
+ if (!root) return;
+ root.replaceChildren();
+
+ if (!state.guilds.length) {
+ root.append(el("div", { class: "vk-empty", text: "No guilds cached." }));
+ return;
+ }
+
+ for (const guild of state.guilds) {
+ const checkbox = el("input", { type: "checkbox" });
+ checkbox.checked = isGuildVisible(guild.id);
+ checkbox.addEventListener("change", () => setGuildVisible(guild.id, checkbox.checked));
+
+ const mark = el("span", { class: "vk-guild-icon-sm" });
+ guildMarkContent(mark, guild);
+
+ root.append(el("label", { class: "vk-visibility-row" }, checkbox, mark, el("span", { text: guild.name })));
+ }
+}
+
+function voiceMarks(member) {
+ const marks = el("span", { class: "vk-marks" });
+
+ if (member.selfMute || member.mute) {
+ marks.append(el("span", { class: "neutral", title: member.mute ? "muted by server" : "self-muted", html: VK.icon("micSlash") }));
+ }
+ if (member.selfDeaf || member.deaf) {
+ marks.append(el("span", { class: "neutral", title: "deafened", html: VK.icon("speakerSlash") }));
+ }
+
+ return marks;
+}
+
+function renderRail() {
+ renderTextChannels();
+ renderVoiceRail();
+ renderDirect();
+}
+
+function renderTextChannels() {
+ const root = $("#text-channels");
+ root.replaceChildren();
+ const guild = currentGuild();
+ if (!guild) return;
+
+ const channels = guild.channels.filter(channel => TEXT_TYPES.has(channel.type));
+ if (!channels.length) {
+ root.append(el("div", { class: "vk-empty", text: "No text channels cached." }));
+ return;
+ }
+
+ for (const channel of channels) {
+ root.append(
+ el(
+ "button",
+ {
+ class: `vk-rail-row ${channel.id === state.channelId ? "active" : ""}`,
+ type: "button",
+ onclick: () => void selectChannel(channel.id)
+ },
+ el("span", { class: "vk-icon vk-dim", html: VK.icon("hash") }),
+ el("span", { class: "vk-rail-name", text: channel.name })
+ )
+ );
+ }
+}
+
+function renderVoiceRail() {
+ const root = $("#voice-channels");
+ if (!root) return;
+ root.replaceChildren();
+
+ const guild = currentGuild();
+ if (!guild) return;
+
+ const voiceChannels = guild.channels.filter(channel => VOICE_TYPES.has(channel.type));
+ if (!voiceChannels.length) {
+ root.append(el("div", { class: "vk-empty", text: "No voice channels cached." }));
+ return;
+ }
+
+ const occupied = new Map((state.voiceOverview ?? []).map(entry => [entry.channelId, entry]));
+
+ for (const channel of voiceChannels) {
+ const call = occupied.get(channel.id);
+ const active = state.voiceSelf?.channelId === channel.id;
+
+ if (call?.members?.length) {
+ const block = el("div", { class: "vk-rail-voice" });
+ block.append(
+ el(
+ "div",
+ { class: "vk-rail-voice-head" },
+ el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
+ el("span", { class: "vk-rail-name", style: "font-weight:500", text: channel.name }),
+ el(
+ "span",
+ { class: "vk-rail-trail" },
+ el("span", {
+ style: `display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;background:var(--color-${active ? "accent" : "neutral-500"})`
+ }),
+ `${call.members.length}`
+ )
+ )
+ );
+
+ const members = el("div", { class: "vk-rail-voice-members" });
+ for (const member of call.members) {
+ members.append(
+ el(
+ "span",
+ { class: "vk-voice-member" },
+ el("span", {
+ class: "vk-name",
+ style: member.id === state.user?.id ? "color: var(--color-accent-300)" : "",
+ text: member.username
+ }),
+ voiceMarks(member)
+ )
+ );
+ }
+ block.append(members);
+ root.append(block);
+ } else {
+ root.append(
+ el(
+ "button",
+ {
+ class: `vk-rail-row ${active ? "active" : ""}`,
+ type: "button",
+ onclick: () => joinVoice(channel.id)
+ },
+ el("span", { class: "vk-icon vk-dim", html: VK.icon("volume") }),
+ el("span", { class: "vk-rail-name", text: channel.name }),
+ el("span", { class: "vk-rail-trail", text: "0" })
+ )
+ );
+ }
+ }
+}
+
+function renderDirect() {
+ const root = $("#direct-list");
+ if (!root) return;
+ root.replaceChildren();
+
+ if (!state.dms.length) {
+ root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
+ return;
+ }
+
+ for (const dm of state.dms.slice(0, 12)) {
+ root.append(
+ el(
+ "button",
+ {
+ class: `vk-rail-row ${dm.id === state.channelId ? "active" : ""}`,
+ type: "button",
+ onclick: () => void selectChannel(dm.id)
+ },
+ dmAvatar(dm, { size: "sm" }),
+ el("span", { class: "vk-rail-name", text: dmLabel(dm) })
+ )
+ );
+ }
+}
+
+/* Center rendering */
+
+function renderCenterHeader() {
+ const title = channelLabel();
+ const channel = state.channel;
+ const guild = currentGuild();
+
+ $("#channel-title").textContent = title;
+ $("#channel-icon").innerHTML = VK.icon(
+ channel && VOICE_TYPES.has(channel.type) ? "volume" : channel?.dm ? "speaker" : "hash"
+ );
+
+ let context = "";
+ if (channel?.dm) {
+ const count = channel.dm.recipients?.length ?? 0;
+ context = channel.dm.type === 3 ? `group · ${count} recipients` : "direct message";
+ } else if (guild) {
+ context = `${guild.name} · ${guild.channels.length} channels cached`;
+ }
+
+ $("#channel-context").textContent = context;
+}
+
+function renderMessages() {
+ const root = $("#message-list");
+ root.replaceChildren();
+
+ if (!state.channelId) {
+ root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." }));
+ return;
+ }
+
+ if (!state.messages.length) {
+ root.append(
+ el("div", {
+ class: "vk-empty",
+ text: "No cached messages. The client only holds channels it has loaded."
+ })
+ );
+ return;
+ }
+
+ for (const message of state.messages) {
+ const author = message.author?.username ?? "unknown";
+ const isAgent = message.author?.id === state.user?.id;
+ const body = el(
+ "div",
+ { class: "vk-message-body-wrap" },
+ el(
+ "span",
+ { class: "vk-message-head" },
+ el("span", { class: "vk-message-author", text: author }),
+ isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null,
+ el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) })
+ ),
+ el("span", { class: "vk-message-body", text: message.content })
+ );
+
+ for (const attachment of message.attachments ?? []) {
+ if (!attachment) continue;
+
+ const filename = attachment.filename ?? "attachment";
+ const isImage =
+ String(attachment.contentType ?? "").startsWith("image/") ||
+ /\.(png|jpe?g|gif|webp|avif)$/i.test(filename);
+
+ if (isImage && attachment.url) {
+ const image = el("img", {
+ class: "vk-message-image",
+ src: attachment.url,
+ alt: filename,
+ loading: "lazy"
+ });
+ image.addEventListener("error", () => image.remove());
+ body.append(image);
+ } else if (attachment.url) {
+ body.append(
+ el("a", {
+ class: "vk-attachment",
+ href: attachment.url,
+ target: "_blank",
+ rel: "noreferrer",
+ text: filename
+ })
+ );
+ } else if (filename) {
+ body.append(el("span", { class: "vk-attachment", text: filename }));
+ }
+ }
+
+ root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body));
+ }
+
+ const scroll = $("#message-scroll");
+ scroll.scrollTop = scroll.scrollHeight;
+}
+
+function ttsToggleButton() {
+ return el("button", {
+ id: "tts-toggle",
+ class: "btn btn-secondary vk-icon-btn vk-tts-toggle",
+ type: "button",
+ title: "Text to speech",
+ onclick: toggleTts,
+ html: VK.icon("wave")
+ });
+}
+
+function composerPlaceholder() {
+ const target = channelLabel() === "No channel selected" ? "" : channelLabel();
+ return `Message ${target} as ${state.user?.username ?? "the account"}`;
+}
+
+function renderComposer() {
+ const wrap = $("#composer");
+ const existing = $("#composer-input");
+
+ if (state.takeover) {
+ wrap.classList.add("unlocked");
+
+ // Reuse the live input so typing and caret survive re-renders.
+ if (existing && state.composerChannelId === state.channelId) {
+ existing.placeholder = composerPlaceholder();
+ return;
+ }
+
+ const draft = state.channelId ? localStorage.getItem(draftKey(state.channelId)) ?? "" : "";
+ const input = el("textarea", {
+ id: "composer-input",
+ class: "vk-composer-input",
+ rows: "1",
+ placeholder: composerPlaceholder()
+ });
+ input.value = draft;
+ input.addEventListener("input", () => {
+ autoGrow(input);
+ if (state.channelId) localStorage.setItem(draftKey(state.channelId), input.value);
+ });
+ input.addEventListener("keydown", event => {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ void sendMessage();
+ }
+ });
+
+ state.composerChannelId = state.channelId;
+ wrap.replaceChildren(
+ ttsToggleButton(),
+ input,
+ el("button", {
+ id: "send",
+ class: "btn btn-primary vk-send",
+ type: "button",
+ text: "Send",
+ onclick: () => void sendMessage()
+ })
+ );
+ requestAnimationFrame(() => autoGrow(input));
+ return;
+ }
+
+ state.composerChannelId = null;
+ wrap.classList.remove("unlocked");
+ wrap.replaceChildren(
+ el("span", { class: "tag tag-neutral vk-readonly-tag", text: "read-only" }),
+ icon("lock"),
+ el("span", {
+ class: "vk-composer-copy",
+ text: `Composer locked — takeover is off. Flip Takeover to send as ${state.user?.username ?? "the account"}.`
+ }),
+ ttsToggleButton(),
+ el("button", { class: "btn btn-secondary vk-send", type: "button", text: "Send", disabled: true })
+ );
+}
+
+function toggleTts() {
+ const panel = $("#tts-panel");
+ panel.hidden = !panel.hidden;
+ $("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`;
+}
+
+/* Right column */
+
+function renderRightVoice() {
+ const root = $("#right-voice");
+ if (!root) return;
+ root.replaceChildren();
+
+ const channels = (state.voiceOverview ?? []).filter(entry => entry.members?.length);
+ const rows = channels.flatMap(entry => entry.members.map(member => ({ member, entry })));
+
+ $("#voice-count").textContent = rows.length ? `${Math.min(rows.length, 7)} of ${rows.length} shown` : "";
+
+ if (!rows.length) {
+ root.append(el("div", { class: "vk-empty", text: "No voice channels occupied." }));
+ return;
+ }
+
+ for (const { member, entry } of rows.slice(0, 7)) {
+ const isSelf = member.id === state.user?.id;
+
+ root.append(
+ el(
+ "div",
+ { class: `vk-right-row ${isSelf ? "highlight" : ""}` },
+ avatar(member),
+ el(
+ "span",
+ { class: "vk-right-name" },
+ el("span", { class: "vk-name", text: member.username }),
+ el("span", { class: "vk-sub", text: entry.channelName ?? entry.channelId })
+ ),
+ el(
+ "span",
+ { class: "vk-right-trail" },
+ voiceMarks(member),
+ isSelf
+ ? el("button", {
+ class: "btn btn-secondary vk-small-btn",
+ type: "button",
+ text: "View",
+ onclick: () => toast("Voice monitoring is not implemented yet", "error")
+ })
+ : el("button", {
+ class: "btn btn-primary vk-small-btn",
+ type: "button",
+ text: "Join",
+ onclick: () => joinVoice(entry.channelId)
+ })
+ )
+ )
+ );
+ }
+}
+
+function renderRightDms() {
+ const root = $("#right-dms");
+ if (!root) return;
+ root.replaceChildren();
+
+ $("#dm-count").textContent = state.dms.length ? `${state.dms.length} threads` : "";
+
+ if (!state.dms.length) {
+ root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
+ return;
+ }
+
+ for (const dm of state.dms.slice(0, 8)) {
+ root.append(
+ el(
+ "button",
+ {
+ class: `vk-rail-row ${dm.id === state.channelId ? "active" : ""}`,
+ type: "button",
+ onclick: () => void selectChannel(dm.id)
+ },
+ dmAvatar(dm, { size: "sm" }),
+ el("span", { class: "vk-rail-name", text: dmLabel(dm) })
+ )
+ );
+ }
+}
+
+function renderCallStrip() {
+ const strip = $("#call-strip");
+ const call = state.voiceSelf;
+
+ if (!call?.channelId || call.channelId === state.channelId) {
+ strip.hidden = true;
+ strip.replaceChildren();
+ return;
+ }
+
+ strip.replaceChildren(
+ el(
+ "span",
+ { class: "vk-call-title" },
+ el("span", { class: "vk-call-live" }),
+ el("span", { class: "vk-call-name", text: call.channelName ?? call.channelId }),
+ el("span", { class: "vk-mono vk-dim", text: `${call.members?.length ?? 0} in call` })
+ ),
+ el(
+ "div",
+ { class: "vk-call-pills" },
+ ...(call.members ?? []).map(member =>
+ el(
+ "span",
+ { class: "vk-strip-pill" },
+ avatar(member, { size: "sm" }),
+ el("span", { class: "vk-strip-name", text: member.username }),
+ voiceMarks(member)
+ )
+ )
+ ),
+ el(
+ "div",
+ { class: "vk-call-actions" },
+ el("button", {
+ class: "btn btn-secondary vk-small-btn",
+ type: "button",
+ text: "Return to call",
+ onclick: () => void selectChannel(call.channelId)
+ }),
+ el("button", {
+ class: "btn btn-secondary vk-small-btn",
+ type: "button",
+ text: "Leave",
+ onclick: () => voiceAction("/api/voice/leave")
+ })
+ )
+ );
+
+ strip.hidden = false;
+}
+
+async function joinVoice(channelId) {
+ await voiceAction("/api/voice/join", { channelId });
+}
+
+async function voiceAction(path, body) {
+ try {
+ await VK.api.post(path, body ?? {});
+ toast("Voice action accepted", "ok");
+ await loadVoice();
+ } catch (error) {
+ toast(error.message, "error");
+ }
+}
+
+/* Console */
+
+function addEvent(event) {
+ state.events.unshift(event);
+ if (state.events.length > 300) state.events.pop();
+ state.eventCount++;
+
+ const log = $("#event-log");
+ if (log) {
+ log.prepend(
+ el(
+ "div",
+ { class: "vk-event-line" },
+ el("span", { class: "vk-event-time", text: new Date().toLocaleTimeString() }),
+ el("span", { class: "vk-event-type", text: event.type ?? "event" }),
+ el("span", { text: JSON.stringify(event) })
+ )
+ );
+
+ while (log.childElementCount > 200) log.lastElementChild.remove();
+ }
+
+ const ticker = $("#ticker-line");
+ if (ticker) ticker.textContent = `${event.type ?? "event"} ${JSON.stringify(event).slice(0, 180)}`;
+
+ syncConsoleCount();
+}
+
+function syncConsoleCount() {
+ const count = $("#console-count");
+ if (count) count.textContent = `${state.eventCount} events`;
+}
+
+function setConsoleOpen(open) {
+ state.consoleOpen = open;
+ $("#console-drawer").hidden = !open;
+ $("#console-caret").innerHTML = VK.icon(open ? "caretDown" : "caretUp");
+}
+
+function setConsoleTab(tab) {
+ state.consoleTab = tab;
+ for (const button of $$(".vk-console-tab")) button.classList.toggle("active", button.dataset.consoleTab === tab);
+ for (const panel of $$(".vk-console-panel")) panel.hidden = panel.id !== `console-panel-${tab}`;
+ if (!state.consoleOpen) setConsoleOpen(true);
+}
+
+/* Settings */
+
+function openSettings() {
+ $("#settings-overlay").hidden = false;
+ void refreshCoreStatus();
+ void refreshAuth();
+ void refreshLogs();
+}
+
+function closeSettings() {
+ $("#settings-overlay").hidden = true;
+}
+
+function statusRow(label, value) {
+ return el(
+ "div",
+ { class: "vk-status-row" },
+ el("span", { class: "vk-status-label", text: label }),
+ el("span", { class: "vk-status-value" }, value)
+ );
+}
+
+function badge(text, kind = "") {
+ return el("span", { class: `tag ${kind === "ok" ? "tag-accent" : "tag-neutral"}`, text });
+}
+
+function renderCoreStatus(status) {
+ const card = $("#core-status");
+ const process = status.process ?? {};
+ const rows = [
+ statusRow("bridge", badge(status.bridgeConnected ? "connected" : "disconnected", status.bridgeConnected ? "ok" : "")),
+ statusRow("process", badge(process.running ? "running" : "stopped", process.running ? "ok" : "")),
+ statusRow("pid", el("code", { text: process.pid ?? "—" })),
+ statusRow("managed", el("span", { text: process.managed ? "yes" : process.running ? "external" : "no" })),
+ statusRow("started", el("span", { text: process.startedAt ? new Date(process.startedAt).toLocaleString() : "—" })),
+ statusRow("command", el("code", { text: process.command ?? "—" }))
+ ];
+
+ if (process.lastExit) {
+ const exit = process.lastExit;
+ const text = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? "?"}`;
+ rows.push(statusRow("last exit", badge(text, exit.code === 0 ? "ok" : "")));
+ }
+
+ card.replaceChildren(...rows);
+
+ if (process.output?.length) {
+ card.append(el("pre", { class: "vk-core-output", text: process.output.join("\n") }));
+ }
+}
+
+async function refreshCoreStatus() {
+ try {
+ renderCoreStatus(await VK.api.get("/api/core/status"));
+ } catch (error) {
+ toast(error.message, "error");
+ }
+}
+
+async function coreAction(path, label) {
+ try {
+ renderCoreStatus(await VK.api.post(path));
+ toast(`${label} requested`, "ok");
+ void refreshHealth();
+ } catch (error) {
+ toast(error.message, "error");
+ }
+}
+
+function renderAuth(status) {
+ const card = $("#auth-status");
+ const session = status.session;
+ const rows = [
+ statusRow("token file", el("code", { text: status.tokenFile })),
+ statusRow("token", badge(status.hasToken ? "present" : "missing", status.hasToken ? "ok" : "")),
+ statusRow("session", el("span", { text: session?.state ?? "none" }))
+ ];
+
+ if (session?.user) rows.push(statusRow("user", el("span", { text: `${session.user.tag} — ${session.user.id}` })));
+ if (session?.captcha) rows.push(statusRow("captcha sitekey", el("code", { text: session.captcha.sitekey ?? "—" })));
+ if (session?.error) rows.push(statusRow("error", el("span", { text: session.error })));
+
+ card.replaceChildren(...rows);
+
+ const qr = $("#auth-qr");
+ if (status.qrDataUrl) {
+ qr.replaceChildren(el("img", { src: status.qrDataUrl, alt: "Discord login QR" }));
+ qr.hidden = false;
+ } else {
+ qr.replaceChildren();
+ qr.hidden = true;
+ }
+
+ $("#auth-captcha").hidden = session?.state !== "captcha";
+}
+
+async function refreshAuth() {
+ try {
+ renderAuth(await VK.api.get("/api/auth"));
+ } catch (error) {
+ toast(error.message, "error");
+ }
+}
+
+async function authAction(path, label) {
+ try {
+ renderAuth(await VK.api.post(path));
+ if (label) toast(label, "ok");
+ } catch (error) {
+ toast(error.message, "error");
+ }
+}
+
+async function refreshLogs() {
+ const root = $("#action-log");
+ try {
+ const entries = await VK.api.get("/api/logs?limit=100");
+ root.replaceChildren();
+
+ if (!entries.length) {
+ root.append(el("div", { class: "vk-empty", text: "Action log is empty." }));
+ return;
+ }
+
+ for (const entry of [...entries].reverse()) {
+ root.append(
+ el(
+ "div",
+ { class: "vk-log-line" },
+ el("span", { class: "vk-log-time", text: entry.timestamp?.slice(11, 19) ?? "" }),
+ el("span", { class: "vk-log-type", text: entry.type ?? "?" }),
+ el("span", { text: JSON.stringify(entry).slice(0, 220) })
+ )
+ );
+ }
+ } catch (error) {
+ root.replaceChildren(el("div", { class: "vk-empty", text: error.message }));
+ }
+}
+
+/* Health */
+
+async function refreshHealth() {
+ try {
+ const health = await VK.api.get("/api/health");
+ state.health = health;
+
+ const now = Date.now();
+ if (state.lastEventSample) {
+ const seconds = (now - state.lastEventSample.at) / 1000;
+ const delta = (health.events ?? 0) - state.lastEventSample.events;
+ if (seconds > 0) state.eventRate = delta / seconds;
+ }
+ state.lastEventSample = { at: now, events: health.events ?? 0 };
+
+ const dot = $("#health-dot");
+ dot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
+ $("#health-bridge").textContent = health.coreConnected ? "bridge ok" : "bridge down";
+ $("#health-uptime").textContent = formatUptime(health.uptimeSeconds);
+ $("#health-events").textContent = `${state.eventRate.toFixed(1)} ev/s`;
+
+ const mcpDot = $("#mcp-dot");
+ mcpDot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
+ $("#mcp-state").textContent = health.coreConnected ? "mcp connected" : "mcp offline";
+ } catch {
+ $("#health-dot").className = "vk-dot offline";
+ $("#health-bridge").textContent = "daemon down";
+ }
+}
+
+/* Live events */
+
+function applyHello(event) {
+ if (typeof event.coreConnected !== "boolean") return;
+ $("#health-dot").className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
+ $("#health-bridge").textContent = event.coreConnected ? "bridge ok" : "bridge down";
+ const mcpDot = $("#mcp-dot");
+ if (mcpDot) mcpDot.className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
+
+ // The page may have loaded before the bridge was up.
+ if (event.coreConnected && !state.user) void refreshAll();
+}
+
+function renderRightFavourites() {
+ const root = $("#right-favourites");
+ if (!root) return;
+ root.replaceChildren(el("div", { class: "vk-empty", text: "Favourites are not implemented yet." }));
+}
+
+function handleLiveEvent(event) {
+ if (event.type === "MESSAGE_CREATE" && event.channelId === state.channelId) {
+ void loadMessages();
+ }
+
+ if (event.type === "VOICE_STATE_UPDATE") {
+ const now = Date.now();
+ if (now - state.voiceReloadAt > 1000) {
+ state.voiceReloadAt = now;
+ void loadVoice();
+ }
+ }
+
+ if (event.type === "CORE_STATUS") {
+ void refreshHealth();
+ if (event.coreConnected) void refreshAll();
+ if (!$("#settings-overlay").hidden) void refreshCoreStatus();
+ }
+
+ if (event.type === "AUTH_QR" && !$("#settings-overlay").hidden) {
+ void refreshAuth();
+ }
+}
+
+/* Wiring */
+
+function init() {
+ hydrateIcons();
+ loadVisibleGuilds();
+
+ const takeover = $("#takeover");
+ takeover.checked = state.takeover;
+ $("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
+ takeover.addEventListener("change", () => {
+ state.takeover = takeover.checked;
+ localStorage.setItem("veskslop-takeover", state.takeover ? "1" : "0");
+ $("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
+ renderComposer();
+ });
+
+ $("#guild-picker").addEventListener("click", () => {
+ const menu = $("#guild-menu");
+ menu.hidden = !menu.hidden;
+ if (menu.hidden) return;
+
+ menu.replaceChildren(
+ ...visibleGuildsList().map(guild =>
+ el("button", {
+ class: `vk-menu-item ${guild.id === state.guildId ? "active" : ""}`,
+ type: "button",
+ text: guild.name,
+ onclick: () => void selectGuild(guild.id)
+ })
+ )
+ );
+ });
+
+ $("#settings-open").addEventListener("click", openSettings);
+ $("#settings-close").addEventListener("click", closeSettings);
+ $("#settings-overlay").addEventListener("click", event => {
+ if (event.target === $("#settings-overlay")) closeSettings();
+ });
+
+ $("#core-start").addEventListener("click", () => void coreAction("/api/core/start", "Start"));
+ $("#core-stop").addEventListener("click", () => void coreAction("/api/core/stop", "Stop"));
+ $("#core-restart").addEventListener("click", () => void coreAction("/api/core/restart", "Restart"));
+
+ $("#auth-start").addEventListener("click", () => void authAction("/api/auth/qr", "QR login started"));
+ $("#auth-cancel").addEventListener("click", () => void authAction("/api/auth/qr/cancel", "Login cancelled"));
+ $("#auth-restart").addEventListener("click", async () => {
+ try {
+ renderCoreStatus(await VK.api.post("/api/core/restart", { forceToken: true }));
+ toast("Restarting with the token file", "ok");
+ } catch (error) {
+ toast(error.message, "error");
+ }
+ });
+
+ $("#auth-captcha").addEventListener("submit", async event => {
+ event.preventDefault();
+ const key = $("#auth-captcha-key").value.trim();
+ if (!key) return;
+
+ try {
+ await VK.api.post("/api/auth/qr/captcha", { key });
+ $("#auth-captcha-key").value = "";
+ toast("Captcha submitted", "ok");
+ void refreshAuth();
+ } catch (error) {
+ toast(error.message, "error");
+ }
+ });
+
+ $("#logs-refresh").addEventListener("click", () => void refreshLogs());
+
+ $("#console-toggle").addEventListener("click", () => setConsoleOpen(!state.consoleOpen));
+ for (const tab of $$(".vk-console-tab")) {
+ tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab));
+ }
+
+ $("#raw-events").addEventListener("click", () => setConsoleTab("events"));
+ $("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
+
+ $("#guilds-show-all").addEventListener("click", () => {
+ state.visibleGuilds = null;
+ localStorage.removeItem("veskslop-visible-guilds");
+ renderVisibleGuilds();
+ renderGuildSwitcher();
+ renderGuildPicker();
+ });
+
+ const ttsInput = $("#tts-input");
+ ttsInput.value = localStorage.getItem(TTS_DRAFT_KEY) ?? "";
+ ttsInput.addEventListener("input", () => localStorage.setItem(TTS_DRAFT_KEY, ttsInput.value));
+
+ $("#tts-close").addEventListener("click", () => {
+ $("#tts-panel").hidden = true;
+ });
+ $("#tts-speak").addEventListener("click", () => toast("TTS is not implemented yet", "error"));
+
+ document.addEventListener("keydown", event => {
+ if (event.key !== "Escape") return;
+ if (!$("#settings-overlay").hidden) closeSettings();
+ else if (state.consoleOpen) setConsoleOpen(false);
+ });
+
+ VK.bus.on("event", event => {
+ if (event.type === "hello") {
+ applyHello(event);
+ return;
+ }
+
+ addEvent(event);
+ handleLiveEvent(event);
+ });
+
+ VK.connectEvents();
+
+ setConsoleTab("events");
+ setConsoleOpen(false);
+ syncConsoleCount();
+ renderRightFavourites();
+
+ void loadState();
+ void loadDms();
+ void loadVoice();
+ void refreshHealth();
+
+ setInterval(refreshHealth, 5000);
+ setInterval(() => {
+ if (!$("#settings-overlay").hidden) {
+ if (!$("#auth-qr").hidden || $("#auth-captcha").hidden === false) void refreshAuth();
+ }
+ }, 3000);
+}
+
+init();
diff --git a/poc/public/icons.js b/poc/public/icons.js
new file mode 100644
index 0000000..bb4709a
--- /dev/null
+++ b/poc/public/icons.js
@@ -0,0 +1,44 @@
+"use strict";
+
+window.VK = window.VK ?? {};
+
+const ICON_PATHS = {
+ hash: '',
+ volume: '',
+ mic: '',
+ micSlash:
+ '',
+ speaker: '',
+ speakerSlash:
+ '',
+ monitor: '',
+ camera: '',
+ caretDown: '',
+ caretUp: '',
+ x: '',
+ lock: '',
+ search: '',
+ gear: '',
+ wave: '',
+ warning: '',
+ refresh: '',
+ play: '',
+ microphone: ''
+};
+
+const FILLED = new Set(["star"]);
+
+ICON_PATHS.star = '';
+
+window.VK.icon = function icon(name, size = 13) {
+ const path = ICON_PATHS[name] ?? "";
+ const fill = FILLED.has(name) ? "currentColor" : "none";
+ return ``;
+};
+
+window.VK.iconEl = function iconEl(name, size = 13) {
+ const span = document.createElement("span");
+ span.className = "vk-icon";
+ span.innerHTML = window.VK.icon(name, size);
+ return span;
+};
diff --git a/poc/public/index.html b/poc/public/index.html
new file mode 100644
index 0000000..ce973c9
--- /dev/null
+++ b/poc/public/index.html
@@ -0,0 +1,262 @@
+
+
+
+
+
+ veskslop debug
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Text to speech
+ not implemented
+
+
+
+ Voice settings
+ sv-SE · Mattias
+ rate 1.0×
+ yield to call
+ duck 30%
+ queue lines
+
+
+
+
+ voice audio is not implemented yet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
waiting for events
+
+ 0 events
+ bridge …
+
+
+
+
+
+
+
+
roadmap
+
Voice transcription (STT/TTS) is not implemented yet. Transcript segments will appear here once voice audio lands.
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+ Core process
+
+
+
+
+
+
+
+
+
+
+
+ Account login
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Visible guilds
+
+
+
+
+
+
+
+ Action log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/poc/public/nocturne.css b/poc/public/nocturne.css
new file mode 100644
index 0000000..4be249a
--- /dev/null
+++ b/poc/public/nocturne.css
@@ -0,0 +1,294 @@
+/* Nocturne — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+:root {
+ --color-bg: #161826;
+ --color-surface: #232532;
+ --color-text: #e9e9ed;
+ --color-accent: #9184d9; /* review round 7 (Barron): the accent moves to
+ the product's blurple — OKLCH hue 289.2, the hue of the app's own Pro
+ accent (#9a8fe0, --om-accent-pro) — at the same lightness the steel
+ accent held (L 0.660, so the documented ratios survive) and chroma
+ in the product blurple's own range (its token sits at C 0.118; the
+ accent takes 0.125, from the old steel's 0.030):
+ the accent now reads as an accent rather than another neutral. */
+ --color-accent-2: #a7a1db; /* the machine-derived stand-in follows: same
+ hue, same L 0.734, chroma at the same 2:3 proportion it held */
+ --color-divider: color-mix(in srgb, #e9e9ed 16%, transparent);
+
+ /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
+ same step of any role matches the others in visual value. */
+ --color-neutral-100: #f3f5fe;
+ --color-neutral-200: #e4e7f5;
+ --color-neutral-300: #cfd3e5;
+ --color-neutral-400: #b2b6ca;
+ --color-neutral-500: #9397ab;
+ --color-neutral-600: #75798c;
+ --color-neutral-700: #595d6c;
+ --color-neutral-800: #3f424d;
+ --color-neutral-900: #292b31;
+
+ --color-accent-100: #f5f4ff;
+ --color-accent-200: #e7e5fe;
+ --color-accent-300: #d2cefd;
+ --color-accent-400: #b5abfc;
+ --color-accent-500: #968ae0;
+ --color-accent-600: #796cbf;
+ --color-accent-700: #5d5294;
+ --color-accent-800: #423a6a;
+ --color-accent-900: #2b2741;
+
+ --color-accent-2-100: #f5f4ff;
+ --color-accent-2-200: #e7e5fe;
+ --color-accent-2-300: #d2cefd;
+ --color-accent-2-400: #b5afe8;
+ --color-accent-2-500: #9690c9;
+ --color-accent-2-600: #7972a9;
+ --color-accent-2-700: #5c5783;
+ --color-accent-2-800: #423e5d;
+ --color-accent-2-900: #2b293a;
+
+ /* Deck section-divider ground — review round 3: saturation as presence.
+ Dividers take a saturated deep indigo (hue 277, the neutral ramp's own
+ family; C 0.094 — written when the accent held C 0.030, before the
+ round-7 blurple raised it past the ground's) with a lighter bloom and
+ a ghost tint of the same hue, while the slide grounds stay
+ desaturated. Deck-scale fills only — not interface colors. */
+ --color-section: #262a60;
+ --color-section-glow: #353b80;
+ --color-section-ghost: #4c5397;
+
+ --font-heading: "Inter", system-ui, sans-serif;
+ --font-heading-weight: 500;
+ --font-body: "Inter", system-ui, sans-serif;
+
+ --space-1: 2.8px;
+ --space-2: 5.6px;
+ --space-3: 8.4px;
+ --space-4: 11.2px;
+ --space-6: 16.8px;
+ --space-8: 22.4px;
+
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 14px;
+
+ /* Elevation — derived from the ground: soft ink-tinted shadows on a
+ light theme, a hairline edge + ambient darkness on a dark one. */
+ --shadow-sm: 0 0 0 1px #3f424d;
+ --shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0,0,0,0.55);
+ --shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0,0,0,0.65);
+}
+
+body {
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+}
+h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
+
+.lighten{mix-blend-mode:lighten;background-color:transparent}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ Components — built with the tokens above. Plain CSS
+ on plain HTML: no JavaScript, no build step. Each class is documented in
+ readme.md and demonstrated in foundations/ and components/.
+ ══════════════════════════════════════════════════════════════════════ */
+
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
+h1, h2, h3, h4, h5, h6 {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
+}
+h1 { font-size: 42px; }
+h2 { font-size: 32px; }
+h3 { font-size: 25px; }
+h4 { font-size: 20px; }
+h5 { font-size: 16px; }
+h6 { font-size: 13px; }
+h6 { letter-spacing: 0.08em; text-transform: uppercase; }
+p { margin: 0 0 var(--space-3); }
+a { color: var(--color-accent); text-underline-offset: 3px; }
+img { display: block; max-width: 100%; }
+figure { margin: 0; }
+figcaption {
+ font-size: 11px; margin-top: var(--space-1);
+ color: color-mix(in srgb, var(--color-text) 55%, transparent);
+}
+.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
+:focus { outline: none; }
+:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
+
+/* — rules — */
+/* Rules fade to transparent at both ends instead of stopping cleanly — a
+ Nocturne signature. The ramp is 48px an end (one deck baseline unit),
+ painted from the same divider token. Freestanding rules fade; box
+ outlines, in-control separators, and short accent marks stay solid. */
+.hr {
+ height: 1px; border: 0; margin: var(--space-4) 0;
+ background: linear-gradient(to right,
+ transparent, var(--color-divider) 48px,
+ var(--color-divider) calc(100% - 48px), transparent);
+}
+
+/* — buttons — */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
+ cursor: pointer; text-decoration: none;
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
+ the pair sits side by side in sign-up rows */
+ background: transparent; border: 1px solid transparent;
+ padding: var(--space-2) calc(var(--space-3) * 1.2);
+ border-radius: var(--radius-md);
+}
+.btn svg { display: block; }
+.btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.btn-primary { color: var(--color-accent); border-color: var(--color-accent); }
+.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
+.btn-primary:active { background: color-mix(in srgb, var(--color-accent) 22%, transparent); }
+.btn-secondary { border-color: var(--color-divider); }
+.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
+.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
+.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
+.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
+.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
+.btn-icon { width: 36px; height: 36px; padding: 0; }
+.btn-block { width: 100%; margin-top: var(--space-2); }
+
+/* — forms — */
+.field > label {
+ display: block; font-size: 12px; margin-bottom: 5px;
+ color: color-mix(in srgb, var(--color-text) 70%, transparent);
+}
+.input {
+ width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
+ font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
+ background: var(--color-surface);
+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);
+}
+.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
+.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
+textarea.input { min-height: 90px; resize: vertical; }
+.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
+.radio input, .seg-opt input {
+ position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
+}
+.radio .dot {
+ width: 16px; height: 16px; flex: none; border-radius: 50%;
+ border: 1.5px solid var(--color-divider);
+}
+.radio:hover .dot { border-color: var(--color-accent); }
+.radio input:checked + .dot {
+ border-color: var(--color-accent); background: var(--color-accent);
+ box-shadow: inset 0 0 0 4px var(--color-bg);
+}
+.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
+.seg {
+ display: inline-flex; overflow: hidden;
+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);
+}
+.seg-opt {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 7px 12px; font-size: 13px; cursor: pointer;
+}
+.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
+.seg-opt:has(input:checked) { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
+.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
+.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
+
+/* — cards — */
+.card {
+ display: flex; flex-direction: column; gap: var(--space-2);
+ padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
+}
+.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
+.card-title {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 17px; line-height: 1.2;
+}
+.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
+.card-meta {
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
+ color: color-mix(in srgb, var(--color-text) 50%, transparent);
+}
+.elev-sm { box-shadow: var(--shadow-sm); }
+.elev-md { box-shadow: var(--shadow-md); }
+.elev-lg { box-shadow: var(--shadow-lg); }
+
+/* — tags — */
+.tag {
+ display: inline-flex; align-items: center; font-size: 11px;
+ letter-spacing: 0.02em; padding: 3px 10px;
+ border-radius: calc(var(--radius-md) * 0.75);
+}
+.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); }
+.tag-accent-2 { background: var(--color-accent-2-800); color: var(--color-accent-2-100); }
+.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); }
+.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
+
+/* — navigation — */
+.nav {
+ display: flex; align-items: center; gap: var(--space-4);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: none;
+}
+.nav-brand {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 18px; margin-right: auto;
+}
+.nav a { color: inherit; text-decoration: none; font-size: 14px; }
+.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
+
+/* — tables — */
+.table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.table th {
+ text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
+ padding: var(--space-2); border-bottom: 1px solid transparent; /* keeps layout; the row paints the fading rule */
+}
+.table td {
+ padding: var(--space-2);
+ border-bottom: 1px solid transparent;
+}
+/* Row rules as row-level strips so the end-fade spans the row, not each cell. */
+.table thead tr {
+ background: linear-gradient(to right,
+ transparent, var(--color-divider) 48px,
+ var(--color-divider) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
+}
+.table tbody tr {
+ background: linear-gradient(to right,
+ transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px,
+ color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
+}
+.table tbody tr:hover {
+ /* the hover tint layers over the row rule, which keeps painting */
+ background:
+ linear-gradient(color-mix(in srgb, var(--color-text) 4%, transparent),
+ color-mix(in srgb, var(--color-text) 4%, transparent)) no-repeat 0 0 / 100% 100%,
+ linear-gradient(to right,
+ transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px,
+ color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
+}
+
+/* — dialog — */
+.dialog-backdrop {
+ position: fixed; inset: 0; display: grid; place-items: center;
+ padding: var(--space-4);
+ background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
+}
+.dialog {
+ width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
+ padding: var(--space-4); border-radius: var(--radius-lg);
+ background: var(--color-surface); box-shadow: var(--shadow-lg);
+}
+.dialog-title {
+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
+ font-size: 20px;
+}
+.dialog-body { font-size: 14px; opacity: 0.85; }
+.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
+
diff --git a/poc/public/style.css b/poc/public/style.css
new file mode 100644
index 0000000..62ecca1
--- /dev/null
+++ b/poc/public/style.css
@@ -0,0 +1,1449 @@
+/* Veskslop debug UI — application layer on top of the Nocturne tokens. */
+
+:root {
+ --vk-header-h: 50px;
+ --vk-strip-h: 46px;
+ --vk-console-h: 35px;
+ --vk-rail-w: 236px;
+ --vk-right-w: 306px;
+ --vk-drawer-h: 250px;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: 13.5px;
+ line-height: 1.5;
+ overflow: hidden;
+}
+
+button {
+ font: inherit;
+}
+
+.vk-app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ min-height: 0;
+}
+
+.vk-mono {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 10.5px;
+ line-height: 1.3;
+}
+
+.vk-dim {
+ color: var(--color-neutral-500);
+}
+
+.vk-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+}
+
+.vk-icon svg {
+ display: block;
+}
+
+.vk-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-neutral-600);
+}
+
+.vk-dot.online {
+ background: var(--color-accent-400);
+}
+
+.vk-dot.offline {
+ background: var(--color-neutral-600);
+}
+
+.vk-dot.error {
+ background: var(--color-neutral-200);
+}
+
+.vk-small-btn {
+ padding: 4px 9px;
+ font-size: 11.5px;
+ gap: 6px;
+}
+
+.vk-icon-btn {
+ width: 28px;
+ height: 26px;
+ padding: 0;
+}
+
+.vk-micro-label {
+ flex: none;
+ font: 500 9.5px/1 var(--font-heading);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--color-neutral-400);
+ padding-right: 2px;
+}
+
+/* Header */
+
+.vk-header {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ height: var(--vk-header-h);
+ flex: none;
+ padding: 0 14px;
+ background: linear-gradient(to bottom, var(--color-surface), var(--color-bg));
+}
+
+.vk-brand {
+ display: flex;
+ align-items: baseline;
+ gap: 7px;
+}
+
+.vk-brand-name {
+ font: 500 15px/1 var(--font-heading);
+ letter-spacing: -0.01em;
+}
+
+.vk-brand-version {
+ font: 400 10px/1 ui-monospace, Menlo, monospace;
+ color: var(--color-neutral-400);
+}
+
+.vk-guild-picker {
+ position: relative;
+}
+
+.vk-guild-button {
+ gap: 8px;
+ padding: 5px 10px;
+ font-size: 13px;
+}
+
+.vk-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ min-width: 220px;
+ max-height: 60vh;
+ overflow-y: auto;
+ z-index: 40;
+ padding: 4px;
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+}
+
+.vk-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.vk-menu-item:hover {
+ background: var(--color-neutral-900);
+}
+
+.vk-menu-item.active {
+ color: var(--color-accent-300);
+ box-shadow: inset 2px 0 0 var(--color-accent);
+}
+
+.vk-health {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding-left: 4px;
+}
+
+.vk-health-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.vk-header-right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.vk-takeover {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ padding: 4px 10px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-divider);
+}
+
+.vk-takeover input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.vk-switch {
+ width: 26px;
+ height: 14px;
+ flex: none;
+ border-radius: 7px;
+ background: var(--color-neutral-800);
+ box-shadow: inset 0 0 0 1px var(--color-neutral-700);
+ position: relative;
+ transition: background 0.15s;
+}
+
+.vk-knob {
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--color-neutral-500);
+ transition: transform 0.15s, background 0.15s;
+}
+
+.vk-takeover input:checked + .vk-switch {
+ background: color-mix(in srgb, var(--color-accent) 30%, transparent);
+ box-shadow: inset 0 0 0 1px var(--color-accent);
+}
+
+.vk-takeover input:checked + .vk-switch .vk-knob {
+ transform: translateX(12px);
+ background: var(--color-accent-200);
+}
+
+.vk-takeover input:focus-visible + .vk-switch {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.vk-takeover-label {
+ font: 500 11px/1 var(--font-heading);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-neutral-400);
+}
+
+.vk-takeover input:checked ~ .vk-takeover-label {
+ color: var(--color-accent);
+}
+
+.vk-settings {
+ gap: 7px;
+ padding: 5px 10px;
+ font-size: 12.5px;
+}
+
+/* Call strip */
+
+.vk-call-strip {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 9px 14px;
+ min-height: var(--vk-strip-h);
+ background: linear-gradient(to right, var(--color-accent-900), var(--color-surface) 70%);
+ box-shadow: inset 0 -1px 0 var(--color-accent-800);
+}
+
+.vk-call-strip .vk-call-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: none;
+}
+
+.vk-call-live {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 22%, transparent);
+}
+
+.vk-call-name {
+ font: 500 13px/1.2 var(--font-heading);
+}
+
+.vk-call-strip .vk-call-pills {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.vk-strip-pill {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: none;
+ padding: 3px 9px 3px 4px;
+ border-radius: 14px;
+ background: var(--color-neutral-900);
+}
+
+.vk-strip-pill.speaking {
+ box-shadow: inset 0 0 0 1px var(--color-accent);
+}
+
+.vk-strip-pill .vk-strip-name {
+ font: 500 11.5px/1 var(--font-heading);
+}
+
+.vk-call-strip .vk-call-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: none;
+}
+
+/* Body grid */
+
+.vk-body {
+ flex: 1;
+ display: grid;
+ grid-template-columns: var(--vk-rail-w) minmax(0, 1fr) var(--vk-right-w);
+ min-height: 0;
+}
+
+/* Left rail */
+
+.vk-rail {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ padding: 10px 8px;
+ gap: 14px;
+ background: var(--color-surface);
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.vk-guild-switcher {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.vk-guild-mark {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ flex: none;
+ overflow: hidden;
+ border-radius: var(--radius-md);
+ background: var(--color-neutral-900);
+ font: 500 12px/1 var(--font-heading);
+ color: var(--color-neutral-500);
+ cursor: pointer;
+ border: none;
+ padding: 0;
+}
+
+.vk-guild-mark img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.vk-guild-mark:hover {
+ color: var(--color-text);
+}
+
+.vk-guild-mark.active {
+ background: var(--color-accent-900);
+ box-shadow: inset 0 0 0 1px var(--color-accent-700);
+ color: var(--color-accent-300);
+}
+
+.vk-rail-section {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.vk-rail-label {
+ font: 500 10px/1 var(--font-heading);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--color-neutral-400);
+ padding: 0 8px 6px;
+}
+
+.vk-rail-label-flat {
+ padding: 0;
+}
+
+.vk-rail-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.vk-rail-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 5px 8px;
+ border-radius: var(--radius-sm);
+ color: var(--color-neutral-400);
+ cursor: pointer;
+ border: none;
+ background: none;
+ text-align: left;
+ width: 100%;
+ font-size: 13px;
+}
+
+.vk-rail-row:hover {
+ color: var(--color-text);
+ background: color-mix(in srgb, var(--color-text) 4%, transparent);
+}
+
+.vk-rail-row.active {
+ background: var(--color-neutral-900);
+ box-shadow: inset 2px 0 0 var(--color-accent);
+ color: var(--color-text);
+ font-weight: 500;
+}
+
+.vk-rail-row .vk-rail-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.vk-rail-trail {
+ margin-left: auto;
+ font: 400 10px/1 ui-monospace, Menlo, monospace;
+ color: var(--color-neutral-400);
+ flex: none;
+}
+
+.vk-rail-badge {
+ display: grid;
+ place-items: center;
+ min-width: 17px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: var(--color-accent-800);
+ font: 500 10px/1 ui-monospace, Menlo, monospace;
+ color: var(--color-accent-100);
+}
+
+.vk-rail-voice {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 5px 8px 7px;
+ border-radius: var(--radius-sm);
+ background: var(--color-neutral-900);
+}
+
+.vk-rail-voice-head {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.vk-rail-voice-members {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding-left: 20px;
+}
+
+.vk-voice-member {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--color-neutral-400);
+}
+
+.vk-voice-member .vk-marks {
+ margin-left: auto;
+}
+
+.vk-rail-footer {
+ margin-top: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 8px;
+ border-radius: var(--radius-md);
+ background: var(--color-neutral-900);
+ flex: none;
+}
+
+.vk-agent-name {
+ font: 500 12px/1.2 var(--font-heading);
+}
+
+.vk-agent-sub {
+ font: 400 9.5px/1.3 ui-monospace, Menlo, monospace;
+ color: var(--color-neutral-500);
+}
+
+/* Monogram avatars */
+
+.vk-avatar {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ flex: none;
+ overflow: hidden;
+ border-radius: 50%;
+ background: var(--color-neutral-900);
+ box-shadow: inset 0 0 0 1px var(--color-neutral-800);
+ font: 500 11px/1 var(--font-heading);
+ color: var(--color-neutral-400);
+}
+
+.vk-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.vk-avatar.agent {
+ border-radius: var(--radius-sm);
+ background: var(--color-accent-900);
+ box-shadow: inset 0 0 0 1px var(--color-accent-700);
+ color: var(--color-accent-300);
+}
+
+.vk-avatar.sm {
+ width: 22px;
+ height: 22px;
+ font-size: 9px;
+}
+
+.vk-avatar.lg {
+ width: 34px;
+ height: 34px;
+ font-size: 14px;
+}
+
+.vk-avatar-wrap {
+ position: relative;
+ flex: none;
+ display: inline-flex;
+}
+
+.vk-presence {
+ position: absolute;
+ right: -1px;
+ bottom: -1px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color-accent-400);
+ box-shadow: 0 0 0 2px var(--color-surface);
+}
+
+.vk-presence.idle {
+ background: var(--color-neutral-200);
+}
+
+.vk-presence.offline {
+ background: var(--color-surface);
+ box-shadow: 0 0 0 1.5px var(--color-neutral-600), 0 0 0 3px var(--color-surface);
+}
+
+/* Center */
+
+.vk-center {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+}
+
+.vk-channel-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: none;
+ height: 44px;
+ padding: 0 18px;
+ background: linear-gradient(
+ to right,
+ transparent,
+ var(--color-divider) 48px,
+ var(--color-divider) calc(100% - 48px),
+ transparent
+ )
+ no-repeat bottom / 100% 1px;
+}
+
+.vk-channel-title {
+ font: 500 15px/1.2 var(--font-heading);
+}
+
+.vk-channel-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.vk-message-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 16px 18px 10px;
+}
+
+.vk-message-list {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.vk-message {
+ display: flex;
+ gap: 11px;
+}
+
+.vk-message-body-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.vk-message-head {
+ display: flex;
+ align-items: baseline;
+ gap: 7px;
+ flex-wrap: wrap;
+}
+
+.vk-message-author {
+ font: 500 13.5px/1.2 var(--font-heading);
+}
+
+.vk-message-body {
+ font: 400 13.5px/1.55 var(--font-body);
+ color: var(--color-neutral-200);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.vk-message-image {
+ display: block;
+ margin-top: 6px;
+ max-width: min(420px, 100%);
+ max-height: 280px;
+ border-radius: var(--radius-sm);
+ box-shadow: inset 0 0 0 1px var(--color-neutral-800);
+ object-fit: contain;
+}
+
+.vk-attachment {
+ display: inline-flex;
+ align-self: flex-start;
+ margin-top: 6px;
+ padding: 3px 8px;
+ border-radius: var(--radius-sm);
+ background: var(--color-neutral-900);
+ box-shadow: inset 0 0 0 1px var(--color-neutral-800);
+ color: var(--color-neutral-300);
+ font: 400 10.5px/1.4 ui-monospace, Menlo, monospace;
+ text-decoration: none;
+}
+
+a.vk-attachment:hover {
+ color: var(--color-accent-300);
+}
+
+.vk-readonly-tag {
+ flex: none;
+ font-size: 9.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.vk-visibility-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 5px 2px;
+ font-size: 12.5px;
+ cursor: pointer;
+}
+
+.vk-visibility-row input {
+ accent-color: var(--color-accent);
+}
+
+.vk-guild-icon-sm {
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ flex: none;
+ overflow: hidden;
+ border-radius: var(--radius-sm);
+ background: var(--color-neutral-900);
+ font: 500 9px/1 var(--font-heading);
+ color: var(--color-neutral-400);
+}
+
+.vk-guild-icon-sm img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.vk-tag-agent {
+ padding: 1px 6px;
+ font-size: 9.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.vk-empty {
+ color: var(--color-neutral-500);
+ font-size: 12.5px;
+ padding: 8px 0;
+}
+
+/* Composer */
+
+.vk-composer-wrap {
+ flex: none;
+ padding: 0 18px 14px;
+}
+
+.vk-composer {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 9px 12px;
+ border-radius: var(--radius-md);
+ background: var(--color-surface);
+ box-shadow: inset 0 0 0 1px var(--color-neutral-800);
+}
+
+.vk-composer.unlocked {
+ align-items: flex-end;
+}
+
+.vk-composer-copy {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ color: var(--color-neutral-400);
+}
+
+.vk-composer-input {
+ flex: 1;
+ min-height: 38px;
+ max-height: 160px;
+ resize: none;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg);
+ color: var(--color-text);
+ border: 1px solid var(--color-divider);
+ font: inherit;
+}
+
+.vk-composer-input:focus-visible {
+ border-color: var(--color-accent);
+ outline: none;
+}
+
+.vk-tts-toggle {
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+ flex: none;
+}
+
+/* TTS panel */
+
+.vk-tts {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 9px;
+ border-radius: var(--radius-md);
+ background: var(--color-accent-900);
+ box-shadow: inset 0 0 0 1px var(--color-accent-600), var(--shadow-md);
+ overflow: hidden;
+}
+
+.vk-tts-title {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 11px;
+ background: var(--color-accent-800);
+ color: var(--color-accent-200);
+}
+
+.vk-tts-heading {
+ font: 500 10.5px/1 var(--font-heading);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+
+.vk-tts-sub {
+ color: var(--color-accent-200);
+}
+
+.vk-tts-title .vk-icon-btn {
+ margin-left: auto;
+ color: var(--color-accent-300);
+ flex: none;
+}
+
+.vk-tts-settings {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ padding: 9px 11px 8px;
+}
+
+.vk-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: none;
+ padding: 4px 9px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg);
+ box-shadow: inset 0 0 0 1px var(--color-accent-800);
+ color: var(--color-neutral-300);
+ font: 400 11px/1 ui-monospace, Menlo, monospace;
+}
+
+.vk-chip-on {
+ background: var(--color-accent-700);
+ box-shadow: inset 0 0 0 1px var(--color-accent-500);
+ color: var(--color-accent-100);
+}
+
+.vk-tts-body {
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+ margin: 9px;
+ padding: 11px 12px;
+ border-radius: var(--radius-sm);
+ background: var(--color-bg);
+}
+
+.vk-tts-field {
+ width: 100%;
+ max-height: 150px;
+ resize: none;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: var(--color-neutral-100);
+ font: 400 13.5px/1.6 var(--font-body);
+}
+
+.vk-tts-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.vk-tts-meta .vk-small-btn {
+ margin-left: auto;
+}
+
+/* Right column */
+
+.vk-right {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--color-surface);
+ box-shadow: inset 1px 0 0 var(--color-neutral-800);
+ padding: 12px 10px;
+}
+
+.vk-right-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.vk-right-section {
+ display: flex;
+ flex-direction: column;
+}
+
+.vk-section-head {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 0 6px 8px;
+}
+
+.vk-section-head .vk-dim,
+.vk-section-head .vk-mono {
+ margin-left: auto;
+}
+
+.vk-right-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.vk-right-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 5px 7px;
+ border-radius: var(--radius-sm);
+ min-width: 0;
+}
+
+.vk-right-row.highlight {
+ background: var(--color-neutral-900);
+}
+
+.vk-right-row.focused {
+ background: var(--color-neutral-900);
+ box-shadow: inset 0 0 0 1px var(--color-accent-700);
+}
+
+.vk-right-name {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.vk-right-name .vk-name {
+ font: 500 12.5px/1.2 var(--font-heading);
+ color: var(--color-neutral-300);
+}
+
+.vk-right-name .vk-sub {
+ font: 400 10px/1.3 ui-monospace, Menlo, monospace;
+ color: var(--color-neutral-400);
+}
+
+.vk-right-trail {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: none;
+}
+
+.vk-right-footer {
+ flex: none;
+ padding: 10px 6px 0;
+}
+
+.vk-roadmap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 2px;
+}
+
+.vk-roadmap p {
+ margin: 0;
+ font-size: 12.5px;
+ color: var(--color-neutral-500);
+}
+
+/* Voice state marks */
+
+.vk-marks {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.vk-marks .accent {
+ color: var(--color-accent-300);
+}
+
+.vk-marks .neutral {
+ color: var(--color-neutral-500);
+}
+
+.vk-levels {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: 11px;
+}
+
+.vk-levels span {
+ width: 3px;
+ height: 100%;
+ border-radius: 1px;
+ background: var(--color-accent);
+ transform-origin: bottom;
+ animation: vk-lvl 0.7s ease-in-out infinite;
+}
+
+.vk-levels span:nth-child(2) {
+ animation-delay: 0.1s;
+}
+.vk-levels span:nth-child(3) {
+ animation-delay: 0.2s;
+}
+.vk-levels span:nth-child(4) {
+ animation-delay: 0.3s;
+}
+
+@keyframes vk-lvl {
+ 0%,
+ 100% {
+ transform: scaleY(0.25);
+ }
+ 50% {
+ transform: scaleY(1);
+ }
+}
+
+@keyframes vk-pulse {
+ 0%,
+ 100% {
+ opacity: 0.35;
+ }
+ 50% {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .vk-levels span {
+ animation: none;
+ transform: scaleY(0.6);
+ }
+ .vk-pulse {
+ animation: none;
+ }
+}
+
+/* Agent console */
+
+.vk-console {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ background: var(--color-surface);
+}
+
+.vk-console-bar {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ height: var(--vk-console-h);
+ flex: none;
+ padding: 8px 14px;
+ box-shadow: inset 0 1px 0 var(--color-neutral-800);
+}
+
+.vk-console-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ color: var(--color-text);
+ font: 500 11px/1 var(--font-heading);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: pointer;
+ padding: 0;
+}
+
+.vk-console-tabs {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.vk-console-tab {
+ position: relative;
+ background: none;
+ border: none;
+ color: var(--color-neutral-400);
+ font-size: 12px;
+ cursor: pointer;
+ padding: 2px 0;
+}
+
+.vk-console-tab:hover {
+ color: var(--color-text);
+}
+
+.vk-console-tab.active {
+ color: var(--color-accent-300);
+}
+
+.vk-console-tab .vk-live-dot {
+ position: absolute;
+ top: -1px;
+ right: -8px;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--color-accent);
+ animation: vk-pulse 1.8s ease-in-out infinite;
+}
+
+.vk-ticker {
+ flex: 1;
+ min-width: 0;
+ height: 18px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 30px), transparent);
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 30px), transparent);
+}
+
+.vk-ticker span {
+ font: 400 11px/1.2 ui-monospace, Menlo, monospace;
+ color: var(--color-neutral-400);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.vk-console-status {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex: none;
+ color: var(--color-neutral-500);
+}
+
+.vk-console-drawer {
+ height: var(--vk-drawer-h);
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ box-shadow: inset 0 1px 0 var(--color-neutral-800);
+ position: relative;
+}
+
+.vk-console-resize {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 6px;
+ cursor: ns-resize;
+}
+
+.vk-console-panel {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 12px 14px;
+}
+
+.vk-console-panel[hidden] {
+ display: none;
+}
+
+.vk-event-log {
+ display: flex;
+ flex-direction: column;
+ font-family: ui-monospace, Menlo, monospace;
+ font-size: 11px;
+}
+
+.vk-event-line {
+ display: flex;
+ gap: 8px;
+ padding: 2px 0;
+ color: var(--color-neutral-400);
+ word-break: break-all;
+}
+
+.vk-event-line .vk-event-time {
+ flex: none;
+ color: var(--color-neutral-500);
+}
+
+.vk-event-line .vk-event-type {
+ flex: none;
+ color: var(--color-accent-300);
+}
+
+/* Settings overlay */
+
+.vk-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ display: flex;
+ justify-content: flex-end;
+ background: color-mix(in srgb, var(--color-neutral-900) 55%, transparent);
+}
+
+.vk-overlay[hidden] {
+ display: none;
+}
+
+.vk-settings-panel {
+ width: min(680px, 100%);
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--color-surface);
+ box-shadow: var(--shadow-lg);
+}
+
+.vk-settings-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: none;
+ padding: 12px 16px;
+ box-shadow: inset 0 -1px 0 var(--color-neutral-800);
+}
+
+.vk-settings-title {
+ font: 500 15px/1.2 var(--font-heading);
+}
+
+.vk-settings-head .vk-icon-btn {
+ margin-left: auto;
+}
+
+.vk-settings-body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+ padding: 16px;
+}
+
+.vk-settings-section {
+ display: flex;
+ flex-direction: column;
+}
+
+.vk-settings-actions {
+ margin-left: auto;
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.vk-settings-card {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 10px;
+ border-radius: var(--radius-md);
+ background: var(--color-bg);
+}
+
+.vk-settings-row {
+ display: flex;
+ gap: 8px;
+ padding: 8px 0;
+}
+
+.vk-settings-row .input {
+ flex: 1;
+}
+
+.vk-status-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 5px 0;
+ font-size: 12.5px;
+ box-shadow: inset 0 -1px 0 var(--color-neutral-900);
+}
+
+.vk-status-row:last-child {
+ box-shadow: none;
+}
+
+.vk-status-row .vk-status-label {
+ color: var(--color-neutral-500);
+ flex: none;
+}
+
+.vk-status-row .vk-status-value {
+ min-width: 0;
+ text-align: right;
+ word-break: break-all;
+}
+
+.vk-qr {
+ margin: 10px 0;
+ padding: 12px;
+ width: fit-content;
+ background: #ffffff;
+ border-radius: var(--radius-md);
+}
+
+.vk-qr img {
+ display: block;
+ width: 240px;
+ height: 240px;
+}
+
+.vk-core-output {
+ margin: 8px 0 0;
+ padding: 8px 10px;
+ max-height: 180px;
+ overflow-y: auto;
+ background: var(--color-bg);
+ border-radius: var(--radius-sm);
+ font: 400 10.5px/1.5 ui-monospace, Menlo, monospace;
+ color: var(--color-neutral-500);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.vk-log {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 10px;
+ border-radius: var(--radius-md);
+ background: var(--color-bg);
+ font: 400 11px/1.5 ui-monospace, Menlo, monospace;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.vk-log-line {
+ display: flex;
+ gap: 8px;
+ color: var(--color-neutral-400);
+ word-break: break-all;
+}
+
+.vk-log-line .vk-log-time {
+ flex: none;
+ color: var(--color-neutral-500);
+}
+
+.vk-log-line .vk-log-type {
+ flex: none;
+ color: var(--color-accent-300);
+}
+
+/* Toasts */
+
+.vk-toasts {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.vk-toast {
+ max-width: 380px;
+ padding: 10px 14px;
+ border-radius: var(--radius-md);
+ background: var(--color-surface);
+ box-shadow: var(--shadow-md);
+ font-size: 12.5px;
+}
+
+.vk-toast.error {
+ box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-neutral-200);
+}
+
+.vk-toast.ok {
+ box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-accent-400);
+}
+
+/* Responsive */
+
+@media (max-width: 1280px) {
+ :root {
+ --vk-right-w: 280px;
+ }
+}
+
+@media (max-width: 1024px) {
+ .vk-right {
+ display: none;
+ }
+
+ .vk-body {
+ grid-template-columns: var(--vk-rail-w) minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .vk-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .vk-rail {
+ display: none;
+ }
+
+ .vk-health {
+ display: none;
+ }
+}
diff --git a/poc/src/auth-service.ts b/poc/src/auth-service.ts
new file mode 100644
index 0000000..1ba82bf
--- /dev/null
+++ b/poc/src/auth-service.ts
@@ -0,0 +1,81 @@
+/*
+ * Veskslop daemon — auth state for the web UI.
+ *
+ * Owns at most one QR login session. The terminal tool and the web UI share
+ * the session class. The token file stays the single credential store.
+ */
+
+import { existsSync } from "node:fs";
+
+import { isTerminalQrState, QrLoginSession, type QrLoginState, type QrLoginUpdate } from "./qr-session.js";
+
+export interface AuthSessionSnapshot {
+ state: QrLoginState;
+ url?: string;
+ user?: object;
+ error?: string;
+ captcha?: object;
+}
+
+export interface AuthStatus {
+ tokenFile: string;
+ hasToken: boolean;
+ session: AuthSessionSnapshot | null;
+}
+
+export class AuthService {
+ private session?: QrLoginSession;
+ private snapshot: AuthSessionSnapshot | null = null;
+ private freshLogin = false;
+
+ constructor(
+ private readonly tokenFile: string,
+ private readonly onUpdate?: (update: QrLoginUpdate) => void
+ ) {}
+
+ status(): AuthStatus {
+ return {
+ tokenFile: this.tokenFile,
+ hasToken: existsSync(this.tokenFile),
+ session: this.snapshot ? structuredClone(this.snapshot) : null
+ };
+ }
+
+ startQr(): AuthStatus {
+ if (this.session && !isTerminalQrState(this.session.state)) return this.status();
+
+ this.snapshot = { state: "connecting" };
+ this.session = new QrLoginSession({
+ tokenFile: this.tokenFile,
+ debug: process.env.DEBUG === "1",
+ onUpdate: update => {
+ this.snapshot = { ...update };
+ if (update.state === "finished") this.freshLogin = true;
+ this.onUpdate?.(update);
+ }
+ });
+ this.session.start();
+
+ return this.status();
+ }
+
+ cancelQr(): AuthStatus {
+ this.session?.cancel();
+ return this.status();
+ }
+
+ submitCaptcha(key: string): boolean {
+ return this.session?.submitCaptcha(key) ?? false;
+ }
+
+ /** True once after a successful login. Used to force the new token on start. */
+ consumeFreshLogin(): boolean {
+ const value = this.freshLogin;
+ this.freshLogin = false;
+ return value;
+ }
+
+ clearFreshLogin(): void {
+ this.freshLogin = false;
+ }
+}
diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts
index 065ab33..c4b1168 100644
--- a/poc/src/control-bridge.ts
+++ b/poc/src/control-bridge.ts
@@ -14,9 +14,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4";
+import { AuthService } from "./auth-service.js";
+import { tokenFile } from "./config.js";
import { CoreClient, coreSocketPath } from "./core-client.js";
-import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
+import { CoreProcess } from "./core-process.js";
+import { dedupe, getQueryCount, paced, pacedSend, sleep } from "./pacing.js";
import { Puppet } from "./puppet.js";
+import { startWebUi } from "./webui.js";
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8788);
@@ -24,27 +28,46 @@ const PORT = Number(process.env.MCP_PORT ?? 8788);
let core: CoreClient | undefined;
let puppet: Puppet | undefined;
let coreConnected = false;
+let connecting = false;
-async function connectLoop() {
- for (;;) {
- try {
- const client = await CoreClient.connect();
+async function connectLoop(): Promise {
+ if (connecting) return;
+ connecting = true;
- core = client;
- puppet = new Puppet(client);
+ try {
+ for (;;) {
+ try {
+ const client = await CoreClient.connect();
- client.onEvent(event => {
- void puppet!.enqueueEvent(event);
- });
+ core = client;
+ puppet = new Puppet(client);
- coreConnected = true;
- console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
+ client.onEvent(event => {
+ void puppet!.enqueueEvent(event);
+ webui.broadcast(event);
+ });
- return;
- } catch (error) {
- console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
- await sleep(2000);
+ client.onClose(() => {
+ console.warn("[daemon] core bridge disconnected");
+ coreConnected = false;
+ core = undefined;
+ puppet = undefined;
+ webui.broadcast({ type: "CORE_STATUS", coreConnected: false });
+ void connectLoop();
+ });
+
+ coreConnected = true;
+ console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
+ webui.broadcast({ type: "CORE_STATUS", coreConnected: true });
+
+ return;
+ } catch (error) {
+ console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
+ await sleep(2000);
+ }
}
+ } finally {
+ connecting = false;
}
}
@@ -58,6 +81,32 @@ function requirePuppet(): Puppet {
return puppet;
}
+const coreProcess = new CoreProcess({ tokenFile });
+
+const auth = new AuthService(tokenFile, update => {
+ webui.broadcast({ type: "AUTH_QR", update });
+
+ if (update.state === "finished" && coreProcess.status().running) {
+ setTimeout(() => {
+ void coreProcess.restart(auth.consumeFreshLogin()).catch(error => {
+ console.error("[core] restart after login failed:", error);
+ });
+ }, 500);
+ }
+});
+
+const webui = startWebUi({
+ getCore: () => core,
+ getPuppet: () => puppet,
+ isCoreConnected: () => coreConnected,
+ coreProcess,
+ auth
+});
+
+coreProcess.onExit(() => {
+ webui.broadcast({ type: "CORE_STATUS", coreConnected, process: coreProcess.status() });
+});
+
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
@@ -66,6 +115,9 @@ function jsonText(value: unknown) {
const READ_ONLY_TOOLS: Record Promise | unknown> = {
get_state: () => requireCore().getState(),
get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId),
+ get_voice_overview: args => requireCore().voiceOverview(args?.guildId),
+ search_users: args => requireCore().userSearch(args?.query, args?.guildId, args?.limit),
+ list_dms: args => requireCore().dmList(args?.limit),
get_context: args =>
requirePuppet().getContext(args?.channelId, {
maxMessages: args?.maxMessages,
@@ -100,7 +152,7 @@ function buildServer(): McpServer {
}
},
async ({ channelId, content }) => {
- await paced(() => requireCore().sendMessage(channelId, content));
+ await pacedSend(() => requireCore().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
}
);
@@ -115,7 +167,7 @@ function buildServer(): McpServer {
}
},
async ({ userId, content }) => {
- const result = await paced(() => requireCore().sendDm(userId, content));
+ const result = await pacedSend(() => requireCore().sendDm(userId, content));
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
}
);
@@ -199,12 +251,47 @@ function buildServer(): McpServer {
description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.",
inputSchema: {
userId: z.string().optional().describe("User ID. Defaults to the logged-in user."),
- guildId: z.string().optional().describe("Guild ID. Omit for a DM call.")
+ guildId: z.string().optional().describe("Guild ID. Omit to search all guilds.")
}
},
async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId))
);
+ server.registerTool(
+ "get_voice_overview",
+ {
+ description: "List the occupied voice channels in a guild and their members.",
+ inputSchema: {
+ guildId: z.string().describe("Guild ID")
+ }
+ },
+ async ({ guildId }) => jsonText(await requireCore().voiceOverview(guildId))
+ );
+
+ server.registerTool(
+ "search_users",
+ {
+ description: "Search users in the client cache by username, global name, guild nickname, or user ID.",
+ inputSchema: {
+ query: z.string().min(1).describe("Username, global name, nickname, or user ID"),
+ guildId: z.string().optional().describe("Limit the search to one guild"),
+ limit: z.number().int().min(1).max(25).default(10).describe("Max results")
+ }
+ },
+ async ({ query, guildId, limit }) => jsonText(await requireCore().userSearch(query, guildId, limit))
+ );
+
+ server.registerTool(
+ "list_dms",
+ {
+ description: "List the cached DM and group DM channels with their recipients.",
+ inputSchema: {
+ limit: z.number().int().min(1).max(50).default(25).describe("Max channels")
+ }
+ },
+ async ({ limit }) => jsonText(await requireCore().dmList(limit))
+ );
+
server.registerTool(
"get_action_log",
{
@@ -338,7 +425,13 @@ app.listen(PORT, HOST, (error?: Error) => {
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
});
-process.on("SIGINT", () => {
+function shutdown() {
core?.close();
- process.exit(0);
-});
+ void coreProcess
+ .dispose(4000)
+ .catch(() => undefined)
+ .finally(() => process.exit(0));
+}
+
+process.on("SIGINT", shutdown);
+process.on("SIGTERM", shutdown);
diff --git a/poc/src/core-process.ts b/poc/src/core-process.ts
new file mode 100644
index 0000000..4144397
--- /dev/null
+++ b/poc/src/core-process.ts
@@ -0,0 +1,251 @@
+/*
+ * Veskslop daemon — Vesktop core process control.
+ *
+ * The daemon can start, stop, and restart the Electron client that hosts the
+ * core bridge. Only a process started by this daemon instance is managed.
+ *
+ * On Linux without a DISPLAY, the daemon starts Xvfb and points the client at
+ * it. Set VESKSLOP_XVFB=0 to disable that.
+ */
+
+import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = fileURLToPath(new URL("../..", import.meta.url));
+
+const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
+const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
+const MAX_OUTPUT_LINES = 20;
+
+export interface CoreExit {
+ code: number | null;
+ signal: string | null;
+ at: string;
+}
+
+export interface CoreProcessStatus {
+ managed: boolean;
+ running: boolean;
+ pid: number | null;
+ startedAt: string | null;
+ command: string | null;
+ lastExit: CoreExit | null;
+ output: string[];
+}
+
+export interface CoreProcessOptions {
+ tokenFile: string;
+}
+
+function prefixLines(prefix: string, chunk: Buffer): string {
+ return chunk
+ .toString()
+ .split("\n")
+ .map(line => (line ? `${prefix} ${line}` : line))
+ .join("\n");
+}
+
+function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function findFreeDisplay(): string {
+ for (let number = 99; number < 110; number++) {
+ if (!existsSync(`/tmp/.X11-unix/X${number}`)) return `:${number}`;
+ }
+ return ":99";
+}
+
+export class CoreProcess {
+ private child?: ChildProcess;
+ private startedAt?: number;
+ private command?: string;
+ private xvfb?: ChildProcess;
+ private display?: string;
+ private lastExit: CoreExit | null = null;
+ private readonly output: string[] = [];
+ private readonly exitListeners = new Set<() => void>();
+
+ constructor(private readonly options: CoreProcessOptions) {}
+
+ status(): CoreProcessStatus {
+ const running = !!this.child && this.child.exitCode === null && !this.child.killed;
+ return {
+ managed: !!this.child,
+ running,
+ pid: this.child?.pid ?? null,
+ startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : null,
+ command: this.command ?? null,
+ lastExit: this.lastExit,
+ output: [...this.output]
+ };
+ }
+
+ onExit(listener: () => void) {
+ this.exitListeners.add(listener);
+ return () => this.exitListeners.delete(listener);
+ }
+
+ async start(forceToken = false): Promise {
+ if (this.child) return this.status();
+
+ const override = process.env.VESKSLOP_CORE_CMD;
+ const args = (process.env.VESKSLOP_CORE_ARGS ?? DEFAULT_ARGS).split(/\s+/).filter(Boolean);
+ const display = await this.ensureDisplay();
+
+ const env = {
+ ...process.env,
+ ...(display ? { DISPLAY: display } : {}),
+ VESKSLOP_HEADLESS: process.env.VESKSLOP_HEADLESS ?? "1",
+ VESKSLOP_TOKEN_FILE: this.options.tokenFile,
+ ...(forceToken ? { VESKSLOP_FORCE_TOKEN: "1" } : {})
+ };
+
+ let command: string;
+ let child: ChildProcess;
+
+ if (override) {
+ command = override;
+ child = spawn(override, { cwd: ROOT, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
+ } else {
+ const electron = join(
+ ROOT,
+ "node_modules",
+ ".bin",
+ process.platform === "win32" ? "electron.cmd" : "electron"
+ );
+ if (!existsSync(electron)) {
+ throw new Error("electron is not installed; run pnpm install or set VESKSLOP_CORE_CMD");
+ }
+ command = `${electron} . ${args.join(" ")}`;
+ child = spawn(electron, [".", ...args], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
+ }
+
+ this.child = child;
+ this.startedAt = Date.now();
+ this.command = command;
+ this.lastExit = null;
+ this.output.length = 0;
+
+ child.stdout?.on("data", chunk => {
+ this.appendOutput(chunk);
+ process.stdout.write(prefixLines("[core]", chunk));
+ });
+ child.stderr?.on("data", chunk => {
+ this.appendOutput(chunk);
+ process.stderr.write(prefixLines("[core]", chunk));
+ });
+
+ child.on("error", error => {
+ console.error("[core] failed to start:", error.message);
+ this.appendOutput(Buffer.from(error.message));
+ });
+
+ child.on("exit", (code, signal) => {
+ console.log(`[core] exited (code ${code ?? "null"}${signal ? `, signal ${signal}` : ""})`);
+ this.lastExit = { code, signal, at: new Date().toISOString() };
+ this.child = undefined;
+ this.startedAt = undefined;
+ this.command = undefined;
+ for (const listener of this.exitListeners) listener();
+ });
+
+ console.log(`[core] starting: ${command}${display ? ` (DISPLAY=${display})` : ""}`);
+ return this.status();
+ }
+
+ async stop(timeoutMs = 8000): Promise {
+ const child = this.child;
+ if (!child) return this.status();
+
+ await new Promise(resolve => {
+ const timer = setTimeout(() => {
+ try {
+ child.kill("SIGKILL");
+ } catch {
+ // already gone
+ }
+ }, timeoutMs);
+
+ child.once("exit", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ clearTimeout(timer);
+ resolve();
+ }
+ });
+
+ return this.status();
+ }
+
+ async restart(forceToken = false): Promise {
+ await this.stop();
+ return this.start(forceToken);
+ }
+
+ /** Stop the core and any Xvfb that this daemon started. */
+ async dispose(timeoutMs = 4000): Promise {
+ await this.stop(timeoutMs);
+ this.stopXvfb();
+ }
+
+ private appendOutput(chunk: Buffer) {
+ for (const line of chunk.toString().split("\n")) {
+ if (!line.trim()) continue;
+ this.output.push(line);
+ if (this.output.length > MAX_OUTPUT_LINES) this.output.shift();
+ }
+ }
+
+ private async ensureDisplay(): Promise {
+ if (process.env.DISPLAY) return process.env.DISPLAY;
+ if (process.platform !== "linux" || process.env.VESKSLOP_XVFB === "0") return undefined;
+ if (this.xvfb && this.xvfb.exitCode === null) return this.display;
+
+ const display = findFreeDisplay();
+ const child = spawn("Xvfb", [display, ...XVFB_ARGS.split(" ")], { stdio: ["ignore", "ignore", "pipe"] });
+
+ child.stderr?.on("data", chunk => process.stderr.write(prefixLines("[xvfb]", chunk)));
+ child.on("error", error => {
+ console.warn(`[core] Xvfb is not available: ${error.message}`);
+ this.xvfb = undefined;
+ this.display = undefined;
+ });
+ child.on("exit", () => {
+ this.xvfb = undefined;
+ this.display = undefined;
+ });
+
+ this.xvfb = child;
+ this.display = display;
+
+ // Wait for the X socket so Electron does not race the server startup.
+ const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
+ for (let attempt = 0; attempt < 30 && !existsSync(socket); attempt++) await sleep(100);
+ if (!existsSync(socket)) console.warn(`[core] Xvfb socket ${socket} did not appear; continuing anyway`);
+
+ console.log(`[core] Xvfb on ${display} (pid ${child.pid})`);
+ return display;
+ }
+
+ private stopXvfb() {
+ const child = this.xvfb;
+ this.xvfb = undefined;
+ this.display = undefined;
+
+ if (child && child.exitCode === null) {
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ // already gone
+ }
+ }
+ }
+}
diff --git a/poc/src/qr-login.ts b/poc/src/qr-login.ts
index 702f17f..4c977bd 100644
--- a/poc/src/qr-login.ts
+++ b/poc/src/qr-login.ts
@@ -1,53 +1,20 @@
/*
- * Veskslop Phase 0 — Discord remote-auth QR login.
- *
- * Connects to Discord's remote-auth gateway, renders an approval QR in the
- * terminal, waits for the phone to approve, and prints/saves the user token.
+ * Veskslop Phase 0 — Discord remote-auth QR login (terminal front end).
*
* Usage:
* pnpm poc:qr
* TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
* HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange
*
- * The token file is written with mode 600 and is gitignored.
+ * The session logic lives in qr-session.ts and is shared with the web UI.
*/
-import crypto from "node:crypto";
-import fs from "node:fs";
import readline from "node:readline";
import qrcode from "qrcode-terminal";
-import WebSocket from "ws";
import { tokenFile } from "./config.js";
-
-const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
-const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
-const USER_AGENT =
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
-const debug = process.env.DEBUG === "1";
-
-const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
-const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
-
-function log(...args: unknown[]) {
- console.log("[qr]", ...args);
-}
-
-function debugLog(...args: unknown[]) {
- if (debug) console.error("[qr:debug]", ...args);
-}
-
-function decryptPayload(payload: string): Buffer {
- return crypto.privateDecrypt(
- {
- key: keyPair.privateKey,
- padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
- oaepHash: "sha256"
- },
- Buffer.from(payload, "base64")
- );
-}
+import { QrLoginSession } from "./qr-session.js";
function promptCaptchaKey(): Promise {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -59,143 +26,58 @@ function promptCaptchaKey(): Promise {
});
}
-async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise {
- const body: Record = { ticket };
- if (captchaKey) {
- body.captcha_key = captchaKey;
- body.captcha_rqtoken = captchaRqToken;
- }
-
- const response = await fetch(LOGIN_URL, {
- method: "POST",
- headers: {
- Accept: "*/*",
- "Accept-Language": "en-US,en;q=0.9",
- "Content-Type": "application/json",
- Origin: "https://discord.com",
- Referer: "https://discord.com/login",
- "User-Agent": USER_AGENT
- },
- body: JSON.stringify(body)
- });
- const data: any = await response.json();
- debugLog("ticket exchange", response.status, JSON.stringify(data));
-
- if (data.encrypted_token) {
- return decryptPayload(data.encrypted_token).toString();
- }
-
- if (data.captcha_sitekey || data.captcha_rqtoken) {
- log("Discord wants a captcha to finish this login.");
- log(` service: ${data.captcha_service}`);
- log(` sitekey: ${data.captcha_sitekey}`);
- log(` rqdata: ${data.captcha_rqdata}`);
- const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
- if (!key) {
- log("No captcha key provided, giving up.");
- return undefined;
- }
- return exchangeTicket(ticket, key, data.captcha_rqtoken);
- }
-
- log(`Ticket exchange failed (HTTP ${response.status}):`, JSON.stringify(data));
- return undefined;
-}
-
-log("Connecting to Discord remote-auth gateway...");
-const ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
-
-let heartbeat: NodeJS.Timeout | undefined;
-let finished = false;
-
-ws.on("open", () => log("Connected. Requesting QR..."));
-
-ws.on("message", async (raw: WebSocket.RawData) => {
- const packet: any = JSON.parse(raw.toString());
- debugLog("<-", packet.op ?? packet);
-
- switch (packet.op) {
- case "hello": {
- heartbeat = setInterval(
- () => ws.send(JSON.stringify({ op: "heartbeat" })),
- packet.heartbeat_interval
- );
- ws.send(JSON.stringify({ op: "init", encoded_public_key: encodedPublicKey }));
- break;
- }
-
- case "nonce_proof": {
- const nonce = decryptPayload(packet.encrypted_nonce);
- const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
- ws.send(JSON.stringify({ op: "nonce_proof", proof }));
- break;
- }
-
- case "pending_remote_init": {
- const url = `https://discord.com/ra/${packet.fingerprint}`;
- log("Scan this QR code with the Discord mobile app (you may need the camera/QR scanner):\n");
- qrcode.generate(url, { small: true });
- log(`Or open this link on the phone: ${url}\n`);
- break;
- }
-
- case "pending_ticket": {
- const payload = decryptPayload(packet.encrypted_user_payload).toString();
- const [id, discriminator, avatar, username] = payload.split(":");
- debugLog("user payload:", payload);
- const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
- log(`Phone approved: ${tag} (${id})${avatar ? " with avatar " + avatar : ""}`);
- break;
- }
-
- case "pending_login": {
- const token = await exchangeTicket(packet.ticket);
- if (!token) {
- finished = true;
- ws.close();
- process.exitCode = 1;
+const session = new QrLoginSession({
+ tokenFile,
+ debug: process.env.DEBUG === "1",
+ onUpdate: update => {
+ switch (update.state) {
+ case "pending":
+ console.log("[qr] Scan this QR code with the Discord mobile app:\n");
+ if (update.url) qrcode.generate(update.url, { small: true });
+ console.log(`[qr] Or open this link on the phone: ${update.url}\n`);
break;
- }
- finished = true;
- fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
- log("\n=== token acquired ===");
- console.log(token);
- log(`Saved to ${tokenFile} (mode 600).`);
- ws.close();
- break;
+ case "scanned":
+ console.log(`[qr] Phone approved: ${update.user?.tag} (${update.user?.id})`);
+ break;
+
+ case "captcha":
+ console.log("[qr] Discord wants a captcha to finish this login.");
+ console.log(` service: ${update.captcha?.service}`);
+ console.log(` sitekey: ${update.captcha?.sitekey}`);
+ console.log(` rqdata: ${update.captcha?.rqdata}`);
+ void (async () => {
+ const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
+ if (!key) session.cancel();
+ else session.submitCaptcha(key);
+ })();
+ break;
+
+ case "finished":
+ console.log("\n=== token acquired ===");
+ console.log(session.token);
+ console.log(`[qr] Saved to ${tokenFile} (mode 600).`);
+ process.exit(0);
+ break;
+
+ case "failed":
+ console.error(`[qr] Login failed: ${update.error}`);
+ process.exit(1);
+ break;
+
+ case "cancelled":
+ console.log("[qr] Login was cancelled.");
+ process.exit(0);
+ break;
}
-
- case "cancel":
- log("Login was cancelled on the phone.");
- finished = true;
- ws.close();
- break;
-
- case "heartbeat_ack":
- break;
-
- default:
- debugLog("unhandled op", packet);
}
});
-ws.on("close", () => {
- if (heartbeat) clearInterval(heartbeat);
- done();
-});
-
-ws.on("error", error => {
- console.error("[qr] gateway error:", error.message);
- process.exitCode = 1;
-});
-
-function done() {
- if (!finished) log("Connection closed before login completed.");
- process.exit(process.exitCode ?? 0);
-}
+console.log("[qr] Connecting to Discord remote-auth gateway...");
+session.start();
process.on("SIGINT", () => {
- log("Interrupted.");
- ws.close();
+ console.log("[qr] Interrupted.");
+ session.cancel();
+ setTimeout(() => process.exit(0), 100);
});
diff --git a/poc/src/qr-session.ts b/poc/src/qr-session.ts
new file mode 100644
index 0000000..bd55439
--- /dev/null
+++ b/poc/src/qr-session.ts
@@ -0,0 +1,248 @@
+/*
+ * Veskslop daemon — Discord remote-auth QR login session.
+ *
+ * The terminal tool and the web UI share this class. It connects to Discord's
+ * remote-auth gateway, produces the approval URL, waits for phone approval,
+ * and writes the token file on success.
+ */
+
+import crypto from "node:crypto";
+import fs from "node:fs";
+
+import WebSocket from "ws";
+
+const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
+const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
+const USER_AGENT =
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
+
+export type QrLoginState = "connecting" | "pending" | "scanned" | "captcha" | "finished" | "failed" | "cancelled";
+
+export interface QrLoginUser {
+ id: string;
+ tag: string;
+ avatar?: string;
+}
+
+export interface QrLoginCaptcha {
+ service?: string;
+ sitekey?: string;
+ rqdata?: string;
+}
+
+export interface QrLoginUpdate {
+ state: QrLoginState;
+ url?: string;
+ user?: QrLoginUser;
+ error?: string;
+ captcha?: QrLoginCaptcha;
+}
+
+export interface QrLoginOptions {
+ tokenFile: string;
+ onUpdate?: (update: QrLoginUpdate) => void;
+ debug?: boolean;
+}
+
+export function isTerminalQrState(state: QrLoginState): boolean {
+ return state === "finished" || state === "failed" || state === "cancelled";
+}
+
+export class QrLoginSession {
+ state: QrLoginState = "connecting";
+ url?: string;
+ user?: QrLoginUser;
+ error?: string;
+ captcha?: QrLoginCaptcha;
+ token?: string;
+
+ private readonly keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
+ private readonly encodedPublicKey = this.keyPair.publicKey
+ .export({ type: "spki", format: "der" })
+ .toString("base64");
+
+ private ws?: WebSocket;
+ private heartbeat?: NodeJS.Timeout;
+ private ticket?: string;
+ private captchaRqToken?: string;
+ private closed = false;
+
+ constructor(private readonly options: QrLoginOptions) {}
+
+ start(): void {
+ this.ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
+
+ this.ws.on("open", () => this.debug("connected, requesting QR"));
+
+ this.ws.on("message", raw => {
+ void this.handle(raw).catch(error => this.fail(String(error?.message ?? error)));
+ });
+
+ this.ws.on("close", () => {
+ if (this.heartbeat) clearInterval(this.heartbeat);
+ if (!this.closed) this.fail("gateway connection closed");
+ });
+
+ this.ws.on("error", error => {
+ this.debug(`gateway error: ${error.message}`);
+ this.fail(error.message);
+ });
+ }
+
+ cancel(): void {
+ if (this.closed) return;
+ this.closed = true;
+ this.ws?.close();
+ this.update({ state: "cancelled" });
+ }
+
+ submitCaptcha(key: string): boolean {
+ if (this.state !== "captcha" || !this.ticket) return false;
+ void this.finishWithTicket(key, this.captchaRqToken);
+ return true;
+ }
+
+ private update(update: QrLoginUpdate) {
+ this.state = update.state;
+ if (update.url) this.url = update.url;
+ if (update.user) this.user = update.user;
+ if (update.error) this.error = update.error;
+ if (update.captcha) this.captcha = update.captcha;
+ this.options.onUpdate?.(update);
+ }
+
+ private fail(error: string) {
+ if (this.closed) return;
+ this.closed = true;
+ this.ws?.close();
+ this.update({ state: "failed", error });
+ }
+
+ private async handle(raw: WebSocket.RawData) {
+ const packet: any = JSON.parse(raw.toString());
+ this.debug(`<- ${packet.op}`);
+
+ switch (packet.op) {
+ case "hello":
+ this.heartbeat = setInterval(
+ () => this.ws?.send(JSON.stringify({ op: "heartbeat" })),
+ packet.heartbeat_interval
+ );
+ this.ws?.send(JSON.stringify({ op: "init", encoded_public_key: this.encodedPublicKey }));
+ break;
+
+ case "nonce_proof": {
+ const nonce = this.decrypt(packet.encrypted_nonce);
+ const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
+ this.ws?.send(JSON.stringify({ op: "nonce_proof", proof }));
+ break;
+ }
+
+ case "pending_remote_init":
+ this.update({ state: "pending", url: `https://discord.com/ra/${packet.fingerprint}` });
+ break;
+
+ case "pending_ticket": {
+ const payload = this.decrypt(packet.encrypted_user_payload).toString();
+ const [id, discriminator, avatar, username] = payload.split(":");
+ const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
+ this.update({ state: "scanned", user: { id, tag, ...(avatar ? { avatar } : {}) } });
+ break;
+ }
+
+ case "pending_login":
+ this.ticket = packet.ticket;
+ await this.finishWithTicket();
+ break;
+
+ case "cancel":
+ this.closed = true;
+ this.ws?.close();
+ this.update({ state: "cancelled" });
+ break;
+
+ case "heartbeat_ack":
+ break;
+
+ default:
+ this.debug(`unhandled op ${packet.op}`);
+ }
+ }
+
+ private async finishWithTicket(captchaKey?: string, captchaRqToken?: string) {
+ const token = await this.exchangeTicket(this.ticket!, captchaKey, captchaRqToken);
+ if (!token) return;
+
+ try {
+ fs.writeFileSync(this.options.tokenFile, token + "\n", { mode: 0o600 });
+ } catch (error) {
+ this.fail(`could not write the token file: ${String(error)}`);
+ return;
+ }
+
+ this.token = token;
+ this.closed = true;
+ this.ws?.close();
+ this.update({ state: "finished", user: this.user });
+ }
+
+ private async exchangeTicket(
+ ticket: string,
+ captchaKey?: string,
+ captchaRqToken?: string
+ ): Promise {
+ const body: Record = { ticket };
+ if (captchaKey) {
+ body.captcha_key = captchaKey;
+ body.captcha_rqtoken = captchaRqToken;
+ }
+
+ const response = await fetch(LOGIN_URL, {
+ method: "POST",
+ headers: {
+ Accept: "*/*",
+ "Accept-Language": "en-US,en;q=0.9",
+ "Content-Type": "application/json",
+ Origin: "https://discord.com",
+ Referer: "https://discord.com/login",
+ "User-Agent": USER_AGENT
+ },
+ body: JSON.stringify(body)
+ });
+ const data: any = await response.json();
+ this.debug(`ticket exchange ${response.status}`);
+
+ if (data.encrypted_token) return this.decrypt(data.encrypted_token).toString();
+
+ if (data.captcha_sitekey || data.captcha_rqtoken) {
+ this.captchaRqToken = data.captcha_rqtoken;
+ this.update({
+ state: "captcha",
+ captcha: {
+ service: data.captcha_service,
+ sitekey: data.captcha_sitekey,
+ rqdata: data.captcha_rqdata
+ }
+ });
+ return undefined;
+ }
+
+ this.fail(`ticket exchange failed (HTTP ${response.status})`);
+ return undefined;
+ }
+
+ private decrypt(payload: string): Buffer {
+ return crypto.privateDecrypt(
+ {
+ key: this.keyPair.privateKey,
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
+ oaepHash: "sha256"
+ },
+ Buffer.from(payload, "base64")
+ );
+ }
+
+ private debug(...args: unknown[]) {
+ if (this.options.debug) console.error("[qr:debug]", ...args);
+ }
+}
diff --git a/poc/src/webui.ts b/poc/src/webui.ts
new file mode 100644
index 0000000..f5b171c
--- /dev/null
+++ b/poc/src/webui.ts
@@ -0,0 +1,349 @@
+/*
+ * Veskslop daemon — web control UI.
+ *
+ * A separate Express listener serves static HTML/JS and a small JSON API on
+ * WEBUI_HOST:WEBUI_PORT. Remote clients can use the API directly, so the page
+ * stays replaceable. There is no authentication: bind to LAN or Tailscale only.
+ * See docs/webui.md for the API contract.
+ */
+
+import express, { type Request, type Response } from "express";
+import { fileURLToPath } from "node:url";
+import QRCode from "qrcode";
+
+import type { AuthService, AuthStatus } from "./auth-service.js";
+import { CoreError, type CoreClient } from "./core-client.js";
+import type { CoreProcess } from "./core-process.js";
+import { dedupe, getQueryCount, paced, pacedSend } from "./pacing.js";
+import type { Puppet } from "./puppet.js";
+
+const HOST = process.env.WEBUI_HOST ?? "0.0.0.0";
+const PORT = Number(process.env.WEBUI_PORT ?? 8789);
+
+export interface WebUiDeps {
+ getCore: () => CoreClient | undefined;
+ getPuppet: () => Puppet | undefined;
+ isCoreConnected: () => boolean;
+ coreProcess: CoreProcess;
+ auth: AuthService;
+}
+
+export interface WebUiHandle {
+ broadcast(event: unknown): void;
+}
+
+class HttpError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string
+ ) {
+ super(message);
+ }
+}
+
+function clampLimit(value: unknown, fallback: number, max: number): number {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) return fallback;
+ return Math.min(Math.max(Math.trunc(parsed), 1), max);
+}
+
+/** Render the pending approval URL as a PNG data URL for the login page. */
+async function qrDataUrl(status: AuthStatus): Promise {
+ const session = status.session;
+ if (session?.state !== "pending" || !session.url) return undefined;
+ return QRCode.toDataURL(session.url, { margin: 1, width: 280 });
+}
+
+export function startWebUi(deps: WebUiDeps): WebUiHandle {
+ const app = express();
+ app.use(express.json({ limit: "64kb" }));
+ app.use(express.static(fileURLToPath(new URL("../public", import.meta.url))));
+
+ const clients = new Set();
+ let eventCount = 0;
+
+ const requireCore = (): CoreClient => {
+ const core = deps.getCore();
+ if (!deps.isCoreConnected() || !core) throw new HttpError(503, "not connected to Vesktop");
+ return core;
+ };
+
+ const requirePuppet = (): Puppet => {
+ const puppet = deps.getPuppet();
+ if (!puppet) throw new HttpError(503, "puppet not connected yet");
+ return puppet;
+ };
+
+ const wrap =
+ (handler: (req: Request, res: Response) => Promise) => (req: Request, res: Response) => {
+ handler(req, res).catch(error => {
+ if (error instanceof CoreError) {
+ res.status(400).json({ ok: false, code: error.code, error: error.message });
+ return;
+ }
+
+ const status = error instanceof HttpError ? error.status : 500;
+ res.status(status).json({ ok: false, error: String(error?.message ?? error) });
+ });
+ };
+
+ const coreStatus = () => ({
+ bridgeConnected: deps.isCoreConnected(),
+ process: deps.coreProcess.status()
+ });
+
+ const authStatus = async () => {
+ const status = deps.auth.status();
+ return { ...status, qrDataUrl: await qrDataUrl(status) };
+ };
+
+ app.get("/api/health", (_req, res) => {
+ res.json({
+ ok: true,
+ result: {
+ coreConnected: deps.isCoreConnected(),
+ ownerId: deps.getPuppet()?.owner ?? null,
+ process: deps.coreProcess.status(),
+ uptimeSeconds: Math.floor(process.uptime()),
+ events: eventCount,
+ queries: getQueryCount()
+ }
+ });
+ });
+
+ app.get("/api/core/status", (_req, res) => {
+ res.json({ ok: true, result: coreStatus() });
+ });
+
+ app.post(
+ "/api/core/start",
+ wrap(async (req, res) => {
+ const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin();
+ await deps.coreProcess.start(forceToken);
+ res.json({ ok: true, result: coreStatus() });
+ })
+ );
+
+ app.post(
+ "/api/core/stop",
+ wrap(async (_req, res) => {
+ await deps.coreProcess.stop();
+ res.json({ ok: true, result: coreStatus() });
+ })
+ );
+
+ app.post(
+ "/api/core/restart",
+ wrap(async (req, res) => {
+ const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin();
+ await deps.coreProcess.restart(forceToken);
+ res.json({ ok: true, result: coreStatus() });
+ })
+ );
+
+ app.get(
+ "/api/auth",
+ wrap(async (_req, res) => {
+ res.json({ ok: true, result: await authStatus() });
+ })
+ );
+
+ app.post(
+ "/api/auth/qr",
+ wrap(async (_req, res) => {
+ deps.auth.startQr();
+ res.json({ ok: true, result: await authStatus() });
+ })
+ );
+
+ app.post(
+ "/api/auth/qr/cancel",
+ wrap(async (_req, res) => {
+ deps.auth.cancelQr();
+ res.json({ ok: true, result: await authStatus() });
+ })
+ );
+
+ app.post(
+ "/api/auth/qr/captcha",
+ wrap(async (req, res) => {
+ const key = String(req.body?.key ?? "").trim();
+ if (!key) throw new HttpError(400, "a captcha key is required");
+ res.json({ ok: true, result: { accepted: deps.auth.submitCaptcha(key) } });
+ })
+ );
+
+ app.get(
+ "/api/state",
+ wrap(async (_req, res) => {
+ res.json({ ok: true, result: await requireCore().getState() });
+ })
+ );
+
+ app.get(
+ "/api/messages",
+ wrap(async (req, res) => {
+ const channelId = String(req.query.channelId ?? "");
+ const limit = clampLimit(req.query.limit, 50, 100);
+ const refresh = req.query.refresh === "1" || req.query.refresh === "true";
+
+ // refresh fetches from Discord and fills the client cache. Attachments
+ // stay as CDN links; nothing is stored as a file.
+ const result = await dedupe(`messages:${channelId}:${limit}:${refresh}`, () =>
+ paced(() =>
+ refresh
+ ? requireCore().fetchMessages(channelId, limit)
+ : requireCore().readMessages(channelId, limit)
+ )
+ );
+
+ res.json({ ok: true, result });
+ })
+ );
+
+ app.post(
+ "/api/messages",
+ wrap(async (req, res) => {
+ const { channelId, content } = req.body ?? {};
+ const result = await pacedSend(() => requireCore().sendMessage(String(channelId), String(content ?? "")));
+ deps.getPuppet()?.appendAction({
+ type: "manual",
+ mode: "channel",
+ channelId: String(channelId),
+ contentLength: String(content ?? "").length
+ });
+ res.json({ ok: true, result });
+ })
+ );
+
+ app.post(
+ "/api/dm",
+ wrap(async (req, res) => {
+ const { content } = req.body ?? {};
+ let userId = req.body?.userId ? String(req.body.userId) : undefined;
+
+ if (!userId) {
+ const username = String(req.body?.username ?? "").trim();
+ if (!username) throw new HttpError(400, "userId or username is required");
+
+ const search = (await requireCore().userSearch(username, undefined, 10)) as {
+ users?: Array<{ id: string; username: string }>;
+ };
+
+ const needle = username.replace(/^@/, "").toLowerCase();
+ const match =
+ search?.users?.find(user => user.id === username) ??
+ search?.users?.find(user => user.username?.toLowerCase() === needle) ??
+ search?.users?.[0];
+
+ if (!match) throw new HttpError(404, `no cached user matches ${username}`);
+ userId = match.id;
+ }
+
+ const result = await pacedSend(() => requireCore().sendDm(String(userId), String(content ?? "")));
+ deps.getPuppet()?.appendAction({
+ type: "manual",
+ mode: "dm",
+ userId: String(userId),
+ contentLength: String(content ?? "").length
+ });
+ res.json({ ok: true, result });
+ })
+ );
+
+ app.get(
+ "/api/users",
+ wrap(async (req, res) => {
+ const query = String(req.query.q ?? "");
+ const guildId = req.query.guildId ? String(req.query.guildId) : undefined;
+ const limit = clampLimit(req.query.limit, 10, 25);
+ res.json({ ok: true, result: await requireCore().userSearch(query, guildId, limit) });
+ })
+ );
+
+ app.get(
+ "/api/dms",
+ wrap(async (req, res) => {
+ const limit = clampLimit(req.query.limit, 25, 50);
+ res.json({ ok: true, result: await requireCore().dmList(limit) });
+ })
+ );
+
+ app.get(
+ "/api/voice",
+ wrap(async (req, res) => {
+ const userId = req.query.userId ? String(req.query.userId) : undefined;
+ const guildId = req.query.guildId ? String(req.query.guildId) : undefined;
+ res.json({ ok: true, result: await requireCore().voiceGet(userId, guildId) });
+ })
+ );
+
+ app.get(
+ "/api/voice/overview",
+ wrap(async (req, res) => {
+ const guildId = String(req.query.guildId ?? "");
+ res.json({ ok: true, result: await requireCore().voiceOverview(guildId) });
+ })
+ );
+
+ app.post("/api/voice/join", (_req, res) => {
+ res.status(501).json({ ok: false, error: "voice.join is not implemented yet" });
+ });
+
+ app.post("/api/voice/leave", (_req, res) => {
+ res.status(501).json({ ok: false, error: "voice.leave is not implemented yet" });
+ });
+
+ app.get("/api/logs", (req, res) => {
+ const puppet = requirePuppet();
+ const limit = clampLimit(req.query.limit, 100, 500);
+ res.json({ ok: true, result: puppet.readActionLog(limit) });
+ });
+
+ app.get("/api/events", (req, res) => {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ "X-Accel-Buffering": "no"
+ });
+
+ res.write(
+ `data: ${JSON.stringify({
+ type: "hello",
+ coreConnected: deps.isCoreConnected(),
+ ownerId: deps.getPuppet()?.owner ?? null
+ })}\n\n`
+ );
+
+ clients.add(res);
+ const keepAlive = setInterval(() => res.write(": ping\n\n"), 15000);
+
+ req.on("close", () => {
+ clearInterval(keepAlive);
+ clients.delete(res);
+ });
+ });
+
+ const server = app.listen(PORT, HOST, () => {
+ console.log(`[webui] listening on http://${HOST}:${PORT} (no auth; LAN or Tailscale only)`);
+ });
+
+ server.on("error", error => {
+ console.error("[webui] failed to start:", error);
+ });
+
+ return {
+ broadcast(event: unknown) {
+ eventCount++;
+ const line = `data: ${JSON.stringify(event)}\n\n`;
+ for (const client of clients) {
+ try {
+ client.write(line);
+ } catch {
+ clients.delete(client);
+ }
+ }
+ }
+ };
+}