Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished notifications while no app window is visible; settings with test button - Guided new-session flow: task, craft target (website/app/game/old software/fresh), clean room or deep, description, model, folder (new, existing or cloned git repository); originals upload to original/ - Craft phase bar (craft_phase tool) for study/specify/build/check/ready - Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB) - All UI glyphs and emojis replaced by themeable SVG icons - Rename dialog with manual name or model-generated suggestion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
013695722a
commit
a0d0e707f2
48 files changed
+2724
-228
No files matched your search
Generated
+342
@@ -8,6 +8,41 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ctutils",
|
||||
"ghash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -167,6 +202,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -324,6 +365,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
@@ -343,6 +395,7 @@ checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
name = "codecraft"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
@@ -354,10 +407,12 @@ dependencies = [
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"globset",
|
||||
"hkdf",
|
||||
"html2text",
|
||||
"ignore",
|
||||
"image",
|
||||
"libc",
|
||||
"p256",
|
||||
"rand 0.10.3",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -434,6 +489,12 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -486,6 +547,22 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"num-traits",
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -502,7 +579,18 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -512,6 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -526,6 +615,17 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -565,6 +665,43 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"crypto-common 0.2.2",
|
||||
"digest 0.11.3",
|
||||
"ff",
|
||||
"group",
|
||||
"hkdf",
|
||||
"hybrid-array",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.10.1",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.44"
|
||||
@@ -633,6 +770,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
@@ -818,6 +965,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
|
||||
dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.21"
|
||||
@@ -831,6 +987,17 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -849,6 +1016,24 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html2text"
|
||||
version = "0.15.5"
|
||||
@@ -929,7 +1114,9 @@ version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"subtle",
|
||||
"typenum",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1171,6 +1358,15 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
@@ -1479,6 +1675,19 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primefield",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1512,6 +1721,15 @@ dependencies = [
|
||||
"phc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1573,6 +1791,16 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -1592,6 +1820,17 @@ dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -1616,6 +1855,33 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "primefield"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
|
||||
dependencies = [
|
||||
"crypto-bigint",
|
||||
"crypto-common 0.2.2",
|
||||
"ff",
|
||||
"rand_core 0.10.1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
"primefield",
|
||||
"serdect",
|
||||
"wnaf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -1874,6 +2140,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
|
||||
dependencies = [
|
||||
"crypto-bigint",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2038,6 +2314,20 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ctutils",
|
||||
"der",
|
||||
"hybrid-array",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -2142,6 +2432,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serdect"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
@@ -2195,6 +2495,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -2251,6 +2561,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.6.2"
|
||||
@@ -2697,6 +3017,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -3073,6 +3403,18 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wnaf"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"group",
|
||||
"hybrid-array",
|
||||
"primefield",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
||||
@@ -38,3 +38,6 @@ tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4", "v7"] }
|
||||
p256 = { version = "0.14", features = ["ecdh", "ecdsa"] }
|
||||
hkdf = "0.13"
|
||||
aes-gcm = "0.11"
|
||||
@@ -5,6 +5,6 @@ clean_room: false
|
||||
---
|
||||
Load the skill "recreate" with the skill tool and carry out phases 1–4 for the software in
|
||||
original/ (inventory and unpacking, analysis, documentation and specification, proposal).
|
||||
Track the phases with todo_write. Stop after the proposal and ask the user how to proceed.
|
||||
Report the workflow phase with craft_phase (study while taking inventory and analysing, specify while documenting and writing the proposal) and track the steps inside a phase with todo_write. Stop after the proposal and ask the user how to proceed.
|
||||
|
||||
Additional notes from the user: $ARGUMENTS
|
||||
@@ -88,5 +88,5 @@ ask_user how to proceed. Do not start building before the user agrees.
|
||||
code are new. Facts needed for compatibility (file formats, constants, rules) are fine.
|
||||
- Original assets (graphics, sounds, levels) stay the user's files: the re-creation may load
|
||||
them from `original/` at runtime but does not copy them into the repository.
|
||||
- Use todo_write for the phases; delegate broad searches through decompiled output to
|
||||
- Report phases with craft_phase and use todo_write for the steps; delegate broad searches through decompiled output to
|
||||
explore subagents to keep your own context small.
|
||||
+83
-2
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions", get(list_sessions).post(create_session))
|
||||
.route("/sessions/search", get(search_sessions))
|
||||
.route("/sessions/{id}/fork", post(fork_session))
|
||||
.route("/sessions/{id}/title", post(suggest_title))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
@@ -120,6 +121,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/images/config", get(images_config))
|
||||
.route("/images/{id}", delete(delete_image))
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.route("/push/key", get(push_key))
|
||||
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
|
||||
.route("/push/test", post(push_test))
|
||||
.route("/ws", get(ws))
|
||||
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
|
||||
// Prompts may carry images (base64); the default limit is 2 MB.
|
||||
@@ -163,12 +167,13 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
|
||||
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||
if !user.is_admin() {
|
||||
// Users only need defaults, not the configuration.
|
||||
return Ok(Json(json!({"default_model": default_model, "currency": cfg.server.currency})));
|
||||
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency})));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"config": config::redacted_json(&cfg),
|
||||
"workspace": app.workspace(),
|
||||
"default_model": default_model,
|
||||
"small_model": cfg.server.small_model,
|
||||
"currency": cfg.server.currency,
|
||||
})))
|
||||
}
|
||||
@@ -320,6 +325,7 @@ struct CreateSession {
|
||||
model: Option<String>,
|
||||
agent: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
clean_room: Option<bool>,
|
||||
}
|
||||
|
||||
/// Resolve a directory and check it lies inside one of the user's roots.
|
||||
@@ -364,7 +370,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: None,
|
||||
clean_room: body.clean_room,
|
||||
phase: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -389,6 +396,19 @@ async fn search_sessions(Extension(user): Extension<User>, State(app): State<App
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct TitleBody {
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
/// Generate a title suggestion with a chosen model (not saved).
|
||||
async fn suggest_title(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<TitleBody>>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
let model = body.and_then(|b| b.0.model);
|
||||
let title = crate::runner::suggest_title(app.clone(), &id, model).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"title": title})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ForkBody {
|
||||
/// Last message to keep (inclusive); all messages when missing.
|
||||
@@ -414,6 +434,7 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_usage: None,
|
||||
phase: None,
|
||||
..src.clone()
|
||||
};
|
||||
let fork = app.save_session(fork)?;
|
||||
@@ -988,6 +1009,50 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
|
||||
|
||||
// --- websocket --------------------------------------------------------------
|
||||
|
||||
// --- web push -------------------------------------------------------------------
|
||||
|
||||
async fn push_key(State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!({"key": app.push.public_key()})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushKeys {
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushSubscription {
|
||||
endpoint: String,
|
||||
keys: PushKeys,
|
||||
}
|
||||
|
||||
async fn push_subscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(sub): Json<PushSubscription>) -> ApiResult {
|
||||
if !sub.endpoint.starts_with("https://") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid push endpoint".into()));
|
||||
}
|
||||
app.store.push_add(&user.id, &sub.endpoint, &sub.keys.p256dh, &sub.keys.auth)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushEndpoint {
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<PushEndpoint>) -> ApiResult {
|
||||
// Only the owner's own subscription.
|
||||
if app.store.push_subscriptions(&user.id)?.iter().any(|s| s.endpoint == b.endpoint) {
|
||||
app.store.push_remove(&b.endpoint)?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let sent = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
|
||||
Ok(Json(json!({"sent": sent})))
|
||||
}
|
||||
|
||||
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
upgrade.on_upgrade(move |socket| ws_loop(app, user, socket))
|
||||
}
|
||||
@@ -1002,6 +1067,7 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
// Re-check the login regularly so disabled users lose their live connection.
|
||||
let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
let mut visible = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rx.recv() => match ev {
|
||||
@@ -1021,6 +1087,18 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
},
|
||||
msg = socket.recv() => match msg {
|
||||
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(WsMessage::Text(t))) => {
|
||||
// The client reports whether its window is visible (push only when none is).
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&t) {
|
||||
if v["type"] == "visibility" {
|
||||
let now = v["visible"].as_bool().unwrap_or(false);
|
||||
if now != visible {
|
||||
app.push.set_visible(&user.id, if now { 1 } else { -1 });
|
||||
visible = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ = ping.tick() => {
|
||||
@@ -1035,6 +1113,9 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if visible {
|
||||
app.push.set_visible(&user.id, -1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct App {
|
||||
pub shells: crate::shells::Shells,
|
||||
/// Language servers (diagnostics after edits).
|
||||
pub lsp: crate::lsp::Lsp,
|
||||
/// Web Push (VAPID key, window visibility per user).
|
||||
pub push: crate::push::Push,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
@@ -108,6 +110,7 @@ impl App {
|
||||
tool_install: Mutex::default(),
|
||||
shells: Mutex::default(),
|
||||
lsp: Default::default(),
|
||||
push: crate::push::Push::new(&data),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
|
||||
@@ -246,6 +246,8 @@ pub struct ServerConfig {
|
||||
pub budget_warning: f64,
|
||||
/// "warn" (default) or "block": refuse new model requests once a budget is used up.
|
||||
pub budget_action: String,
|
||||
/// Largest file upload in MB (uploads are sent in chunks, so the reverse proxy limit doesn't apply).
|
||||
pub max_upload_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
@@ -263,6 +265,7 @@ impl Default for ServerConfig {
|
||||
monthly_budget: 0.0,
|
||||
budget_warning: 0.8,
|
||||
budget_action: "warn".into(),
|
||||
max_upload_mb: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -373,16 +373,35 @@ pub async fn remove(Extension(user): Extension<User>, State(app): State<AppState
|
||||
pub struct UploadQuery {
|
||||
/// Target file path (absolute).
|
||||
path: String,
|
||||
/// Chunked upload: byte offset of this chunk (0 = first chunk, creates the file).
|
||||
offset: Option<u64>,
|
||||
}
|
||||
|
||||
/// Upload one file as the raw request body.
|
||||
/// Upload a file as the raw request body – whole, or in chunks with `offset`
|
||||
/// (large files: each chunk stays below the reverse proxy's body limit).
|
||||
pub async fn upload(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<UploadQuery>, body: Bytes) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &q.path, false)?;
|
||||
if p.is_dir() {
|
||||
return Err(bad("is a directory"));
|
||||
}
|
||||
tokio::fs::write(&p, &body).await?;
|
||||
Ok(Json(json!({"ok": true, "path": p, "size": body.len()})))
|
||||
let max = app.config().server.max_upload_mb.saturating_mul(1024 * 1024);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
if offset + body.len() as u64 > max {
|
||||
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("file is larger than {} MB", max / 1024 / 1024)));
|
||||
}
|
||||
if offset == 0 {
|
||||
tokio::fs::write(&p, &body).await?;
|
||||
} else {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let current = tokio::fs::metadata(&p).await.map(|m| m.len()).unwrap_or(0);
|
||||
if current != offset {
|
||||
return Err(ApiError(StatusCode::CONFLICT, format!("chunk offset {offset} does not match the uploaded size {current}")));
|
||||
}
|
||||
let mut f = tokio::fs::OpenOptions::new().append(true).open(&p).await?;
|
||||
f.write_all(&body).await?;
|
||||
f.flush().await?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true, "path": p, "size": offset + body.len() as u64})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -16,6 +16,7 @@ mod mentions;
|
||||
mod mcp;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
mod push;
|
||||
mod provider;
|
||||
mod reverse;
|
||||
mod runner;
|
||||
@@ -47,6 +48,7 @@ async fn main() -> Result<()> {
|
||||
let bind = cfg.server.bind.clone();
|
||||
let app = app::App::new(path, cfg).await?;
|
||||
lsp::spawn_reaper(app.clone());
|
||||
push::spawn_listener(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
tracing::info!("listening on http://{bind}");
|
||||
|
||||
@@ -50,7 +50,7 @@ const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify an
|
||||
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
|
||||
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
|
||||
|
||||
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase).
|
||||
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in phases. Report each phase with craft_phase when you enter it (study, specify in deep mode, build, check, ready) – the user sees it as a progress bar; use todo_write for the steps inside a phase.
|
||||
|
||||
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
|
||||
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Web Push notifications (VAPID, RFC 8030/8291/8292) without external services.
|
||||
//!
|
||||
//! The server sends a push to a user's subscribed devices when an agent asks
|
||||
//! for permission, asks a question, or finishes a longer run – but only while
|
||||
//! none of the user's app windows is visible.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::types::Event;
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
|
||||
use hkdf::Hkdf;
|
||||
use p256::ecdsa::signature::Signer;
|
||||
use p256::elliptic_curve::sec1::ToSec1Point;
|
||||
use p256::{PublicKey, SecretKey};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Runs shorter than this don't trigger a "finished" notification.
|
||||
const MIN_RUN_FOR_DONE: Duration = Duration::from_secs(20);
|
||||
|
||||
pub struct Push {
|
||||
key_path: PathBuf,
|
||||
key: OnceLock<SecretKey>,
|
||||
/// user id -> number of visible app windows (reported over the WebSocket)
|
||||
visible: Mutex<HashMap<String, usize>>,
|
||||
/// session id -> start of the current run
|
||||
runs: Mutex<HashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
fn random_bytes<const N: usize>() -> [u8; N] {
|
||||
rand::random()
|
||||
}
|
||||
|
||||
impl Push {
|
||||
pub fn new(data_dir: &std::path::Path) -> Self {
|
||||
Self { key_path: data_dir.join("vapid.key"), key: OnceLock::new(), visible: Mutex::default(), runs: Mutex::default() }
|
||||
}
|
||||
|
||||
/// The VAPID key pair, created on first use and kept on the data volume.
|
||||
fn key(&self) -> &SecretKey {
|
||||
self.key.get_or_init(|| {
|
||||
if let Ok(bytes) = std::fs::read(&self.key_path) {
|
||||
if let Ok(k) = SecretKey::from_slice(&bytes) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
let k = loop {
|
||||
if let Ok(k) = SecretKey::from_slice(&random_bytes::<32>()) {
|
||||
break k;
|
||||
}
|
||||
};
|
||||
let _ = std::fs::write(&self.key_path, k.to_bytes());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
k
|
||||
})
|
||||
}
|
||||
|
||||
/// Public key for PushManager.subscribe (base64url, uncompressed point).
|
||||
pub fn public_key(&self) -> String {
|
||||
B64.encode(self.key().public_key().as_affine().to_uncompressed_point())
|
||||
}
|
||||
|
||||
pub fn set_visible(&self, user_id: &str, delta: isize) {
|
||||
let mut v = self.visible.lock().unwrap();
|
||||
let n = v.entry(user_id.to_string()).or_default();
|
||||
*n = (*n as isize + delta).max(0) as usize;
|
||||
}
|
||||
|
||||
fn is_visible(&self, user_id: &str) -> bool {
|
||||
self.visible.lock().unwrap().get(user_id).is_some_and(|n| *n > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 8291 "aes128gcm" encryption of one record.
|
||||
fn encrypt(ua_public: &[u8], auth: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let as_secret = loop {
|
||||
if let Ok(k) = SecretKey::from_slice(&random_bytes::<32>()) {
|
||||
break k;
|
||||
}
|
||||
};
|
||||
encrypt_with(ua_public, auth, plaintext, &as_secret, random_bytes::<16>())
|
||||
}
|
||||
|
||||
fn encrypt_with(ua_public: &[u8], auth: &[u8], plaintext: &[u8], as_secret: &SecretKey, salt: [u8; 16]) -> Result<Vec<u8>> {
|
||||
let ua = PublicKey::from_sec1_bytes(ua_public).map_err(|_| anyhow!("invalid p256dh key"))?;
|
||||
let as_public = as_secret.public_key().as_affine().to_uncompressed_point();
|
||||
let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua.as_affine());
|
||||
|
||||
// IKM from the ECDH secret and the subscription's auth secret.
|
||||
let mut key_info = b"WebPush: info\0".to_vec();
|
||||
key_info.extend_from_slice(ua_public);
|
||||
key_info.extend_from_slice(&as_public);
|
||||
let mut ikm = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(Some(auth), shared.raw_secret_bytes()).expand(&key_info, &mut ikm).map_err(|_| anyhow!("hkdf"))?;
|
||||
|
||||
let prk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
|
||||
let mut cek = [0u8; 16];
|
||||
let mut nonce = [0u8; 12];
|
||||
prk.expand(b"Content-Encoding: aes128gcm\0", &mut cek).map_err(|_| anyhow!("hkdf"))?;
|
||||
prk.expand(b"Content-Encoding: nonce\0", &mut nonce).map_err(|_| anyhow!("hkdf"))?;
|
||||
|
||||
let mut record = plaintext.to_vec();
|
||||
record.push(2); // padding delimiter: last record
|
||||
let cipher = aes_gcm::Aes128Gcm::new_from_slice(&cek).map_err(|_| anyhow!("aes key"))?;
|
||||
let ct = cipher.encrypt(&nonce.into(), record.as_slice()).map_err(|_| anyhow!("encryption failed"))?;
|
||||
|
||||
let mut out = Vec::with_capacity(16 + 4 + 1 + 65 + ct.len());
|
||||
out.extend_from_slice(&salt);
|
||||
out.extend_from_slice(&4096u32.to_be_bytes());
|
||||
out.push(as_public.len() as u8);
|
||||
out.extend_from_slice(&as_public);
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// VAPID authorization header (RFC 8292) for an endpoint.
|
||||
fn vapid_header(key: &SecretKey, endpoint: &str, subject: &str) -> Result<String> {
|
||||
let url = reqwest::Url::parse(endpoint)?;
|
||||
let aud = url.origin().ascii_serialization();
|
||||
let exp = chrono::Utc::now().timestamp() + 12 * 3600;
|
||||
let header = B64.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
|
||||
let claims = B64.encode(serde_json::to_vec(&json!({"aud": aud, "exp": exp, "sub": subject}))?);
|
||||
let signing_input = format!("{header}.{claims}");
|
||||
let sk = p256::ecdsa::SigningKey::from(key);
|
||||
let sig: p256::ecdsa::Signature = sk.sign(signing_input.as_bytes());
|
||||
let jwt = format!("{signing_input}.{}", B64.encode(sig.to_bytes()));
|
||||
let k = B64.encode(key.public_key().as_affine().to_uncompressed_point());
|
||||
Ok(format!("vapid t={jwt}, k={k}"))
|
||||
}
|
||||
|
||||
/// Send a notification to all devices of a user.
|
||||
pub async fn send_to_user(app: &App, user_id: &str, payload: &Value) -> usize {
|
||||
let subs = app.store.push_subscriptions(user_id).unwrap_or_default();
|
||||
let mut sent = 0;
|
||||
for sub in subs {
|
||||
match send_one(app, &sub.endpoint, &sub.p256dh, &sub.auth, payload).await {
|
||||
Ok(()) => sent += 1,
|
||||
Err(e) => {
|
||||
let gone = e.to_string().contains("410") || e.to_string().contains("404");
|
||||
tracing::debug!("push to {}: {e:#}", sub.endpoint);
|
||||
if gone {
|
||||
let _ = app.store.push_remove(&sub.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
async fn send_one(app: &App, endpoint: &str, p256dh: &str, auth: &str, payload: &Value) -> Result<()> {
|
||||
let ua = B64.decode(p256dh.trim_end_matches('=')).context("p256dh")?;
|
||||
let auth = B64.decode(auth.trim_end_matches('=')).context("auth")?;
|
||||
let body = encrypt(&ua, &auth, &serde_json::to_vec(payload)?)?;
|
||||
let cfg = app.config();
|
||||
let subject = if cfg.auth.public_url.starts_with("https://") { cfg.auth.public_url.clone() } else { "mailto:admin@localhost".to_string() };
|
||||
let resp = crate::provider::http_client()
|
||||
.post(endpoint)
|
||||
.header("Authorization", vapid_header(app.push.key(), endpoint, &subject)?)
|
||||
.header("Content-Encoding", "aes128gcm")
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("TTL", "86400")
|
||||
.header("Urgency", "high")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("HTTP {}: {}", resp.status().as_u16(), resp.text().await.unwrap_or_default().chars().take(200).collect::<String>());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Owner and title of the top-level session.
|
||||
fn session_info(app: &App, session_id: &str) -> Option<(String, String, String)> {
|
||||
let root = app.root_session_id(session_id);
|
||||
let s = app.store.get_session(&root).ok().flatten()?;
|
||||
let title = if s.title.is_empty() { "CodeCraft".to_string() } else { s.title.clone() };
|
||||
Some((s.owner_id?, title, root))
|
||||
}
|
||||
|
||||
/// Background task: turn agent events into notifications.
|
||||
pub fn spawn_listener(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
let mut rx = app.subscribe();
|
||||
loop {
|
||||
let ev = match rx.recv().await {
|
||||
Ok(ev) => ev,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
let (session_id, kind, body) = match &ev {
|
||||
Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")),
|
||||
Event::QuestionRequest { session_id, questions, .. } => {
|
||||
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
|
||||
(session_id.clone(), "question", q)
|
||||
}
|
||||
Event::SessionStatus { session_id, busy: true } => {
|
||||
app.push.runs.lock().unwrap().insert(session_id.clone(), Instant::now());
|
||||
continue;
|
||||
}
|
||||
Event::SessionStatus { session_id, busy: false } => {
|
||||
let started = app.push.runs.lock().unwrap().remove(session_id);
|
||||
if !started.is_some_and(|t| t.elapsed() >= MIN_RUN_FOR_DONE) {
|
||||
continue;
|
||||
}
|
||||
// Subagents report through their parent.
|
||||
if app.store.get_session(session_id).ok().flatten().is_none_or(|s| s.parent_id.is_some()) {
|
||||
continue;
|
||||
}
|
||||
(session_id.clone(), "done", String::new())
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let Some((owner, title, root)) = session_info(&app, &session_id) else { continue };
|
||||
if app.push.is_visible(&owner) {
|
||||
continue;
|
||||
}
|
||||
let payload = json!({
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"body": body.chars().take(180).collect::<String>(),
|
||||
"tag": format!("{root}-{kind}"),
|
||||
"url": format!("/#/s/{root}"),
|
||||
});
|
||||
let app2 = app.clone();
|
||||
tokio::spawn(async move {
|
||||
send_to_user(&app2, &owner, &payload).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Decrypt with the user agent's keys (RFC 8291) to check the encryption.
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let ua_secret = SecretKey::from_slice(&[7u8; 32]).unwrap();
|
||||
let ua_public = ua_secret.public_key().as_affine().to_uncompressed_point();
|
||||
let auth = [9u8; 16];
|
||||
let msg = br#"{"title":"hi"}"#;
|
||||
let body = encrypt(&ua_public, &auth, msg).unwrap();
|
||||
|
||||
let salt = &body[..16];
|
||||
let idlen = body[20] as usize;
|
||||
let as_public = &body[21..21 + idlen];
|
||||
let ct = &body[21 + idlen..];
|
||||
let as_pk = PublicKey::from_sec1_bytes(as_public).unwrap();
|
||||
let shared = p256::ecdh::diffie_hellman(ua_secret.to_nonzero_scalar(), as_pk.as_affine());
|
||||
let mut info = b"WebPush: info\0".to_vec();
|
||||
info.extend_from_slice(&ua_public);
|
||||
info.extend_from_slice(as_public);
|
||||
let mut ikm = [0u8; 32];
|
||||
Hkdf::<Sha256>::new(Some(&auth), shared.raw_secret_bytes()).expand(&info, &mut ikm).unwrap();
|
||||
let prk = Hkdf::<Sha256>::new(Some(salt), &ikm);
|
||||
let (mut cek, mut nonce) = ([0u8; 16], [0u8; 12]);
|
||||
prk.expand(b"Content-Encoding: aes128gcm\0", &mut cek).unwrap();
|
||||
prk.expand(b"Content-Encoding: nonce\0", &mut nonce).unwrap();
|
||||
let pt = aes_gcm::Aes128Gcm::new_from_slice(&cek).unwrap().decrypt(&nonce.into(), ct).unwrap();
|
||||
assert_eq!(&pt[..pt.len() - 1], msg);
|
||||
assert_eq!(pt[pt.len() - 1], 2);
|
||||
}
|
||||
|
||||
/// Example from RFC 8291, section 5.
|
||||
#[test]
|
||||
fn rfc8291_vector() {
|
||||
let d = |s: &str| B64.decode(s).unwrap();
|
||||
let as_secret = SecretKey::from_slice(&d("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw")).unwrap();
|
||||
let ua_public = d("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4");
|
||||
let auth = d("BTBZMqHH6r4Tts7J_aSIgg");
|
||||
let salt: [u8; 16] = d("DGv6ra1nlYgDCS1FRnbzlw").try_into().unwrap();
|
||||
let out = encrypt_with(&ua_public, &auth, b"When I grow up, I want to be a watermelon", &as_secret, salt).unwrap();
|
||||
assert_eq!(
|
||||
B64.encode(out),
|
||||
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vapid_jwt_verifies() {
|
||||
use p256::ecdsa::signature::Verifier;
|
||||
let key = SecretKey::from_slice(&[3u8; 32]).unwrap();
|
||||
let h = vapid_header(&key, "https://fcm.googleapis.com/fcm/send/abc", "mailto:a@b.c").unwrap();
|
||||
let jwt = h.strip_prefix("vapid t=").unwrap().split(", k=").next().unwrap();
|
||||
let (input, sig) = jwt.rsplit_once('.').unwrap();
|
||||
let sig = p256::ecdsa::Signature::from_slice(&B64.decode(sig).unwrap()).unwrap();
|
||||
p256::ecdsa::VerifyingKey::from(key.public_key()).verify(input.as_bytes(), &sig).unwrap();
|
||||
let claims: Value = serde_json::from_slice(&B64.decode(input.split('.').nth(1).unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(claims["aud"], "https://fcm.googleapis.com");
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -220,7 +220,7 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
||||
all.into_iter()
|
||||
.filter(|t| !agent.read_only || t.read_only())
|
||||
// Craft tools only where they are useful.
|
||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile") || agent.tools.is_some())
|
||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
|
||||
.filter(|t| match &agent.tools {
|
||||
None => true,
|
||||
Some(list) => {
|
||||
@@ -640,6 +640,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: parent.clean_room,
|
||||
phase: None,
|
||||
};
|
||||
let child = app.save_session(child)?;
|
||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||
@@ -929,6 +930,45 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
|
||||
const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points.";
|
||||
|
||||
/// Suggest a title for a session from its conversation, with the given model
|
||||
/// (or the small model). The title is returned, not saved.
|
||||
pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<String>) -> Result<String> {
|
||||
let session = app.session(session_id)?;
|
||||
let spec = model_spec.filter(|m| !m.is_empty()).unwrap_or_else(|| small_model(&app, &session));
|
||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||
model.effort = Some("low".into());
|
||||
let history = active_history(app.store.messages(session_id)?);
|
||||
let mut transcript = transcript_of(&history);
|
||||
if transcript.len() > 12_000 {
|
||||
// Beginning (the goal) and end (where it went).
|
||||
let head = clip(&transcript, 6_000);
|
||||
let mut start = transcript.len() - 5_000;
|
||||
while !transcript.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
transcript = format!("{head}\n…\n{}", &transcript[start..]);
|
||||
}
|
||||
if transcript.trim().is_empty() {
|
||||
bail!("the session has no messages yet");
|
||||
}
|
||||
let req = ChatRequest {
|
||||
system: "Write a short, specific title (max 6 words) for the coding session below. Use the language of the user's messages. Reply with the title only, no quotes, no punctuation at the end.".into(),
|
||||
messages: vec![Message::new(Role::User, vec![Part::Text { text: format!("<session>\n{transcript}\n</session>") }])],
|
||||
tools: vec![],
|
||||
max_tokens: 2000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "title");
|
||||
let text = strip_think(&text);
|
||||
let title = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ']).to_string();
|
||||
if title.is_empty() {
|
||||
bail!("the model returned no title");
|
||||
}
|
||||
Ok(title.chars().take(80).collect())
|
||||
}
|
||||
|
||||
/// Remove a leading `<think>…</think>` block some models put into the text.
|
||||
fn strip_think(text: &str) -> String {
|
||||
let t = text.trim_start();
|
||||
@@ -953,7 +993,7 @@ fn clip(s: &str, max: usize) -> String {
|
||||
}
|
||||
|
||||
/// The history as readable text for summarizing (tool output shortened).
|
||||
fn transcript_of(history: &[Message]) -> String {
|
||||
pub fn transcript_of(history: &[Message]) -> String {
|
||||
let mut out = String::new();
|
||||
for m in history {
|
||||
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
|
||||
@@ -97,6 +97,13 @@ impl Store {
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS push_subs (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
@@ -874,6 +881,30 @@ impl Store {
|
||||
// --- audit log & backup ----------------------------------------------------------
|
||||
|
||||
impl Store {
|
||||
// --- web push ---------------------------------------------------------
|
||||
|
||||
pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO push_subs (endpoint, user_id, p256dh, auth, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET user_id = ?2, p256dh = ?3, auth = ?4",
|
||||
rusqlite::params![endpoint, user_id, p256dh, auth, now_ms()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_remove(&self, endpoint: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("DELETE FROM push_subs WHERE endpoint = ?1", [endpoint])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_subscriptions(&self, user_id: &str) -> Result<Vec<PushSub>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT endpoint, p256dh, auth FROM push_subs WHERE user_id = ?1")?;
|
||||
let rows = stmt.query_map([user_id], |r| Ok(PushSub { endpoint: r.get(0)?, p256dh: r.get(1)?, auth: r.get(2)? }))?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
pub fn audit(&self, user_id: Option<&str>, username: &str, action: &str, detail: &str, ip: &str) {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
@@ -946,3 +977,9 @@ impl Store {
|
||||
Ok(data.and_then(|d| serde_json::from_str::<Value>(&d).ok()).and_then(|v| v["created_at"].as_i64()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PushSub {
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
}
|
||||
@@ -55,6 +55,48 @@ impl Tool for TodoWrite {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const CRAFT_PHASES: &[&str] = &["study", "specify", "build", "check", "ready"];
|
||||
|
||||
/// Craft workflow: report the current phase (shown as a progress bar).
|
||||
pub struct CraftPhase;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CraftPhase {
|
||||
fn name(&self) -> &str {
|
||||
"craft_phase"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Report the craft phase you are entering (study, specify, build, check, ready). The user sees it \
|
||||
as a progress bar. Call it at the start of every phase, also when you go back to an earlier one."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({"type": "object", "properties": {
|
||||
"phase": {"type": "string", "enum": CRAFT_PHASES},
|
||||
"note": {"type": "string", "description": "One short sentence: what happens in this phase"}
|
||||
}, "required": ["phase"]})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Phase: {}", input["phase"].as_str().unwrap_or("?"))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let phase = arg_str(&input, "phase")?;
|
||||
if !CRAFT_PHASES.contains(&phase) {
|
||||
bail!("unknown phase '{phase}' – use one of {}", CRAFT_PHASES.join(", "));
|
||||
}
|
||||
let root = ctx.app.root_session_id(&ctx.session_id);
|
||||
let mut s = ctx.app.session(&root)?;
|
||||
s.phase = Some(phase.to_string());
|
||||
ctx.app.save_session(s)?;
|
||||
Ok(ToolOutput::text(format!("Phase: {phase}")).with_meta(json!({"phase": phase, "note": input["note"]})))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AskUser;
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(crate::shells::KillShell),
|
||||
Arc::new(web::WebFetch),
|
||||
Arc::new(web::WebSearch),
|
||||
Arc::new(agent::CraftPhase),
|
||||
Arc::new(crate::lsp::Diagnostics),
|
||||
Arc::new(agent::TodoWrite),
|
||||
Arc::new(agent::AskUser),
|
||||
|
||||
@@ -127,6 +127,9 @@ pub struct Session {
|
||||
/// Craft mode: clean room (true) or deep (false); None = server default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clean_room: Option<bool>,
|
||||
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
||||
Reference in new issue
Block a user