diff --git a/bun.lock b/bun.lock index 7a40e43908..1861be9c7c 100644 --- a/bun.lock +++ b/bun.lock @@ -100,12 +100,16 @@ "@lobehub/icons": "^5.4.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@react-three/xr": "^6.6.29", "@shikijs/langs": "^3.20.0", "@shikijs/themes": "^3.20.0", "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-router": "^1.170.8", "@tanstack/router-core": "^1.171.6", + "@types/three": "^0.184.1", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -116,6 +120,7 @@ "hast-util-to-jsx-runtime": "^2.3.6", "katex": "^0.16.45", "mermaid": "^11.12.0", + "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-katex": "^7.0.1", @@ -125,6 +130,7 @@ "shiki": "^3.20.0", "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", + "three": "^0.184.0", "workbox-expiration": "^7.4.0", "workbox-precaching": "^7.4.0", "workbox-routing": "^7.4.0", @@ -132,9 +138,11 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "1.60.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", @@ -237,6 +245,9 @@ }, }, }, + "overrides": { + "@bufbuild/protobuf": "^2.10.2", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -496,7 +507,7 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@builder.io/jsx-loc-internals": ["@builder.io/jsx-loc-internals@0.0.1", "", { "dependencies": { "@babel/parser": "^7.24.0", "estree-walker": "2.0.2", "magic-string": "^0.30.8" } }, "sha512-cSADapVCi07DDhcuDmcAVItqSVmji7DNyD3xxYTHyNCwhWMNnTpZjyvDIWwYFJLleyDCJ9VUtbaXtUjjqBiRqw=="], @@ -524,6 +535,8 @@ "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -652,6 +665,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.6.0", "", {}, "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw=="], + + "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], + + "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA=="], + + "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@0.2.2", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~1 || ~6", "react": ">=16.3" } }, "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g=="], + "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="], "@grammyjs/types": ["@grammyjs/types@3.22.2", "", {}, "sha512-uu7DX2ezhnBPozL3bXHmwhLvaFsh59E4QyviNH4Cij7EdVekYrs6mCzeXsa2pDk30l3uXo7DBahlZLzTPtpYZg=="], @@ -676,6 +697,10 @@ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@iwer/devui": ["@iwer/devui@1.1.2", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", "@pmndrs/handle": "^6.6.17", "@pmndrs/pointer-events": "^6.6.17", "react": ">=18.3.1", "react-dom": ">=18.3.1", "styled-components": "^6.1.13", "three": "^0.165.0" }, "peerDependencies": { "iwer": "^2.0.1" } }, "sha512-ggF1lXSX14BTYP0QzB4xaurySr2PC+3+rtK/dpCR++giWquzFv2mBw3LW/PaCtdl5mqkZMrQ2GSwfUNg9ZoO+w=="], + + "@iwer/sem": ["@iwer/sem@0.2.5", "", { "dependencies": { "three": "^0.165.0", "ts-proto": "^2.6.0" }, "peerDependencies": { "iwer": "^2.0.0" } }, "sha512-vMCfpu/7Qqc+hkBiGD9pxjeObgrhXOrL0KX94CA3yzJaU0dq0y49HXZT6fC+6X/jOmjaM3hjyE1m2h7ZmLzzyA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -708,12 +733,16 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], + "@medv/finder": ["@medv/finder@4.0.2", "", {}, "sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ=="], "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="], + "@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="], + "@pierre/diffs": ["@pierre/diffs@1.1.12", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-InssHHM7f0nkazIRkuaiNCy6GkBLfwJlqc7LtTkMD/KSqsuc6bnL2V9sIQoG5PZu9jwinQiXUb/gT7itFa6U9A=="], "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], @@ -722,6 +751,12 @@ "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@pmndrs/handle": ["@pmndrs/handle@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "zustand": "^4.5.2" } }, "sha512-KPutdaLpCNPZoqXX2HvlRc+cQMETXdHTF0nKqFHMrJrne34apqELRVig53lO469rJhheedsRVepfdyo4g9Rukw=="], + + "@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.30", "", {}, "sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g=="], + + "@pmndrs/xr": ["@pmndrs/xr@6.6.30", "", { "dependencies": { "@iwer/devui": "^1.1.1", "@iwer/sem": "~0.2.5", "@pmndrs/pointer-events": "~6.6.30", "iwer": "^2.1.0", "meshline": "^3.3.1", "zustand": "^4.5.2" }, "peerDependencies": { "three": "*" } }, "sha512-qy0UQHaXdZs192awbYkde+O/eKn15fz88X08+7EhlVIdsMfPAORR2JDocTzfYmtz8bKyE4z1JMUUvXb9DyUrzg=="], + "@primer/octicons": ["@primer/octicons@19.23.1", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-CzjGmxkmNhyst6EekrS3SJPdtzgIkUMP/LSJch65y99/kmiFXbO1a+q7zoYe3hnI9NaOM0IN+ydDIbOmd8YqcA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -918,6 +953,12 @@ "@rc-component/virtual-list": ["@rc-component/virtual-list@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ=="], + "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], + + "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], + + "@react-three/xr": ["@react-three/xr@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "@pmndrs/xr": "~6.6.30", "suspend-react": "^0.1.3", "tunnel-rat": "^0.1.2", "zustand": "^4.5.2" }, "peerDependencies": { "@react-three/fiber": ">=8", "react": ">=18", "react-dom": ">=18", "three": "*" } }, "sha512-C+PYxnDsWvF2WG669DXwlcKnxcy7O+FMhxlaOq75HrRSJRtk4T5iZCv6qak2+8ztcYrip8t79W9E+LSV3P93hA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/plugin-babel": ["@rollup/plugin-babel@5.3.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0" }, "optionalPeers": ["@types/babel__core"] }, "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q=="], @@ -1064,6 +1105,8 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"], "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.20.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-gV9qqvfNjmWcIRWscO20MtbfpHERgPm7gTiK3kLaylCObH+38UBi+4IqI64k9JuVDZ+XCf0b7h77iyLEUg91hg=="], @@ -1164,6 +1207,8 @@ "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1198,6 +1243,8 @@ "@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="], + "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], "@types/ps-list": ["@types/ps-list@6.2.1", "", { "dependencies": { "ps-list": "*" } }, "sha512-YGrVUmLaAu89QffaehCcCp5kphlbCf72+vDxnRyP1soC9y7xaatnoB0Fgo/tEGHk8teglcLwY6b4iHcyd/UWFA=="], @@ -1212,12 +1259,18 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-reconciler": ["@types/react-reconciler@0.28.9", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg=="], + "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -1226,6 +1279,8 @@ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -1380,10 +1435,14 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], @@ -1394,6 +1453,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1414,8 +1475,14 @@ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + + "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="], + "case-anything": ["case-anything@2.1.13", "", {}, "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], @@ -1504,10 +1571,16 @@ "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -1632,6 +1705,8 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + "detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -1650,6 +1725,10 @@ "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "dprint-node": ["dprint-node@1.0.8", "", { "dependencies": { "detect-libc": "^1.0.3" } }, "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg=="], + + "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1780,6 +1859,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], @@ -1810,7 +1891,7 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], - "framer-motion": ["framer-motion@12.23.26", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -1848,10 +1929,14 @@ "giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "glsl-noise": ["glsl-noise@0.0.0", "", {}, "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1912,6 +1997,8 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="], + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hono": ["hono@4.11.2", "", {}, "sha512-o+avdUAD1v94oHkjGBhiMhBV4WBHxhbu0+CUVH78hhphKy/OKQLxtKjkmmNcrMlbYAhAbsM/9F+l3KnYxyD3Lg=="], @@ -1942,6 +2029,10 @@ "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -2054,6 +2145,10 @@ "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], + + "iwer": ["iwer@2.2.1", "", { "dependencies": { "gl-matrix": "^3.4.3", "webxr-layers-polyfill": "^1.1.0" } }, "sha512-TBGgUlOpYsJpYmBCDRrW2KDHC70XZeFrlOwlDNiso+dp83wwRyCLaeN50/qo4JRIKyn08+cD6iaw5+t+D7U+uQ=="], + "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], @@ -2106,6 +2201,8 @@ "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -2168,6 +2265,8 @@ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="], @@ -2224,6 +2323,10 @@ "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], + "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="], + + "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -2336,9 +2439,9 @@ "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], - "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -2462,6 +2565,8 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], + "preact": ["preact@10.28.1", "", {}, "sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw=="], "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], @@ -2472,6 +2577,8 @@ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -2576,6 +2683,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -2806,6 +2915,10 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], + + "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], @@ -2844,6 +2957,8 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "styled-components": ["styled-components@6.4.2", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "stylis": "4.3.6" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0", "react-native": ">= 0.68.0" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg=="], + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], @@ -2852,6 +2967,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], + "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -2876,6 +2993,12 @@ "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], + + "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], + + "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], + "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2910,6 +3033,12 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="], + + "troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="], + + "troika-worker-utils": ["troika-worker-utils@0.52.0", "", {}, "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "ts-debounce": ["ts-debounce@4.0.0", "", {}, "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg=="], @@ -2918,10 +3047,18 @@ "ts-md5": ["ts-md5@2.0.1", "", {}, "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w=="], + "ts-poet": ["ts-poet@6.12.0", "", { "dependencies": { "dprint-node": "^1.0.8" } }, "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA=="], + + "ts-proto": ["ts-proto@2.11.8", "", { "dependencies": { "@bufbuild/protobuf": "^2.10.2", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" }, "bin": { "protoc-gen-ts_proto": "protoc-gen-ts_proto" } }, "sha512-+5hzECnyVB33jxjG1BIdzAHcRBm7hjnm8womdJVp2A7xJWihP0drHHVsXYTr9i/LpWNGfh80I+AVVNzFM5AwJw=="], + + "ts-proto-descriptors": ["ts-proto-descriptors@2.1.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -3000,6 +3137,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -3054,10 +3193,16 @@ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="], + + "webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "webrtc-adapter": ["webrtc-adapter@9.0.3", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ=="], + "webxr-layers-polyfill": ["webxr-layers-polyfill@1.1.0", "", { "dependencies": { "gl-matrix": "^3.4.3" } }, "sha512-GqWE6IFlut8a1Lnh9t1RPnOXud1rZ7wLPvWp7mqTDOYtgorXqlNMhEnI9EqjU33grBx0v3jm0Oc13opkAdmgMQ=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -3190,6 +3335,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@iwer/devui/three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], + + "@iwer/sem/three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="], + "@lobehub/fluent-emoji/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "@lobehub/fluent-emoji/es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], @@ -3224,6 +3373,10 @@ "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], + "@pmndrs/handle/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + + "@pmndrs/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -3352,6 +3505,8 @@ "@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + "@react-three/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@rollup/plugin-babel/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -3442,6 +3597,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dprint-node/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], @@ -3488,8 +3645,6 @@ "micromark-extension-math/katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], - "motion/framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], - "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -3504,6 +3659,8 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "promise-worker-transferable/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -3544,6 +3701,8 @@ "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], + "streamdown/katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], "streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], @@ -3560,10 +3719,14 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], + "tsx/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], @@ -3750,10 +3913,6 @@ "hapi-website/vitest/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], - "motion/framer-motion/motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - - "motion/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], - "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 88ba35966b..7839bb10c6 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -22,6 +22,7 @@ import { type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' +import { findCodexSessionPath } from '../modules/common/codexSessions' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' @@ -92,7 +93,14 @@ export class ApiMachineClient { logger: (msg, data) => logger.debug(msg, data) }) - registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) + registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd(), { + codexSessionPathAllowed: this.normalizedWorkspaceRoots?.length + ? async (path) => { + if (!path) return false + return this.isWithinWorkspaceRoots(await this.resolveForWorkspaceCheck(path)) + } + : undefined + }) this.rpcHandlerManager.registerHandler(RPC_METHODS.PathExists, async (params) => { const rawPaths = Array.isArray(params?.paths) ? params.paths : [] @@ -249,7 +257,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName, importHistory } = params || {} if (!directory) { throw new Error('Directory is required') @@ -260,6 +268,17 @@ export class ApiMachineClient { return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' } } + if (agent === 'codex' && resumeSessionId && this.normalizedWorkspaceRoots?.length) { + const codexSessionPath = await findCodexSessionPath(resumeSessionId) + if (!codexSessionPath) { + return { type: 'error', errorMessage: 'Codex session path is unavailable or outside workspace roots' } + } + const resolvedCodexSessionPath = await this.resolveForWorkspaceCheck(codexSessionPath) + if (!this.isWithinWorkspaceRoots(resolvedCodexSessionPath)) { + return { type: 'error', errorMessage: 'Codex session is outside this machine\'s workspace roots' } + } + } + const result = await spawnSession({ directory, sessionId, @@ -275,7 +294,8 @@ export class ApiMachineClient { serviceTier, token, sessionType, - worktreeName + worktreeName, + importHistory }) switch (result.type) { diff --git a/cli/src/claude/claudeRemote.fork.test.ts b/cli/src/claude/claudeRemote.fork.test.ts new file mode 100644 index 0000000000..f4c2dea01e --- /dev/null +++ b/cli/src/claude/claudeRemote.fork.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as claudeSdk from '@/claude/sdk'; +import type { SDKMessage } from '@/claude/sdk/types'; +import type { Metadata } from '@/api/types'; + +vi.mock('@/claude/utils/claudeCheckSession', () => ({ + claudeCheckSession: () => true +})); + +vi.mock('@/modules/watcher/awaitFileExist', () => ({ + awaitFileExist: async () => true +})); + +vi.mock('@/claude/sdk/utils', () => ({ + getDefaultClaudeCodePath: () => '/usr/bin/claude' +})); + +const { getLiveAgentKindMock } = vi.hoisted(() => ({ + getLiveAgentKindMock: vi.fn<(sessionId: string) => 'background' | 'interactive' | null>() +})); + +vi.mock('@/claude/utils/getLiveAgentKind', () => ({ + getLiveAgentKind: getLiveAgentKindMock +})); + +const queryMock = vi.fn(); + +function createAsyncStream(messages: SDKMessage[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const message of messages) { + await Promise.resolve(); + yield message; + } + } + }; +} + +const RESUME_ID = '6f0c4551-1111-4222-8333-444455556666'; +const FORKED_ID = 'aaaa1111-2222-4333-8444-555566667777'; + +function initThenResult(sessionId: string): SDKMessage[] { + return [ + { + type: 'system', + subtype: 'init', + session_id: sessionId + } as unknown as SDKMessage, + { + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: sessionId + } as unknown as SDKMessage + ]; +} + +const ENV_KEYS_UNDER_TEST = ['CLAUDE_CONFIG_DIR', 'DISABLE_AUTOUPDATER'] as const; +let savedEnv: Record; + +beforeEach(() => { + vi.clearAllMocks(); + savedEnv = {}; + for (const key of ENV_KEYS_UNDER_TEST) { + savedEnv[key] = process.env[key]; + } +}); + +afterEach(() => { + // Restore any process.env keys we touched so cases don't leak into each other. + for (const key of ENV_KEYS_UNDER_TEST) { + const original = savedEnv[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } +}); + +describe('claudeRemote fork-on-live-session decision', () => { + it('forks (forkSession=true) and records forkedFrom when the session is live', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + getLiveAgentKindMock.mockReturnValue('background'); + queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(FORKED_ID))); + + const { claudeRemote } = await import('./claudeRemote'); + const foundCalls: Array<{ id: string; extras?: Partial }> = []; + + let nextCallCount = 0; + await claudeRemote({ + sessionId: RESUME_ID, + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: 'ping', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: (id, extras) => { + foundCalls.push({ id, extras }); + }, + onMessage: () => {} + }); + + try { + expect(getLiveAgentKindMock).toHaveBeenCalledWith(RESUME_ID); + const passedOptions = queryMock.mock.calls[0][0].options; + expect(passedOptions.resume).toBe(RESUME_ID); + expect(passedOptions.forkSession).toBe(true); + expect(foundCalls).toEqual([{ id: FORKED_ID, extras: { forkedFrom: RESUME_ID } }]); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); + + it('applies claudeEnvVars to process.env BEFORE probing liveness (regression)', async () => { + // getLiveAgentKind execs `claude agents --json` off process.env, so the + // probe must see the injected claude env (e.g. CLAUDE_CONFIG_DIR) the real + // launch uses; otherwise it queries the default config and misses live + // sessions. Capture process.env at the moment the probe runs. + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + let configDirAtProbe: string | undefined = 'NOT_CALLED'; + getLiveAgentKindMock.mockImplementation(() => { + configDirAtProbe = process.env.CLAUDE_CONFIG_DIR; + return null; + }); + queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(RESUME_ID))); + + const { claudeRemote } = await import('./claudeRemote'); + + let nextCallCount = 0; + await claudeRemote({ + sessionId: RESUME_ID, + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: { CLAUDE_CONFIG_DIR: '/tmp/x-cfg' }, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: 'ping', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: () => {}, + onMessage: () => {} + }); + + try { + expect(getLiveAgentKindMock).toHaveBeenCalledWith(RESUME_ID); + // The injected env must already be live when the probe runs. + expect(configDirAtProbe).toBe('/tmp/x-cfg'); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); + + it('does NOT fork a dead session and records no forkedFrom (regression: AC7)', async () => { + const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); + getLiveAgentKindMock.mockReturnValue(null); + // A dead resume reuses the same session id. + queryMock.mockReturnValueOnce(createAsyncStream(initThenResult(RESUME_ID))); + + const { claudeRemote } = await import('./claudeRemote'); + const foundCalls: Array<{ id: string; extras?: Partial }> = []; + + let nextCallCount = 0; + await claudeRemote({ + sessionId: RESUME_ID, + path: process.cwd(), + mcpServers: {}, + claudeEnvVars: {}, + claudeArgs: [], + allowedTools: [], + hookSettingsPath: '/tmp/hook.json', + canCallTool: async () => ({ behavior: 'allow', updatedInput: {} }), + nextMessage: async () => { + nextCallCount += 1; + if (nextCallCount === 1) { + return { message: 'ping', mode: { permissionMode: 'default' } }; + } + return null; + }, + onReady: () => {}, + isAborted: () => false, + onSessionFound: (id, extras) => { + foundCalls.push({ id, extras }); + }, + onMessage: () => {} + }); + + try { + const passedOptions = queryMock.mock.calls[0][0].options; + expect(passedOptions.resume).toBe(RESUME_ID); + expect(passedOptions.forkSession).toBe(false); + expect(foundCalls).toEqual([{ id: RESUME_ID, extras: undefined }]); + } finally { + queryMock.mockReset(); + querySpy.mockRestore(); + } + }, 15_000); +}); diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 038b037fd8..372b918b7c 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -1,6 +1,8 @@ import { EnhancedMode, PermissionMode } from "./loop"; import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' import { claudeCheckSession } from "./utils/claudeCheckSession"; +import { getLiveAgentKind } from "./utils/getLiveAgentKind"; +import type { Metadata } from "@/api/types"; import { join } from 'node:path'; import { parseSpecialCommand } from "@/parsers/specialCommands"; import { logger } from "@/lib"; @@ -31,7 +33,7 @@ export async function claudeRemote(opts: { isAborted: (toolCallId: string) => boolean, // Callbacks - onSessionFound: (id: string) => void, + onSessionFound: (id: string, extras?: Partial) => void, onThinkingChange?: (thinking: boolean) => void, onMessage: (message: SDKMessage) => void, onCompletionEvent?: (message: string) => void, @@ -71,7 +73,13 @@ export async function claudeRemote(opts: { } } - // Set environment variables for Claude Code SDK + // Set environment variables for Claude Code SDK. + // Apply these BEFORE the liveness probe below: `getLiveAgentKind` execs + // `claude agents --json` using `process.env` (e.g. CLAUDE_CONFIG_DIR, + // HAPI_CLAUDE_PATH), so the probe must see the same claude env the real + // launch will use. Otherwise it queries the default config/home, misses the + // live session, picks `forkSession=false`, and the resume hits the + // "currently running as a background agent" failure path. if (opts.claudeEnvVars) { Object.entries(opts.claudeEnvVars).forEach(([key, value]) => { process.env[key] = value; @@ -79,6 +87,22 @@ export async function claudeRemote(opts: { } process.env.DISABLE_AUTOUPDATER = '1'; + // Decide how to resume. A claude session can still be held open by a running + // agent (background/interactive); a plain `--resume` is then rejected with + // "currently running as a background agent". When that's the case, branch off + // a copy with `--fork-session` instead of taking over the live session. + // `getLiveAgentKind` degrades to null (treat as dead -> plain resume) when the + // daemon roster is unavailable, so this never blocks the resume path. + let forkSession = false; + if (startFrom) { + const liveKind = getLiveAgentKind(startFrom); + if (liveKind) { + forkSession = true; + logger.debug(`[claudeRemote] Session ${startFrom} is live as ${liveKind} agent; resuming with --fork-session`); + } + } + const forkedFrom = forkSession ? startFrom : null; + // Get initial message let initial; try { @@ -125,6 +149,7 @@ export async function claudeRemote(opts: { const sdkOptions: Options = { cwd: opts.path, resume: startFrom ?? undefined, + forkSession, mcpServers: opts.mcpServers, permissionMode: initial.mode.permissionMode, model: initial.mode.model, @@ -250,7 +275,13 @@ export async function claudeRemote(opts: { const projectDir = getProjectPath(opts.path); const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`)); logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`); - opts.onSessionFound(systemInit.session_id); + // When forked, the new session_id is a branched copy: record its + // origin so the web list can mark it as "forked from ..." instead + // of surfacing two unrelated-looking sessions (R5). + const extras = forkedFrom && forkedFrom !== systemInit.session_id + ? { forkedFrom } + : undefined; + opts.onSessionFound(systemInit.session_id, extras); } } diff --git a/cli/src/claude/claudeRemoteLauncher.retry.test.ts b/cli/src/claude/claudeRemoteLauncher.retry.test.ts new file mode 100644 index 0000000000..29c340fa00 --- /dev/null +++ b/cli/src/claude/claudeRemoteLauncher.retry.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * Wiring test for the launch retry loop in claudeRemoteLauncher (design §4.3, + * AC4). The two predicates are unit-tested in utils/claudeResumeError.test.ts; + * this exercises how the launcher acts on them inside runMainLoop: + * - unrecoverable error -> stop immediately (no retry, exit) + * - recoverable error -> retry up to MAX_LAUNCH_RETRIES, then stop + * - clean run after fails -> retry budget resets + * This is the regression guard for the original "infinite respawn" bug. + */ + +const harness = vi.hoisted(() => ({ + // Each entry is a behavior for the Nth claudeRemote() call. + behaviors: [] as Array<() => Promise>, + callIndex: 0 +})); + +vi.mock('./claudeRemote', () => ({ + claudeRemote: async () => { + const behavior = harness.behaviors[harness.callIndex] ?? (async () => {}); + harness.callIndex += 1; + await behavior(); + } +})); + +// Inner collaborators of runMainLoop that we do not exercise here. Mock them +// to no-ops so the test stays focused on the catch/retry branch. +vi.mock('./utils/permissionHandler', () => ({ + PermissionHandler: class { + handleToolCall = async () => ({ behavior: 'allow', updatedInput: {} }); + isAborted = () => false; + setOnPermissionRequest = () => {}; + getResponses = () => new Map(); + onMessage = () => {}; + reset = () => {}; + handleModeChange = () => {}; + } +})); + +vi.mock('./utils/OutgoingMessageQueue', () => ({ + OutgoingMessageQueue: class { + constructor(_send: unknown) {} + enqueue = () => {}; + releaseToolCall = async () => {}; + flush = async () => {}; + destroy = () => {}; + } +})); + +vi.mock('./utils/sdkToLogConverter', () => ({ + SDKToLogConverter: class { + constructor(_meta: unknown, _responses: unknown) {} + updateSessionId = () => {}; + resetParentChain = () => {}; + convert = () => null; + convertSidechainUserMessage = () => null; + generateInterruptedToolResult = () => null; + } +})); + +vi.mock('@/ui/ink/RemoteModeDisplay', () => ({ + RemoteModeDisplay: () => null +})); + +import { claudeRemoteLauncher } from './claudeRemoteLauncher'; + +type SessionEvent = { type: string; message?: string }; + +function createSessionStub() { + const events: SessionEvent[] = []; + // waitForMessagesAndGetAsString is only reached on a *clean* claudeRemote + // return (the loop awaits the next user message). Returning null ends the + // session, so a clean run terminates the loop deterministically. + const session = { + sessionId: 'sess-1', + path: '/tmp/test', + allowedTools: [], + mcpServers: {}, + hookSettingsPath: '/tmp/hook.json', + claudeEnvVars: {}, + claudeArgs: [], + logPath: '/tmp/test.log', + onThinkingChange: () => {}, + queue: { + size: () => 0, + waitForMessagesAndGetAsString: async () => null + }, + client: { + rpcHandlerManager: { registerHandler: () => {} }, + sendClaudeSessionMessage: () => {}, + sendSessionEvent: (event: SessionEvent) => { events.push(event); } + }, + addSessionFoundCallback: () => {}, + removeSessionFoundCallback: () => {}, + consumeOneTimeFlags: () => {}, + onSessionFound: () => {}, + clearSessionId: () => {} + }; + return { session, events }; +} + +function messages(events: SessionEvent[]): string[] { + return events.filter(e => e.type === 'message').map(e => e.message ?? ''); +} + +afterEach(() => { + harness.behaviors = []; + harness.callIndex = 0; + vi.clearAllMocks(); +}); + +describe('claudeRemoteLauncher launch retry wiring', () => { + it('stops immediately on an unrecoverable resume error (no retry)', async () => { + harness.behaviors = [ + async () => { + throw new Error( + 'Session sess-1 is currently running as a background agent (bg). ' + + 'Use claude agents to find and attach to it, or add --fork-session to branch off a copy.' + ); + } + ]; + const { session, events } = createSessionStub(); + + const exitReason = await claudeRemoteLauncher(session as never); + + // Exactly one attempt: the loop must break instead of looping. + expect(harness.callIndex).toBe(1); + expect(exitReason).toBe('exit'); + const msgs = messages(events); + expect(msgs.some(m => m.startsWith('Cannot resume session:'))).toBe(true); + // The real underlying reason must be surfaced (AC2), not swallowed. + expect(msgs.some(m => m.includes('currently running as a background agent'))).toBe(true); + // A recoverable "Process exited unexpectedly" retry message must NOT appear. + expect(msgs.some(m => m.startsWith('Process exited unexpectedly'))).toBe(false); + }, 15_000); + + it('retries a recoverable error up to MAX_LAUNCH_RETRIES then stops', async () => { + // Always throw a transient (recoverable) error so the budget is the + // only thing that stops the loop. + const transient = async () => { throw new Error('Claude Code process exited with code 1'); }; + harness.behaviors = Array.from({ length: 10 }, () => transient); + const { session, events } = createSessionStub(); + + const exitReason = await claudeRemoteLauncher(session as never); + + // MAX_LAUNCH_RETRIES = 3: attempts are the initial try + 3 retries = 4, + // and the 4th is where budgetExhausted trips and the loop breaks. + expect(harness.callIndex).toBe(4); + expect(exitReason).toBe('exit'); + const msgs = messages(events); + const retries = msgs.filter(m => m.startsWith('Process exited unexpectedly')); + expect(retries).toHaveLength(3); + expect(msgs.some(m => m.includes('failed to start after 3 attempts'))).toBe(true); + }, 15_000); + + it('resets the retry budget after a clean run', async () => { + // The launch loop only ends when exitReason is set (the mocked + // claudeRemote returns synchronously, so a clean run alone re-loops). + // To prove the budget RESET, we burn 2 retries, do a clean run (reset), + // then need a *full* fresh budget (3 more retries) before exhaustion. + // If the reset were missing, exhaustion would trip far sooner. + const transient = () => { throw new Error('Claude Code process exited with code 1'); }; + let cleanRunHappened = false; + harness.behaviors = [ + async () => transient(), // attempt 1: retry 1 + async () => transient(), // attempt 2: retry 2 + async () => { cleanRunHappened = true; }, // attempt 3: clean -> budget reset + async () => transient(), // attempt 4: retry 1 (post-reset) + async () => transient(), // attempt 5: retry 2 (post-reset) + async () => transient(), // attempt 6: retry 3 (post-reset) + async () => transient() // attempt 7: budget exhausted -> break + ]; + const { session, events } = createSessionStub(); + + const exitReason = await claudeRemoteLauncher(session as never); + + expect(cleanRunHappened).toBe(true); + // Without the reset, MAX_LAUNCH_RETRIES(=3) would trip at attempt 4 + // (2 pre + 1 post). The reset lets the loop reach attempt 7. + expect(harness.callIndex).toBe(7); + expect(exitReason).toBe('exit'); + const msgs = messages(events); + // 2 retries before the clean run + 3 retries after = 5 transient messages. + expect(msgs.filter(m => m.startsWith('Process exited unexpectedly'))).toHaveLength(5); + // Exhaustion fires exactly once, on the post-reset budget. + expect(msgs.filter(m => m.includes('failed to start after 3 attempts'))).toHaveLength(1); + }, 15_000); +}); diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index f3ca0eb702..6e6fe689f4 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -17,6 +17,12 @@ import { type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; +import { isUnrecoverableClaudeResumeError } from "./utils/claudeResumeError"; + +// Bound on consecutive automatic relaunches after an unexpected claudeRemote +// failure. Mirrors codex's SAME_THREAD_MAX_RETRIES. Prevents an unforeseen +// persistent failure (e.g. a reject we did not classify) from spinning forever. +const MAX_LAUNCH_RETRIES = 3; interface PermissionsField { date: number; @@ -273,6 +279,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { } | null = null; let previousSessionId: string | null = null; + let launchRetryCount = 0; while (!this.exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); @@ -340,8 +347,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { return null; }, - onSessionFound: (sessionId) => { - session.onSessionFound(sessionId); + onSessionFound: (sessionId, extras) => { + session.onSessionFound(sessionId, extras); }, onThinkingChange: session.onThinkingChange, claudeEnvVars: session.claudeEnvVars, @@ -372,6 +379,10 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { session.consumeOneTimeFlags(); + // Clean run (the session ended normally and we loop to await + // the next message): forget any prior error-retry budget. + launchRetryCount = 0; + if (!this.exitReason && controller.signal.aborted) { session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } @@ -379,6 +390,25 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { logger.debug('[remote]: launch error', e); if (!this.exitReason) { const detail = e instanceof Error ? e.message : String(e); + + // Unrecoverable failures (e.g. the resume target is held open + // by a live agent) can never be fixed by re-running identical + // args; the live-agent path normally forks instead, but this is + // the second line of defense. A retry budget bounds any + // unforeseen persistent failure so we never spin forever. + const unrecoverable = isUnrecoverableClaudeResumeError(e); + const budgetExhausted = launchRetryCount >= MAX_LAUNCH_RETRIES; + if (unrecoverable || budgetExhausted) { + const reason = unrecoverable + ? `Cannot resume session: ${detail}` + : `Session failed to start after ${MAX_LAUNCH_RETRIES} attempts: ${detail}`; + logger.debug(`[remote]: launch unrecoverable (unrecoverable=${unrecoverable}, retries=${launchRetryCount}); stopping`); + session.client.sendSessionEvent({ type: 'message', message: reason }); + this.exitReason = 'exit'; + break; + } + + launchRetryCount += 1; session.client.sendSessionEvent({ type: 'message', message: `Process exited unexpectedly: ${detail}` }); continue; } diff --git a/cli/src/claude/sdk/query.test.ts b/cli/src/claude/sdk/query.test.ts index 7286024959..50cb142875 100644 --- a/cli/src/claude/sdk/query.test.ts +++ b/cli/src/claude/sdk/query.test.ts @@ -79,6 +79,38 @@ describe('Query', () => { await expect(result.next()).rejects.toThrow('prompt failed') }) + it('passes --resume and --fork-session to the spawned claude process', async () => { + const child = createFakeChild() + spawnMock.mockReturnValueOnce(child) + process.env.HAPI_CLAUDE_PATH = 'claude' + + const { query } = await import('./query') + const sessionId = '6f0c4551-1111-4222-8333-444455556666' + query({ prompt: 'ping', options: { resume: sessionId, forkSession: true } }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('--resume') + expect(args[args.indexOf('--resume') + 1]).toBe(sessionId) + expect(args).toContain('--fork-session') + // --fork-session must come after --resume so claude treats it as a branch + expect(args.indexOf('--fork-session')).toBeGreaterThan(args.indexOf('--resume')) + }) + + it('omits --fork-session when forkSession is not set', async () => { + const child = createFakeChild() + spawnMock.mockReturnValueOnce(child) + process.env.HAPI_CLAUDE_PATH = 'claude' + + const { query } = await import('./query') + query({ prompt: 'ping', options: { resume: 'some-session-id' } }) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toContain('--resume') + expect(args).not.toContain('--fork-session') + }) + it('fails fast after cleanup timeout when prompt cleanup hangs', async () => { const child = createFakeChild() spawnMock.mockReturnValueOnce(child) diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 246fb32afc..317f6d43a8 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -309,6 +309,7 @@ export function query(config: { permissionMode = 'default', continue: continueConversation, resume, + forkSession, model, effort, fallbackModel, @@ -341,6 +342,7 @@ export function query(config: { } if (continueConversation) args.push('--continue') if (resume) args.push('--resume', resume) + if (forkSession) args.push('--fork-session') if (settingsPath) args.push('--settings', settingsPath) if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(',')) if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(',')) diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index 5af54833e0..564376485a 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -179,6 +179,13 @@ export interface QueryOptions { permissionMode?: ClaudePermissionMode continue?: boolean resume?: string + /** + * When resuming (`resume`/`continue`), branch off a copy with a new session + * ID instead of taking over the existing one. Required when the target + * session is still held open by a running Claude agent, where a plain + * `--resume` is rejected with "currently running as a background agent". + */ + forkSession?: boolean model?: string effort?: string fallbackModel?: string diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 2106d312fa..2c057307a7 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -53,8 +53,9 @@ export class Session extends AgentSessionBase { mode: opts.mode, sessionLabel: 'Session', sessionIdLabel: 'Claude Code', - applySessionIdToMetadata: (metadata, sessionId) => ({ + applySessionIdToMetadata: (metadata, sessionId, extras) => ({ ...metadata, + ...extras, claudeSessionId: sessionId }), permissionMode: opts.permissionMode, diff --git a/cli/src/claude/utils/claudeResumeError.test.ts b/cli/src/claude/utils/claudeResumeError.test.ts new file mode 100644 index 0000000000..1bf287f2ff --- /dev/null +++ b/cli/src/claude/utils/claudeResumeError.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { isUnrecoverableClaudeResumeError } from './claudeResumeError' + +describe('isUnrecoverableClaudeResumeError', () => { + it('classifies the "running as a background agent" rejection as unrecoverable', () => { + const error = new Error( + 'Session 6f0c4551 is currently running as a background agent (bg). ' + + 'Use claude agents to find and attach to it, or add --fork-session to branch off a copy.' + ) + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('classifies the interactive variant as unrecoverable', () => { + const error = new Error('Session abc is currently running as an interactive agent.') + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('matches the --fork-session hint regardless of surrounding wording', () => { + const error = new Error('add --fork-session to branch off a copy') + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('is case-insensitive', () => { + const error = new Error('SESSION X IS CURRENTLY RUNNING AS A BACKGROUND AGENT') + expect(isUnrecoverableClaudeResumeError(error)).toBe(true) + }) + + it('treats a transient process-exit error as recoverable (retryable)', () => { + const error = new Error('Claude Code process exited with code 1') + expect(isUnrecoverableClaudeResumeError(error)).toBe(false) + }) + + it('treats a spawn failure as recoverable (retryable)', () => { + const error = new Error('Failed to spawn Claude Code process: ENOENT') + expect(isUnrecoverableClaudeResumeError(error)).toBe(false) + }) + + it('handles non-Error values without throwing', () => { + expect(isUnrecoverableClaudeResumeError('currently running as a background agent')).toBe(true) + expect(isUnrecoverableClaudeResumeError(undefined)).toBe(false) + expect(isUnrecoverableClaudeResumeError(null)).toBe(false) + }) +}) diff --git a/cli/src/claude/utils/claudeResumeError.ts b/cli/src/claude/utils/claudeResumeError.ts new file mode 100644 index 0000000000..ad5500919f --- /dev/null +++ b/cli/src/claude/utils/claudeResumeError.ts @@ -0,0 +1,33 @@ +/** + * Classify Claude launch failures so the remote launcher can tell apart + * transient errors (worth retrying) from unrecoverable ones (retrying can only + * loop forever). + * + * The motivating case: resuming a session that is still held open by a running + * agent makes claude exit 1 with + * "Session is currently running as a background agent (bg). Use claude + * agents to find and attach to it, or add --fork-session to branch off a + * copy." + * Retrying that verbatim never succeeds, so it must stop the retry loop. + * + * Matching is intentionally loose (substrings, not exact text) so it keeps + * working if claude's wording drifts across versions; the launcher also caps + * total retries as a second line of defense (see MAX_LAUNCH_RETRIES). + */ + +const UNRECOVERABLE_RESUME_MESSAGE_PATTERNS: string[] = [ + 'currently running as a background agent', + 'currently running as an interactive', + '--fork-session to branch off', + 'is currently running' +] + +/** + * True when `error`'s message indicates the resume target is occupied / the + * request was rejected in a way that re-running identical args cannot fix. + */ +export function isUnrecoverableClaudeResumeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + const lower = message.toLowerCase() + return UNRECOVERABLE_RESUME_MESSAGE_PATTERNS.some((pattern) => lower.includes(pattern)) +} diff --git a/cli/src/claude/utils/getLiveAgentKind.test.ts b/cli/src/claude/utils/getLiveAgentKind.test.ts new file mode 100644 index 0000000000..549412b46e --- /dev/null +++ b/cli/src/claude/utils/getLiveAgentKind.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process') + return { + ...actual, + execFileSync: execFileSyncMock + } +}) + +vi.mock('@/claude/sdk/utils', () => ({ + getDefaultClaudeCodePath: () => 'claude' +})) + +vi.mock('@/utils/bunRuntime', () => ({ + withBunRuntimeEnv: (env: NodeJS.ProcessEnv) => env +})) + +const SESSION_ID = '6f0c4551-1111-4222-8333-444455556666' + +function rosterJson(entries: Array>): string { + return JSON.stringify(entries) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('getLiveAgentKind', () => { + it('returns "background" when the session is alive as a background agent', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 1, kind: 'background', sessionId: SESSION_ID } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBe('background') + }) + + it('returns "interactive" when the session is alive as an interactive agent', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 2, kind: 'interactive', sessionId: SESSION_ID } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBe('interactive') + }) + + it('returns null when the session is not in the roster (dead -> resume directly)', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 3, kind: 'background', sessionId: 'some-other-session' } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null when the roster is empty', async () => { + execFileSyncMock.mockReturnValue(rosterJson([])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null when "claude agents --json" fails (command unavailable / timeout)', async () => { + execFileSyncMock.mockImplementation(() => { + throw new Error('claude: command not found') + }) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null when the output is not valid JSON', async () => { + execFileSyncMock.mockReturnValue('not json at all') + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBeNull() + }) + + it('returns null for an empty sessionId without querying the roster', async () => { + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind('')).toBeNull() + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('treats an in-roster session with an unknown kind as held open (fork)', async () => { + execFileSyncMock.mockReturnValue(rosterJson([ + { pid: 4, kind: 'something-new', sessionId: SESSION_ID } + ])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + expect(getLiveAgentKind(SESSION_ID)).toBe('background') + }) + + it('passes "agents --json" to the resolved claude executable', async () => { + execFileSyncMock.mockReturnValue(rosterJson([])) + const { getLiveAgentKind } = await import('./getLiveAgentKind') + getLiveAgentKind(SESSION_ID) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + const [command, args] = execFileSyncMock.mock.calls[0] + expect(command).toBe('claude') + expect(args).toEqual(['agents', '--json']) + }) +}) diff --git a/cli/src/claude/utils/getLiveAgentKind.ts b/cli/src/claude/utils/getLiveAgentKind.ts new file mode 100644 index 0000000000..32564c134b --- /dev/null +++ b/cli/src/claude/utils/getLiveAgentKind.ts @@ -0,0 +1,89 @@ +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { logger } from '@/ui/logger' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { getDefaultClaudeCodePath } from '@/claude/sdk/utils' + +/** + * Live agent kind reported by the local Claude daemon roster. + * + * A Claude session can be in one of three states from HAPI's point of view: + * - dead (only a `.jsonl` transcript on disk) -> can be resumed directly + * - alive as a background agent -> `--resume` is rejected; must `--fork-session` + * - alive as an interactive agent -> same, must `--fork-session` + * + * `claudeCheckSession` only answers "does a resumable transcript exist"; it + * cannot tell whether the session is currently held open by a running agent. + */ +export type LiveAgentKind = 'background' | 'interactive' + +const AGENTS_QUERY_TIMEOUT_MS = 5_000 + +interface ClaudeAgentEntry { + sessionId?: unknown + kind?: unknown +} + +/** + * Determine whether `sessionId` is currently held open by a running Claude + * agent (background or interactive), per the local daemon roster + * (`claude agents --json`). + * + * Returns the agent `kind` when the session is alive, or `null` when it is not + * in the roster, the command is unavailable / times out, or the output cannot + * be parsed. Returning `null` is the safe degradation: the caller treats the + * session as dead and resumes it directly (current behavior), so this never + * blocks the resume path. + */ +export function getLiveAgentKind(sessionId: string): LiveAgentKind | null { + if (!sessionId) { + return null + } + + let raw: string + try { + raw = execFileSync(getDefaultClaudeCodePath(), ['agents', '--json'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: homedir(), + timeout: AGENTS_QUERY_TIMEOUT_MS, + env: withBunRuntimeEnv(process.env, { allowBunBeBun: false }), + shell: process.platform === 'win32', + windowsHide: process.platform === 'win32' + }) + } catch (e) { + // Command missing, daemon down, timeout, non-zero exit, etc. + // Degrade to "treat as dead" so the resume path is never blocked. + logger.debug('[getLiveAgentKind] failed to query claude agents', e) + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (e) { + logger.debug('[getLiveAgentKind] failed to parse claude agents --json output', e) + return null + } + + if (!Array.isArray(parsed)) { + return null + } + + for (const entry of parsed as ClaudeAgentEntry[]) { + if (!entry || typeof entry !== 'object') { + continue + } + if (entry.sessionId !== sessionId) { + continue + } + if (entry.kind === 'background' || entry.kind === 'interactive') { + return entry.kind + } + // Session is in the roster but with an unknown/foreign kind: it is still + // held open, so fork rather than risk an occupied-session resume. + return 'background' + } + + return null +} diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index a02e23ed6a..1b7b1174aa 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -105,6 +105,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch session.sendUserMessage(converted.userMessage); } if (converted?.message) { + if (converted.message.type === 'token_count') { + session.recordCodexUsage(converted.message); + } session.sendAgentMessage(converted.message); } } diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 28e931a166..07459e30bb 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -26,6 +26,10 @@ const harness = vi.hoisted(() => ({ suppressGoalNotifications: false, suppressTurnCompletion: false, remainingThreadSystemErrors: 0, + transcriptPathByThreadId: new Map(), + scannerStarts: [] as Array<{ transcriptPath: string | null; replayExistingEvents?: boolean }>, + scannerCleanups: 0, + scannerEvents: [] as Array<(event: unknown) => void>, startTurnMessages: [] as string[], failResumeThreadIds: [] as string[], nextThreadSystemErrorMessage: null as string | null, @@ -803,6 +807,30 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ } })); +vi.mock('@/modules/common/codexSessions', () => ({ + findCodexSessionFile: async (threadId: string) => harness.transcriptPathByThreadId.get(threadId) ?? `/tmp/${threadId}.jsonl` +})); + +vi.mock('./utils/codexSessionScanner', () => ({ + createCodexSessionScanner: async (opts: { + transcriptPath: string | null; + replayExistingEvents?: boolean; + onEvent: (event: unknown) => void; + }) => { + harness.scannerStarts.push({ + transcriptPath: opts.transcriptPath, + replayExistingEvents: opts.replayExistingEvents + }); + harness.scannerEvents.push(opts.onEvent); + return { + cleanup: async () => { + harness.scannerCleanups += 1; + }, + setTranscriptPath: async () => {} + }; + } +})); + import { codexRemoteLauncher } from './codexRemoteLauncher'; type FakeAgentState = { @@ -832,6 +860,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; const codexMessages: unknown[] = []; const summaryMessages: unknown[] = []; + const usagePayloads: unknown[] = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; const resetThreadCalls: string[] = []; @@ -911,6 +940,9 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat }, sendUserMessage(text: string) { client.sendUserMessage(text); + }, + recordCodexUsage(payload: unknown) { + usagePayloads.push(payload); } }; @@ -929,7 +961,8 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat getModel: () => currentModel, getCollaborationMode: () => currentCollaborationMode, collaborationModes, - getAgentState: () => agentState + getAgentState: () => agentState, + usagePayloads }; } @@ -988,6 +1021,10 @@ describe('codexRemoteLauncher', () => { harness.emitCompletedChildTurnBeforeSuppressedParent = false; harness.emitTurnAbortedOnInterrupt = false; harness.bridgeOptions = []; + harness.transcriptPathByThreadId = new Map(); + harness.scannerStarts = []; + harness.scannerCleanups = 0; + harness.scannerEvents = []; }); it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => { @@ -2256,4 +2293,48 @@ describe('codexRemoteLauncher', () => { message: '/compact does not accept arguments' }); }); + + it('tails remote Codex transcript for usage without replaying transcript messages', async () => { + harness.transcriptPathByThreadId.set('thread-1', '/tmp/codex-thread-1.jsonl'); + const { session, codexMessages, usagePayloads } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.scannerStarts).toEqual([{ + transcriptPath: '/tmp/codex-thread-1.jsonl', + replayExistingEvents: true + }]); + + harness.scannerEvents[0]?.({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { total_tokens: 42000 }, + model_context_window: 128000 + } + } + }); + harness.scannerEvents[0]?.({ + type: 'event_msg', + payload: { + type: 'agent_message', + message: 'transcript duplicate' + } + }); + + expect(usagePayloads).toHaveLength(1); + expect(usagePayloads[0]).toMatchObject({ + type: 'token_count', + info: { + total_token_usage: { total_tokens: 42000 }, + model_context_window: 128000 + } + }); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + message: 'transcript duplicate' + })); + expect(harness.scannerCleanups).toBe(1); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2cb80fe17f..728a586803 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -15,11 +15,14 @@ import type { EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; import { detectImageMimeType, registerGeneratedImage } from '@/modules/common/generatedImages'; +import { convertCodexEvent } from './utils/codexEventConverter'; +import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; +import { findCodexSessionFile } from '@/modules/common/codexSessions'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -185,6 +188,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { private currentThreadId: string | null = null; private currentTurnId: string | null = null; private readonly activeChildTurns = new Map(); + private usageScanner: CodexSessionScanner | null = null; + private usageScannerThreadId: string | null = null; + private usageScannerSetup: Promise | null = null; + private shuttingDown = false; constructor(session: CodexSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -275,6 +282,104 @@ class CodexRemoteLauncher extends RemoteLauncherBase { await this.handleAbort(); } + private async ensureUsageScanner(threadId: string): Promise { + if (this.usageScannerThreadId === threadId && (this.usageScanner || this.usageScannerSetup)) { + return this.usageScannerSetup ?? Promise.resolve(); + } + + const setupTask = this.replaceUsageScanner(threadId); + this.usageScannerSetup = setupTask.finally(() => { + if (this.usageScannerSetup === setupTask) { + this.usageScannerSetup = null; + } + }); + return this.usageScannerSetup; + } + + private async replaceUsageScanner(threadId: string): Promise { + const previousScanner = this.usageScanner; + this.usageScanner = null; + this.usageScannerThreadId = threadId; + if (previousScanner) { + await previousScanner.cleanup(); + } + + const transcriptPath = await this.findTranscriptWithRetry(threadId); + if (this.shuttingDown || this.usageScannerThreadId !== threadId) { + return; + } + if (!transcriptPath) { + logger.debug(`[Codex] No transcript found for remote thread ${threadId}; usage unavailable`); + return; + } + + const scanner = await createCodexSessionScanner({ + transcriptPath, + replayExistingEvents: true, + onEvent: (event) => { + const converted = convertCodexEvent(event); + if (converted?.message?.type === 'token_count') { + this.session.recordCodexUsage(converted.message); + } + } + }); + if (this.shuttingDown || this.usageScannerThreadId !== threadId) { + await scanner.cleanup(); + return; + } + this.usageScanner = scanner; + } + + private async findTranscriptWithRetry(threadId: string): Promise { + const attempts = 6; + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (this.shuttingDown || this.usageScannerThreadId !== threadId) { + return null; + } + try { + const transcriptPath = await findCodexSessionFile(threadId); + if (transcriptPath) { + return transcriptPath; + } + } catch (error) { + logger.debug(`[Codex] Failed to find transcript for remote thread ${threadId}:`, error); + return null; + } + if (attempt < attempts - 1) { + await this.sleep(250); + } + } + return null; + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); + } + + private async cleanupUsageScanner(): Promise { + if (this.usageScannerSetup) { + try { + await this.usageScannerSetup; + } catch (error) { + logger.debug('[Codex] Remote usage scanner setup failed during cleanup:', error); + } + } + this.shuttingDown = true; + const scanner = this.usageScanner; + this.usageScanner = null; + this.usageScannerThreadId = null; + if (scanner) { + try { + await scanner.cleanup(); + } catch (error) { + logger.debug('[Codex] Remote usage scanner cleanup failed:', error); + } + } + } + public async launch(): Promise { if (this.session.codexArgs && this.session.codexArgs.length > 0) { if (hasCodexCliOverrides(this.session.codexCliOverrides)) { @@ -1972,6 +2077,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!this.currentThreadId || this.currentThreadId === threadId) { this.currentThreadId = threadId; session.onSessionFound(threadId); + void this.ensureUsageScanner(threadId).catch((error) => { + logger.debug(`[Codex] Failed to start remote usage scanner for ${threadId}:`, error); + }); } else { logger.debug( `[Codex] Ignoring thread_started for non-active thread; ` + @@ -2270,6 +2378,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (msgType === 'token_count') { const threadId = eventThreadId ?? this.currentThreadId; + session.recordCodexUsage(msg); session.sendAgentMessage({ ...addCodexEventScope(msg, 'parent', threadId), id: randomUUID() @@ -3041,6 +3150,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { this.currentThreadId = threadId; session.onSessionFound(threadId); + void this.ensureUsageScanner(threadId).catch((error) => { + logger.debug(`[Codex] Failed to start remote usage scanner for ${threadId}:`, error); + }); hasThread = true; } else { if (!this.currentThreadId) { @@ -3162,6 +3274,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { protected async cleanup(): Promise { logger.debug('[codex-remote]: cleanup start'); this.appServerClient.setStderrHandler(null); + await this.cleanupUsageScanner(); try { await this.appServerClient.disconnect(); } catch (error) { diff --git a/cli/src/codex/importHistory.test.ts b/cli/src/codex/importHistory.test.ts new file mode 100644 index 0000000000..33f398ee50 --- /dev/null +++ b/cli/src/codex/importHistory.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { importCodexSessionHistory } from './importHistory'; +import type { ApiSessionClient } from '@/lib'; +import type { Metadata } from '@hapi/protocol'; + +describe('importCodexSessionHistory', () => { + const originalCodexHome = process.env.CODEX_HOME; + let codexHome: string; + + beforeEach(async () => { + codexHome = join(tmpdir(), `hapi-codex-history-${Date.now()}-${Math.random().toString(16).slice(2)}`); + process.env.CODEX_HOME = codexHome; + await mkdir(join(codexHome, 'sessions', '2026', '04', '27'), { recursive: true }); + }); + + afterEach(async () => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await rm(codexHome, { recursive: true, force: true }); + }); + + it('imports user and agent messages from the matching Codex transcript', async () => { + const transcriptPath = join(codexHome, 'sessions', '2026', '04', '27', 'session.jsonl'); + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-1' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old prompt' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old answer' } }) + ].join('\n') + '\n' + ); + await writeFile( + join(codexHome, 'session_index.jsonl'), + `${JSON.stringify({ id: 'thread-1', thread_name: 'codex generated title', updated_at: '2026-04-27T00:00:00.000Z' })}\n` + ); + + const userMessages: string[] = []; + const agentMessages: unknown[] = []; + const updateMetadata = vi.fn(); + const session = { + updateMetadata, + sendUserMessage: (message: string) => userMessages.push(message), + sendAgentMessage: (message: unknown) => agentMessages.push(message), + } as unknown as ApiSessionClient; + + const result = await importCodexSessionHistory({ + session, + codexSessionId: 'thread-1', + }); + + expect(result).toEqual({ imported: 2, filePath: transcriptPath }); + expect(updateMetadata).toHaveBeenCalledTimes(2); + const metadata = updateMetadata.mock.calls.reduce( + (current, call) => call[0](current), + { path: '/repo', host: 'test' } + ); + expect(metadata).toMatchObject({ + codexSessionId: 'thread-1', + summary: { text: 'codex generated title' } + }); + expect(userMessages).toEqual(['old prompt']); + expect(agentMessages).toMatchObject([ + { type: 'message', message: 'old answer' } + ]); + }); + + it('restores Codex session metadata from transcript model, reasoning effort, and latest usage', async () => { + const transcriptPath = join(codexHome, 'sessions', '2026', '04', '27', 'session.jsonl'); + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-usage', model: 'gpt-5.4' } }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'turn_context', + model: 'gpt-5.4', + reasoning_effort: 'high' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + model_context_window: 100_000, + total_token_usage: { + input_tokens: 1000, + cached_input_tokens: 500, + output_tokens: 250, + reasoning_output_tokens: 250, + total_tokens: 2000 + } + }, + rate_limits: { + primary: { + used_percent: 25, + window_minutes: 300 + } + } + } + }) + ].join('\n') + '\n' + ); + + const updateMetadata = vi.fn(); + const applySessionConfig = vi.fn(); + const session = { + updateMetadata, + applySessionConfig, + sendUserMessage: vi.fn(), + sendAgentMessage: vi.fn(), + } as unknown as ApiSessionClient; + + const result = await importCodexSessionHistory({ + session, + codexSessionId: 'thread-usage', + }); + + expect(result).toMatchObject({ + imported: 1, + filePath: transcriptPath, + model: 'gpt-5.4', + modelReasoningEffort: 'high' + }); + expect(applySessionConfig).toHaveBeenCalledWith({ + model: 'gpt-5.4', + modelReasoningEffort: 'high' + }); + const metadata = updateMetadata.mock.calls.reduce( + (current, call) => call[0](current), + { path: '/repo', host: 'test' } + ); + expect(metadata).toMatchObject({ + codexSessionId: 'thread-usage', + codexUsage: { + contextWindow: { + usedTokens: 2000, + limitTokens: 100_000, + percent: 2 + }, + rateLimits: { + fiveHour: { + usedPercent: 25, + windowMinutes: 300 + } + }, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + }); + }); +}); diff --git a/cli/src/codex/importHistory.ts b/cli/src/codex/importHistory.ts new file mode 100644 index 0000000000..8c4c9885e4 --- /dev/null +++ b/cli/src/codex/importHistory.ts @@ -0,0 +1,179 @@ +import { readFile } from 'node:fs/promises'; +import { isAbsolute, relative, resolve } from 'node:path'; +import type { ApiSessionClient } from '@/lib'; +import { findCodexSessionFile, findCodexSessionTitle, formatCodexSessionTitle } from '@/modules/common/codexSessions'; +import { logger } from '@/ui/logger'; +import { convertCodexEvent, type CodexSessionEvent } from './utils/codexEventConverter'; +import { normalizeCodexUsage } from './utils/codexUsage'; + +function isSameOrChild(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} + +type TitleSource = 'index' | 'user' | 'agent'; +type ImportSessionConfig = { + model?: string; + modelReasoningEffort?: string; +}; +type ImportSessionClient = ApiSessionClient & { + applySessionConfig?: (config: ImportSessionConfig) => void; +}; + +function parseCodexSessionEvent(line: string): CodexSessionEvent | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') { + return null; + } + const record = parsed as Record; + if (typeof record.type !== 'string' || record.type.length === 0) { + return null; + } + return { + timestamp: typeof record.timestamp === 'string' ? record.timestamp : undefined, + type: record.type, + payload: record.payload + }; +} + +function extractSessionPath(lines: string[]): string | null { + for (const line of lines.slice(0, 100)) { + if (!line.trim()) continue; + let parsed: unknown; + try { parsed = JSON.parse(line); } catch { continue; } + if (!parsed || typeof parsed !== 'object') continue; + const record = parsed as Record; + if (record.type === 'session_meta') { + const payload = record.payload && typeof record.payload === 'object' + ? record.payload as Record + : null; + const path = (typeof payload?.cwd === 'string' && payload.cwd) || + (typeof payload?.path === 'string' && payload.path) || null; + if (path) return path; + } + } + return null; +} + +export async function importCodexSessionHistory(args: { + session: ImportSessionClient; + codexSessionId: string; + expectedDirectory?: string; +}): Promise<{ imported: number; filePath: string | null; model?: string; modelReasoningEffort?: string }> { + const filePath = await findCodexSessionFile(args.codexSessionId); + if (!filePath) { + logger.debug(`[codex-history-import] No transcript found for Codex session ${args.codexSessionId}`); + return { imported: 0, filePath: null }; + } + + const content = await readFile(filePath, 'utf8'); + + if (args.expectedDirectory) { + const sessionPath = extractSessionPath(content.split('\n')); + if (!sessionPath || !isSameOrChild(args.expectedDirectory, sessionPath)) { + logger.warn( + `[codex-history-import] Rejecting import: session path "${sessionPath ?? '(missing)'}" is outside expected directory "${args.expectedDirectory}"` + ); + return { imported: 0, filePath: null }; + } + } + let imported = 0; + let title = await findCodexSessionTitle(args.codexSessionId); + let titleSource: TitleSource | null = title ? 'index' : null; + let restoredModel: string | undefined; + let restoredModelReasoningEffort: string | undefined; + for (const line of content.split('\n')) { + if (!line.trim()) { + continue; + } + const event = parseCodexSessionEvent(line); + if (!event) { + continue; + } + const converted = convertCodexEvent(event); + if (converted?.sessionId) { + const payload = event.payload && typeof event.payload === 'object' + ? event.payload as Record + : null; + if (typeof payload?.model === 'string' && payload.model.length > 0) { + restoredModel = payload.model; + } + const sessionReasoningEffort = payload?.model_reasoning_effort ?? payload?.modelReasoningEffort ?? payload?.reasoning_effort ?? payload?.reasoningEffort; + if (typeof sessionReasoningEffort === 'string' && sessionReasoningEffort.length > 0) { + restoredModelReasoningEffort = sessionReasoningEffort; + } + args.session.updateMetadata((metadata) => ({ + ...metadata, + codexSessionId: converted.sessionId + })); + } + if (event.type === 'event_msg' && event.payload && typeof event.payload === 'object') { + const payload = event.payload as Record; + if (payload.type === 'turn_context') { + if (typeof payload.model === 'string' && payload.model.length > 0) { + restoredModel = payload.model; + } + const reasoningEffort = payload.reasoning_effort ?? payload.reasoningEffort ?? payload.model_reasoning_effort ?? payload.modelReasoningEffort; + if (typeof reasoningEffort === 'string' && reasoningEffort.length > 0) { + restoredModelReasoningEffort = reasoningEffort; + } + } + } + if (converted?.userMessage) { + const userTitle = formatCodexSessionTitle(converted.userMessage); + if (userTitle && titleSource !== 'index' && titleSource !== 'user') { + title = userTitle; + titleSource = 'user'; + } + args.session.sendUserMessage(converted.userMessage); + imported += 1; + } + if (converted?.message) { + if (converted.message.type === 'token_count') { + const codexUsage = normalizeCodexUsage(converted.message); + if (codexUsage) { + args.session.updateMetadata((metadata) => ({ + ...metadata, + codexUsage + })); + } + } + if (converted.message.type === 'message' && !title) { + title = formatCodexSessionTitle(converted.message.message); + titleSource = 'agent'; + } + args.session.sendAgentMessage(converted.message); + imported += 1; + } + } + + if (title) { + args.session.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: title, + updatedAt: Date.now() + } + })); + } + + const restoredConfig: ImportSessionConfig = { + ...(restoredModel ? { model: restoredModel } : {}), + ...(restoredModelReasoningEffort ? { modelReasoningEffort: restoredModelReasoningEffort } : {}) + }; + if (restoredConfig.model || restoredConfig.modelReasoningEffort) { + args.session.applySessionConfig?.(restoredConfig); + } + + logger.debug(`[codex-history-import] Imported ${imported} messages from ${filePath}`); + return { + imported, + filePath, + ...restoredConfig + }; +} diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index ff60da6b82..c94350120e 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -39,6 +39,7 @@ interface LoopOptions { collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; replayTranscriptHistoryOnStart?: boolean; + importHistory?: boolean; onSessionReady?: (session: CodexSession) => void; } @@ -63,7 +64,8 @@ export async function loop(opts: LoopOptions): Promise { model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, collaborationMode: opts.collaborationMode ?? 'default', - replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false + replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false, + importHistory: opts.importHistory }); await runLocalRemoteSession({ diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index de907c2271..ae233b7894 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -19,6 +19,7 @@ import type { ReasoningEffort } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands'; +import { importCodexSessionHistory } from './importHistory'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -29,6 +30,7 @@ export async function runCodex(opts: { codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; + importHistory?: boolean; model?: string; modelReasoningEffort?: ReasoningEffort; serviceTier?: string; @@ -77,7 +79,7 @@ export async function runCodex(opts: { const sessionWrapperRef: { current: CodexSession | null } = { current: null }; // 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时, // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。 - const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId); + const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId && !opts.importHistory); let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; @@ -101,6 +103,32 @@ export async function runCodex(opts: { registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); + if (opts.importHistory && opts.resumeSessionId) { + try { + const importedHistory = await importCodexSessionHistory({ + session, + codexSessionId: opts.resumeSessionId, + expectedDirectory: workingDirectory + }); + if (!opts.model && importedHistory.model) { + currentModel = importedHistory.model; + } + if ( + !opts.modelReasoningEffort + && importedHistory.modelReasoningEffort + && REASONING_EFFORTS.has(importedHistory.modelReasoningEffort as ReasoningEffort) + ) { + currentModelReasoningEffort = importedHistory.modelReasoningEffort as ReasoningEffort; + } + } catch (error) { + logger.debug('[codex] Failed to import Codex session history:', error); + session.sendAgentMessage({ + type: 'message', + message: `Failed to import Codex session history: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { const sessionInstance = sessionWrapperRef.current; if (!sessionInstance) { @@ -412,6 +440,7 @@ export async function runCodex(opts: { collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, replayTranscriptHistoryOnStart, + importHistory: opts.importHistory, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index c8892419d1..55b4199c83 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -5,6 +5,7 @@ import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { Metadata, SessionModel, SessionModelReasoningEffort } from '@/api/types'; +import { normalizeCodexUsage } from './utils/codexUsage'; type LocalLaunchFailure = { message: string; @@ -40,6 +41,7 @@ export class CodexSession extends AgentSessionBase { modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; replayTranscriptHistoryOnStart?: boolean; + importHistory?: boolean; }) { super({ api: opts.api, @@ -71,8 +73,11 @@ export class CodexSession extends AgentSessionBase { this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; this.collaborationMode = opts.collaborationMode; + this.importHistory = opts.importHistory === true; } + readonly importHistory: boolean; + onTranscriptPathFound(path: string): void { if (this.transcriptPath === path) { return; @@ -127,6 +132,17 @@ export class CodexSession extends AgentSessionBase { this.modelReasoningEffort = modelReasoningEffort; }; + recordCodexUsage = (payload: unknown): void => { + const codexUsage = normalizeCodexUsage(payload); + if (!codexUsage) { + return; + } + this.client.updateMetadata((metadata) => ({ + ...metadata, + codexUsage + })); + }; + setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => { this.collaborationMode = mode; this.pushKeepAlive(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 28bae2be92..dff652e3d7 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -697,7 +697,15 @@ export class AppServerEventConverter { } if (method === 'thread/tokenUsage/updated') { - const info = asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {}; + const info = { + ...(asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {}) + }; + if (info.rate_limits === undefined && info.rateLimits === undefined) { + const rateLimits = paramsRecord.rate_limits ?? paramsRecord.rateLimits; + if (rateLimits !== undefined) { + info.rate_limits = rateLimits; + } + } events.push(scoped({ type: 'token_count', info })); return events; } diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index efd7394cc0..443a2579fe 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -196,10 +196,17 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu } if (eventType === 'token_count') { - const info = asRecord(payloadRecord.info); + const rawInfo = asRecord(payloadRecord.info); + const info = rawInfo ? { ...rawInfo } : null; if (!info) { return null; } + if (info.rate_limits === undefined && info.rateLimits === undefined) { + const rateLimits = payloadRecord.rate_limits ?? payloadRecord.rateLimits; + if (rateLimits !== undefined) { + info.rate_limits = rateLimits; + } + } return { message: { type: 'token_count', diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index d6a7db8032..aac70063ae 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -80,6 +80,30 @@ describe('codexSessionScanner', () => { expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' }); }); + it('can replay existing events on startup', async () => { + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'session-123' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old prompt' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old answer' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + transcriptPath, + replayExistingEvents: true, + onEvent: (event) => events.push(event) + }); + + await wait(300); + expect(events.map((event) => event.payload)).toEqual([ + { id: 'session-123' }, + { type: 'user_message', message: 'old prompt' }, + { type: 'agent_message', message: 'old answer' } + ]); + }); + it('reports session id from the transcript metadata', async () => { await writeFile( transcriptPath, diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 06bd667014..136adf0323 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -5,6 +5,7 @@ import type { CodexSessionEvent } from './codexEventConverter'; interface CodexSessionScannerOptions { transcriptPath: string | null; + replayExistingEvents?: boolean; onEvent: (event: CodexSessionEvent) => void; onSessionId?: (sessionId: string) => void; replayExistingHistory?: boolean; @@ -33,6 +34,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private transcriptPath: string | null; private readonly onEvent: (event: CodexSessionEvent) => void; private readonly onSessionId?: (sessionId: string) => void; + private readonly replayExistingEvents: boolean; private readonly fileEpochByPath = new Map(); private readonly fileSizeByPath = new Map(); private replayExistingHistoryOnNextAttach: boolean; @@ -44,6 +46,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.onEvent = opts.onEvent; this.onSessionId = opts.onSessionId; this.replayExistingHistoryOnNextAttach = opts.replayExistingHistory ?? false; + this.replayExistingEvents = opts.replayExistingEvents === true; } async setTranscriptPath(transcriptPath: string): Promise { @@ -105,6 +108,11 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private async primeTranscript(filePath: string): Promise { const { events, nextCursor } = await this.readSessionFile(filePath, 0); + if (this.replayExistingEvents) { + for (const entry of events) { + this.onEvent(entry.event); + } + } const keys = events.map((entry) => this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex })); this.seedProcessedKeys(keys); this.setCursor(filePath, nextCursor); diff --git a/cli/src/codex/utils/codexUsage.test.ts b/cli/src/codex/utils/codexUsage.test.ts new file mode 100644 index 0000000000..785bfc45e9 --- /dev/null +++ b/cli/src/codex/utils/codexUsage.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeCodexUsage } from './codexUsage'; + +describe('normalizeCodexUsage', () => { + it('parses app-server token usage with context and rate-limit buckets', () => { + const usage = normalizeCodexUsage({ + model_context_window: 200_000, + used_tokens: 35_000, + total_token_usage: { + input_tokens: 10_000, + cached_input_tokens: 20_000, + output_tokens: 3_000, + reasoning_output_tokens: 2_000, + total_tokens: 35_000 + }, + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 200, + output_tokens: 30, + reasoning_output_tokens: 20, + total_tokens: 350 + }, + rate_limits: { + primary: { + used_percent: 42.5, + window_minutes: 300, + resets_in_seconds: 600 + }, + secondary: { + used_percent: 9, + window_minutes: 10080, + reset_at: '2026-04-28T00:00:00.000Z' + } + } + }, { now: 1_000_000 }); + + expect(usage).toMatchObject({ + contextWindow: { + usedTokens: 35_000, + limitTokens: 200_000, + percent: 17.5, + updatedAt: 1_000_000 + }, + rateLimits: { + fiveHour: { + usedPercent: 42.5, + windowMinutes: 300, + resetAt: 1_600_000 + }, + weekly: { + usedPercent: 9, + windowMinutes: 10080, + resetAt: Date.parse('2026-04-28T00:00:00.000Z') + } + }, + totalTokenUsage: { + inputTokens: 10_000, + cachedInputTokens: 20_000, + outputTokens: 3_000, + reasoningOutputTokens: 2_000, + totalTokens: 35_000 + }, + lastTokenUsage: { + inputTokens: 100, + cachedInputTokens: 200, + outputTokens: 30, + reasoningOutputTokens: 20, + totalTokens: 350 + } + }); + }); + + it('parses transcript token_count info with sibling rate limits', () => { + const usage = normalizeCodexUsage({ + info: { + model_context_window: 100_000, + total_token_usage: { + input_tokens: 1000, + cached_input_tokens: 500, + output_tokens: 250, + reasoning_output_tokens: 250, + total_tokens: 2000 + } + }, + rate_limits: { + primary: { + used_percent: 80, + window_minutes: 300 + } + } + }, { now: 2_000_000 }); + + expect(usage?.contextWindow).toMatchObject({ + usedTokens: 2000, + limitTokens: 100_000, + percent: 2 + }); + expect(usage?.rateLimits.fiveHour).toMatchObject({ + usedPercent: 80, + windowMinutes: 300 + }); + }); + + it('uses last token usage for context window when cumulative total exceeds the model window', () => { + const usage = normalizeCodexUsage({ + info: { + model_context_window: 258_400, + total_token_usage: { + input_tokens: 2_767_000, + cached_input_tokens: 2_509_000, + output_tokens: 20_000, + reasoning_output_tokens: 3_000, + total_tokens: 2_787_000 + }, + last_token_usage: { + input_tokens: 75_918, + cached_input_tokens: 46_976, + output_tokens: 542, + reasoning_output_tokens: 52, + total_tokens: 76_460 + } + } + }, { now: 2_000_000 }); + + expect(usage?.contextWindow).toMatchObject({ + usedTokens: 76_460, + limitTokens: 258_400, + percent: (76_460 / 258_400) * 100 + }); + expect(usage?.totalTokenUsage?.totalTokens).toBe(2_787_000); + }); + + it('returns null when no supported usage fields are present', () => { + expect(normalizeCodexUsage({ message: 'hello' })).toBeNull(); + }); + + it('extracts credits + plan metadata for premium-credits accounts with both windows null', () => { + // Shape captured from a live Codex Pro account whose 5h subscription + // window AND topped-up credits are both exhausted (rollout JSONL, + // 2026-06-08). primary/secondary are explicitly null because the + // plan no longer bills by window; the constraint is credits.balance. + const usage = normalizeCodexUsage({ + info: { + model_context_window: 258_400, + total_token_usage: { + input_tokens: 51_733_893, + cached_input_tokens: 50_161_280, + output_tokens: 74_915, + reasoning_output_tokens: 27_228, + total_tokens: 51_808_808 + }, + last_token_usage: { + input_tokens: 206_333, + cached_input_tokens: 205_696, + output_tokens: 41, + reasoning_output_tokens: 0, + total_tokens: 206_374 + } + }, + rate_limits: { + limit_id: 'premium', + limit_name: null, + primary: null, + secondary: null, + credits: { + has_credits: false, + unlimited: false, + balance: '0' + }, + plan_type: null, + rate_limit_reached_type: null + } + }, { now: 3_000_000 }); + + expect(usage?.rateLimits.fiveHour).toBeUndefined(); + expect(usage?.rateLimits.weekly).toBeUndefined(); + expect(usage?.credits).toEqual({ + hasCredits: false, + unlimited: false, + balance: '0' + }); + expect(usage?.limitId).toBe('premium'); + // plan_type and rate_limit_reached_type were null in the captured + // shape - those should drop out instead of surfacing as 'null'. + expect(usage?.planType).toBeUndefined(); + expect(usage?.rateLimitReachedType).toBeUndefined(); + }); + + it('preserves rate_limit_reached_type when codex flags an explicit cap', () => { + const usage = normalizeCodexUsage({ + info: { model_context_window: 100_000 }, + rate_limits: { + limit_id: 'plus', + plan_type: 'plus', + primary: { used_percent: 100, window_minutes: 300 }, + secondary: { used_percent: 100, window_minutes: 10080 }, + credits: null, + rate_limit_reached_type: 'weekly' + } + }); + + expect(usage?.rateLimitReachedType).toBe('weekly'); + expect(usage?.planType).toBe('plus'); + expect(usage?.limitId).toBe('plus'); + expect(usage?.credits).toBeUndefined(); + }); + + it('surfaces a non-blocking unlimited credit balance without exhausting flags', () => { + const usage = normalizeCodexUsage({ + info: { model_context_window: 100_000 }, + rate_limits: { + credits: { has_credits: true, unlimited: true, balance: '0' } + } + }); + + expect(usage?.credits).toEqual({ + hasCredits: true, + unlimited: true, + balance: '0' + }); + }); +}); diff --git a/cli/src/codex/utils/codexUsage.ts b/cli/src/codex/utils/codexUsage.ts new file mode 100644 index 0000000000..633d937b55 --- /dev/null +++ b/cli/src/codex/utils/codexUsage.ts @@ -0,0 +1,250 @@ +import type { CodexTokenUsage, CodexUsage, CodexUsageCredits, CodexUsageRateLimit } from '@hapi/protocol/types'; + +type NormalizerOptions = { + now?: number; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? value as Record : null; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function firstNumber(record: Record | null, keys: string[]): number | null { + if (!record) return null; + for (const key of keys) { + const value = asNumber(record[key]); + if (value !== null) return value; + } + return null; +} + +function normalizeTokenUsage(value: unknown): CodexTokenUsage | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const inputTokens = firstNumber(record, ['input_tokens', 'inputTokens']) ?? 0; + const cachedInputTokens = firstNumber(record, ['cached_input_tokens', 'cachedInputTokens', 'cache_read_input_tokens', 'cacheReadInputTokens']) ?? 0; + const outputTokens = firstNumber(record, ['output_tokens', 'outputTokens']) ?? 0; + const reasoningOutputTokens = firstNumber(record, ['reasoning_output_tokens', 'reasoningOutputTokens']) ?? 0; + const totalTokens = firstNumber(record, ['total_tokens', 'totalTokens']) + ?? inputTokens + cachedInputTokens + outputTokens + reasoningOutputTokens; + + if (inputTokens === 0 && cachedInputTokens === 0 && outputTokens === 0 && reasoningOutputTokens === 0 && totalTokens === 0) { + return undefined; + } + + return { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + }; +} + +function parseResetAt(record: Record, now: number): number | undefined { + const direct = record.reset_at ?? record.resetAt; + if (typeof direct === 'string') { + const parsed = Date.parse(direct); + if (Number.isFinite(parsed)) return parsed; + } + const directNumber = asNumber(direct); + if (directNumber !== null) { + return directNumber < 10_000_000_000 ? directNumber * 1000 : directNumber; + } + + const resetsInSeconds = firstNumber(record, ['resets_in_seconds', 'resetsInSeconds', 'reset_in_seconds', 'resetInSeconds']); + if (resetsInSeconds !== null) { + return now + (resetsInSeconds * 1000); + } + + const resetsInMinutes = firstNumber(record, ['resets_in_minutes', 'resetsInMinutes', 'reset_in_minutes', 'resetInMinutes']); + if (resetsInMinutes !== null) { + return now + (resetsInMinutes * 60_000); + } + + return undefined; +} + +function normalizeRateLimit(value: unknown, now: number): CodexUsageRateLimit | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const usedPercent = firstNumber(record, ['used_percent', 'usedPercent', 'percent', 'usage_percent', 'usagePercent']); + const windowMinutes = firstNumber(record, ['window_minutes', 'windowMinutes', 'window', 'minutes']); + if (usedPercent === null || windowMinutes === null) { + return undefined; + } + + const resetAt = parseResetAt(record, now); + return { + usedPercent, + windowMinutes, + ...(resetAt !== undefined ? { resetAt } : {}) + }; +} + +function collectRateLimitCandidates(value: unknown): unknown[] { + const record = asRecord(value); + if (!record) return []; + + const direct = record.rate_limits ?? record.rateLimits; + const directRecord = asRecord(direct); + if (Array.isArray(direct)) return direct; + if (directRecord) { + return Object.values(directRecord); + } + + if (record.primary || record.secondary) { + return [record.primary, record.secondary]; + } + + return []; +} + +function extractRateLimitsRoot(value: unknown): Record | null { + const record = asRecord(value); + if (!record) return null; + const direct = asRecord(record.rate_limits ?? record.rateLimits); + return direct ?? record; +} + +function normalizeCredits(value: unknown): CodexUsageCredits | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const hasCreditsRaw = record.has_credits ?? record.hasCredits; + const unlimitedRaw = record.unlimited; + const balanceRaw = record.balance; + + const hasCredits = typeof hasCreditsRaw === 'boolean' ? hasCreditsRaw : undefined; + const unlimited = typeof unlimitedRaw === 'boolean' ? unlimitedRaw : undefined; + let balance: string | undefined; + if (typeof balanceRaw === 'string' && balanceRaw.length > 0) { + balance = balanceRaw; + } else if (typeof balanceRaw === 'number' && Number.isFinite(balanceRaw)) { + balance = String(balanceRaw); + } + + if (hasCredits === undefined && unlimited === undefined && balance === undefined) { + return undefined; + } + return { + ...(hasCredits !== undefined ? { hasCredits } : {}), + ...(unlimited !== undefined ? { unlimited } : {}), + ...(balance !== undefined ? { balance } : {}) + }; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function unwrapUsagePayload(value: unknown): Record | null { + const record = asRecord(value); + if (!record) return null; + + const info = asRecord(record.info); + if (info) { + return { + ...record, + ...info, + rate_limits: info.rate_limits ?? info.rateLimits ?? record.rate_limits ?? record.rateLimits + }; + } + + const tokenUsage = asRecord(record.tokenUsage ?? record.token_usage); + if (tokenUsage) { + return { + ...record, + ...tokenUsage, + rate_limits: tokenUsage.rate_limits ?? tokenUsage.rateLimits ?? record.rate_limits ?? record.rateLimits + }; + } + + return record; +} + +export function normalizeCodexUsage(value: unknown, options: NormalizerOptions = {}): CodexUsage | null { + const now = options.now ?? Date.now(); + const record = unwrapUsagePayload(value); + if (!record) return null; + + const totalTokenUsage = normalizeTokenUsage(record.total_token_usage ?? record.totalTokenUsage ?? record.total_usage ?? record.totalUsage); + const lastTokenUsage = normalizeTokenUsage(record.last_token_usage ?? record.lastTokenUsage ?? record.last_usage ?? record.lastUsage); + const contextLimit = firstNumber(record, ['model_context_window', 'modelContextWindow', 'context_window', 'contextWindow']); + const explicitContextUsed = firstNumber(record, ['context_window_used_tokens', 'contextWindowUsedTokens', 'used_tokens', 'usedTokens']); + const cumulativeTotal = totalTokenUsage?.totalTokens + ?? firstNumber(asRecord(record.total_token_usage ?? record.totalTokenUsage), ['total_tokens', 'totalTokens']); + const cumulativeFitsContext = cumulativeTotal !== undefined + && cumulativeTotal !== null + && contextLimit !== null + && cumulativeTotal <= contextLimit + ? cumulativeTotal + : null; + const contextUsed = explicitContextUsed + ?? lastTokenUsage?.totalTokens + ?? firstNumber(asRecord(record.last_token_usage ?? record.lastTokenUsage), ['total_tokens', 'totalTokens']) + ?? cumulativeFitsContext; + + const rateLimits: CodexUsage['rateLimits'] = {}; + for (const candidate of collectRateLimitCandidates(record)) { + const bucket = normalizeRateLimit(candidate, now); + if (!bucket) continue; + if (bucket.windowMinutes === 300) { + rateLimits.fiveHour = bucket; + } else if (bucket.windowMinutes === 10080) { + rateLimits.weekly = bucket; + } + } + + const rateLimitsRoot = extractRateLimitsRoot(record); + const credits = normalizeCredits(rateLimitsRoot?.credits); + const rateLimitReachedType = asNonEmptyString( + rateLimitsRoot?.rate_limit_reached_type ?? rateLimitsRoot?.rateLimitReachedType + ); + const planType = asNonEmptyString(rateLimitsRoot?.plan_type ?? rateLimitsRoot?.planType); + const limitId = asNonEmptyString(rateLimitsRoot?.limit_id ?? rateLimitsRoot?.limitId); + + const contextWindow = contextLimit !== null && contextLimit > 0 && contextUsed !== null + ? { + usedTokens: contextUsed, + limitTokens: contextLimit, + percent: Math.min(100, Math.max(0, (contextUsed / contextLimit) * 100)), + updatedAt: now + } + : undefined; + + if ( + !contextWindow + && !totalTokenUsage + && !lastTokenUsage + && !rateLimits.fiveHour + && !rateLimits.weekly + && !credits + && !rateLimitReachedType + ) { + return null; + } + + return { + ...(contextWindow ? { contextWindow } : {}), + rateLimits, + ...(credits ? { credits } : {}), + ...(rateLimitReachedType ? { rateLimitReachedType } : {}), + ...(planType ? { planType } : {}), + ...(limitId ? { limitId } : {}), + ...(totalTokenUsage ? { totalTokenUsage } : {}), + ...(lastTokenUsage ? { lastTokenUsage } : {}) + }; +} diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index 7e04fc33b6..4e35eb5824 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -107,6 +107,17 @@ describe('codexCommand', () => { } }) + it('parses the internal history import flag', async () => { + await codexCommand.run(createCommandContext(['resume', 'session-123', '--hapi-import-history', '--started-by', 'runner'])) + + expect(runCodexMock).toHaveBeenCalledWith({ + resumeSessionId: 'session-123', + importHistory: true, + startedBy: 'runner' + }) + }) + + it('prints the upgrade error and exits when the local version check fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 196adc9457..0387e6f82e 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -44,6 +44,7 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + importHistory?: boolean model?: string modelReasoningEffort?: ReasoningEffort serviceTier?: string @@ -93,6 +94,8 @@ export const codexCommand: CommandDefinition = { throw new Error('Missing --service-tier value') } options.serviceTier = parseServiceTier(tier) + } else if (arg === '--hapi-import-history') { + options.importHistory = true } else { unknownArgs.push(arg) } diff --git a/cli/src/modules/common/codexSessions.test.ts b/cli/src/modules/common/codexSessions.test.ts new file mode 100644 index 0000000000..f6579cc060 --- /dev/null +++ b/cli/src/modules/common/codexSessions.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, writeFile, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { findCodexSessionTitle, listCodexSessions } from './codexSessions' + +describe('listCodexSessions', () => { + const originalCodexHome = process.env.CODEX_HOME + let codexHome: string + + beforeEach(async () => { + codexHome = join(tmpdir(), `hapi-codex-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`) + process.env.CODEX_HOME = codexHome + await mkdir(join(codexHome, 'sessions'), { recursive: true }) + }) + + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + return + } + process.env.CODEX_HOME = originalCodexHome + }) + + it('lists codex sessions and hides old entries by default', async () => { + const sessionsDir = join(codexHome, 'sessions') + const recentPath = join(sessionsDir, 'recent.jsonl') + const oldPath = join(sessionsDir, 'old.jsonl') + + await writeFile( + recentPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-recent', cwd: '/repo/recent', model: 'gpt-5' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'agent reply should not win' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'user session title' } }) + ].join('\n') + '\n' + ) + await writeFile( + join(codexHome, 'session_index.jsonl'), + `${JSON.stringify({ id: 'thread-recent', thread_name: 'codex generated title', updated_at: '2026-04-27T00:00:00.000Z' })}\n` + ) + await writeFile(oldPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-old', cwd: '/repo/old', model: 'gpt-5' } })}\n`) + + const oldDate = new Date(Date.now() - (190 * 24 * 60 * 60 * 1000)) + await utimes(oldPath, oldDate, oldDate) + + const recentOnly = await listCodexSessions() + expect(recentOnly.sessions.map((entry) => entry.id)).toEqual(['thread-recent']) + expect(recentOnly.sessions[0]?.title).toBe('codex generated title') + + const withOld = await listCodexSessions({ includeOld: true }) + expect(withOld.sessions.map((entry) => entry.id)).toEqual(['thread-recent', 'thread-old']) + expect(withOld.sessions[1]?.isOld).toBe(true) + }) + + it('supports cursor pagination', async () => { + const sessionsDir = join(codexHome, 'sessions') + for (let i = 0; i < 3; i++) { + const sessionPath = join(sessionsDir, `s-${i}.jsonl`) + await writeFile(sessionPath, `${JSON.stringify({ type: 'session_meta', payload: { id: `thread-${i}` } })}\n`) + } + + const page1 = await listCodexSessions({ includeOld: true, limit: 2 }) + expect(page1.sessions.length).toBe(2) + expect(page1.nextCursor).toBe('2') + + const page2 = await listCodexSessions({ includeOld: true, limit: 2, cursor: page1.nextCursor ?? undefined }) + expect(page2.sessions.length).toBe(1) + expect(page2.nextCursor).toBeNull() + }) + + it('uses transcript thread names before first user message', async () => { + const sessionPath = join(codexHome, 'sessions', 'named.jsonl') + await writeFile( + sessionPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-named', cwd: '/repo/named', model: 'gpt-5' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'long first user message' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'thread_name_updated', thread_id: 'thread-named', thread_name: 'short generated title' } }) + ].join('\n') + '\n' + ) + + const result = await listCodexSessions({ includeOld: true }) + expect(result.sessions[0]?.title).toBe('short generated title') + await expect(findCodexSessionTitle('thread-named')).resolves.toBe('short generated title') + }) + + it('falls back to the first user message when no generated title exists', async () => { + const sessionPath = join(codexHome, 'sessions', 'untitled.jsonl') + await writeFile( + sessionPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-untitled', cwd: '/repo/untitled', model: 'gpt-5' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'agent reply should not win' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'first user message fallback' } }) + ].join('\n') + '\n' + ) + + const result = await listCodexSessions({ includeOld: true }) + expect(result.sessions[0]?.title).toBe('first user message fallback') + await expect(findCodexSessionTitle('thread-untitled')).resolves.toBe('first user message fallback') + }) +}) diff --git a/cli/src/modules/common/codexSessions.ts b/cli/src/modules/common/codexSessions.ts new file mode 100644 index 0000000000..b3598721a7 --- /dev/null +++ b/cli/src/modules/common/codexSessions.ts @@ -0,0 +1,474 @@ +import { homedir } from 'node:os'; +import { basename, extname, join } from 'node:path'; +import { promises as fs, type Dirent, type Stats } from 'node:fs'; + +export interface CodexSessionSummary { + id: string; + title: string; + updatedAt: number; + path: string | null; + model: string | null; + isOld: boolean; +} + +export interface ListCodexSessionsRequest { + includeOld?: boolean; + olderThanDays?: number; + limit?: number; + cursor?: string; +} + +export interface ListCodexSessionsResponse { + success: boolean; + sessions?: CodexSessionSummary[]; + nextCursor?: string | null; + error?: string; +} + +type RawSession = { + id: string; + title: string; + titleSource: TitleSource; + updatedAt: number; + path: string | null; + model: string | null; +}; + +type IndexedSessionTitle = { + title: string; + updatedAt: number; +}; + +type SqliteDatabaseConstructor = new (path: string, options?: { readonly?: boolean }) => { + query: (sql: string) => { all: () => unknown[] }; + close: (throwOnError?: boolean) => void; +}; + +type TitleSource = 'generated' | 'user' | 'agent' | 'fallback'; + +export function formatCodexSessionTitle(text: string): string | null { + const title = text.replace(/\s+/g, ' ').trim(); + return title.length > 0 ? title.slice(0, 80) : null; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + return value as Record; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readCodexHome(): string { + return process.env.CODEX_HOME ?? join(homedir(), '.codex'); +} + +function parseIndexUpdatedAt(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +async function walkJsonlFiles(rootDir: string, maxFiles: number): Promise { + const queue: string[] = [rootDir]; + const files: string[] = []; + + while (queue.length > 0 && files.length < maxFiles) { + const current = queue.shift(); + if (!current) { + break; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + if (entry.isFile() && extname(entry.name) === '.jsonl') { + files.push(fullPath); + if (files.length >= maxFiles) { + break; + } + } + } + } + + return files; +} + +async function readJsonlLines(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf8'); + return content.split('\n').filter((line) => line.trim().length > 0); + } catch { + return null; + } +} + +function titleSourcePriority(source: TitleSource | undefined): number { + switch (source) { + case 'generated': + return 3; + case 'user': + return 2; + case 'agent': + return 1; + default: + return 0; + } +} + +function parseSessionLine(line: string): { id?: string; title?: string; titleSource?: TitleSource; path?: string; model?: string } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + + const record = asRecord(parsed); + if (!record) { + return null; + } + + const type = asNonEmptyString(record.type); + const payload = asRecord(record.payload); + + if (type === 'session_meta') { + const id = asNonEmptyString(payload?.id) ?? asNonEmptyString(record.id); + const path = asNonEmptyString(payload?.cwd) ?? asNonEmptyString(payload?.path); + const model = asNonEmptyString(payload?.model); + return { id: id ?? undefined, path: path ?? undefined, model: model ?? undefined }; + } + + if (type === 'event_msg') { + const messageType = asNonEmptyString(payload?.type); + if (messageType === 'thread_name_updated') { + const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id); + const text = asNonEmptyString(payload?.thread_name) ?? asNonEmptyString(payload?.threadName) ?? asNonEmptyString(payload?.title); + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { id: id ?? undefined, title, titleSource: 'generated' }; + } + } + + if (messageType === 'user_message') { + const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text) ?? asNonEmptyString(payload?.content); + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { title, titleSource: 'user' }; + } + } + + if (messageType === 'agent_message') { + const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text); + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { title, titleSource: 'agent' }; + } + } + + if (messageType === 'thread_started') { + const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id); + return id ? { id } : null; + } + } + + return null; +} + +function parseSessionIndexLine(line: string): { id: string; title: string; updatedAt: number } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + + const record = asRecord(parsed); + if (!record) { + return null; + } + + const id = asNonEmptyString(record.id) ?? asNonEmptyString(record.session_id) ?? asNonEmptyString(record.sessionId); + const title = asNonEmptyString(record.thread_name) ?? asNonEmptyString(record.title) ?? asNonEmptyString(record.name); + if (!id || !title) { + return null; + } + + return { + id, + title: formatCodexSessionTitle(title) ?? title, + updatedAt: parseIndexUpdatedAt(record.updated_at ?? record.updatedAt ?? record.ts) + }; +} + +async function readCodexSessionIndex(): Promise> { + const lines = await readJsonlLines(join(readCodexHome(), 'session_index.jsonl')); + const titles = new Map(); + if (!lines) { + return titles; + } + + for (const line of lines) { + const parsed = parseSessionIndexLine(line); + if (!parsed) { + continue; + } + const existing = titles.get(parsed.id); + if (!existing || existing.updatedAt <= parsed.updatedAt) { + titles.set(parsed.id, { + title: parsed.title, + updatedAt: parsed.updatedAt + }); + } + } + return titles; +} + +async function loadBunSqliteDatabase(): Promise { + try { + const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<{ Database: SqliteDatabaseConstructor }>; + return (await dynamicImport('bun:sqlite')).Database; + } catch { + return null; + } +} + +async function readCodexThreadTitles(): Promise> { + const titles = new Map(); + const Database = await loadBunSqliteDatabase(); + if (!Database) { + return titles; + } + + let db: InstanceType | null = null; + try { + db = new Database(join(readCodexHome(), 'state_5.sqlite'), { readonly: true }); + const rows = db.query(` + SELECT id, title, updated_at, updated_at_ms + FROM threads + WHERE title IS NOT NULL AND title != '' + `).all() as Array<{ id: unknown; title: unknown; updated_at: unknown; updated_at_ms: unknown }>; + + for (const row of rows) { + const id = asNonEmptyString(row.id); + const title = asNonEmptyString(row.title); + if (!id || !title) { + continue; + } + titles.set(id, { + title: formatCodexSessionTitle(title) ?? title, + updatedAt: parseIndexUpdatedAt(row.updated_at_ms ?? row.updated_at) + }); + } + } catch { + return titles; + } finally { + db?.close(false); + } + return titles; +} + +async function parseSessionFile(filePath: string): Promise { + let stat: Stats; + let lines: string[] | null; + try { + [stat, lines] = await Promise.all([ + fs.stat(filePath), + readJsonlLines(filePath) + ]); + } catch { + return null; + } + if (!lines) { + return null; + } + + let id: string | null = null; + let title: string | null = null; + let titleSource: TitleSource = 'fallback'; + let path: string | null = null; + let model: string | null = null; + + for (const line of lines.slice(0, 400)) { + const parsed = parseSessionLine(line); + if (!parsed) { + continue; + } + if (!id && parsed.id) { + id = parsed.id; + } + if (parsed.title && (!title || titleSourcePriority(parsed.titleSource) > titleSourcePriority(titleSource))) { + title = parsed.title; + titleSource = parsed.titleSource ?? titleSource; + } + if (!path && parsed.path) { + path = parsed.path; + } + if (!model && parsed.model) { + model = parsed.model; + } + } + + const fallbackId = basename(filePath, extname(filePath)); + const resolvedId = id ?? fallbackId; + if (!resolvedId) { + return null; + } + + return { + id: resolvedId, + title: title ?? resolvedId, + titleSource: title ? titleSource : 'fallback', + updatedAt: Math.max(0, Math.floor(stat.mtimeMs)), + path, + model + }; +} + +export async function findCodexSessionFile(sessionId: string): Promise { + if (!sessionId.trim()) { + return null; + } + + const sessionsDir = join(readCodexHome(), 'sessions'); + const files = await walkJsonlFiles(sessionsDir, 5000); + for (const filePath of files) { + const lines = await readJsonlLines(filePath); + if (!lines) { + continue; + } + for (const line of lines.slice(0, 400)) { + const parsed = parseSessionLine(line); + if (parsed?.id === sessionId) { + return filePath; + } + } + } + return null; +} + +export async function findCodexSessionPath(sessionId: string): Promise { + const filePath = await findCodexSessionFile(sessionId); + if (!filePath) return null; + const lines = await readJsonlLines(filePath); + if (!lines) return null; + for (const line of lines.slice(0, 100)) { + const parsed = parseSessionLine(line); + if (parsed?.path) return parsed.path; + } + return null; +} + +export async function findCodexSessionTitle(sessionId: string): Promise { + if (!sessionId.trim()) { + return null; + } + + const indexedTitle = (await readCodexSessionIndex()).get(sessionId)?.title; + if (indexedTitle) { + return indexedTitle; + } + + const sessionFile = await findCodexSessionFile(sessionId); + const parsedSession = sessionFile ? await parseSessionFile(sessionFile) : null; + if (parsedSession?.titleSource === 'generated') { + return parsedSession.title; + } + + const threadTitle = (await readCodexThreadTitles()).get(sessionId)?.title; + if (threadTitle) { + return threadTitle; + } + + return parsedSession?.title ?? null; +} + +export async function listCodexSessions( + request: ListCodexSessionsRequest = {}, + pathAllowed?: (path: string | null) => boolean | Promise +): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> { + const includeOld = request.includeOld === true; + const olderThanDays = Number.isFinite(request.olderThanDays) && (request.olderThanDays ?? 0) > 0 + ? Number(request.olderThanDays) + : 180; + const limit = Number.isFinite(request.limit) && (request.limit ?? 0) > 0 + ? Math.min(100, Math.floor(Number(request.limit))) + : 50; + const offset = Number.isFinite(Number(request.cursor)) && Number(request.cursor) >= 0 + ? Math.floor(Number(request.cursor)) + : 0; + + const sessionsDir = join(readCodexHome(), 'sessions'); + const [files, indexedTitles, threadTitles] = await Promise.all([ + walkJsonlFiles(sessionsDir, 5000), + readCodexSessionIndex(), + readCodexThreadTitles() + ]); + const raw = (await Promise.all(files.map((filePath) => parseSessionFile(filePath)))) + .filter((entry): entry is RawSession => entry !== null); + + const deduped = new Map(); + for (const entry of raw) { + const existing = deduped.get(entry.id); + if (!existing || existing.updatedAt < entry.updatedAt) { + deduped.set(entry.id, entry); + } + } + + const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; + const sorted = Array.from(deduped.values()) + .sort((a, b) => b.updatedAt - a.updatedAt) + .map((entry) => ({ + id: entry.id, + title: indexedTitles.get(entry.id)?.title + ?? (entry.titleSource === 'generated' ? entry.title : undefined) + ?? threadTitles.get(entry.id)?.title + ?? entry.title, + updatedAt: entry.updatedAt, + path: entry.path, + model: entry.model, + isOld: entry.updatedAt < cutoff + })); + + const ageFiltered = includeOld ? sorted : sorted.filter((entry) => !entry.isOld); + + // Apply workspace-root scoping when the machine runs with --workspace-root. + // Sessions whose `path` is outside the allowed roots are dropped so the web + // picker never exposes projects from other workspaces. Sessions with a null + // path are also excluded when a filter is active. + const filtered = pathAllowed + ? (await Promise.all(ageFiltered.map(async (entry) => ({ + entry, + allowed: await pathAllowed(entry.path ?? null) + })))).filter(({ allowed }) => allowed).map(({ entry }) => entry) + : ageFiltered; + + const sliced = filtered.slice(offset, offset + limit); + const nextOffset = offset + sliced.length; + + return { + sessions: sliced, + nextCursor: nextOffset < filtered.length ? String(nextOffset) : null + }; +} diff --git a/cli/src/modules/common/handlers/codexSessions.ts b/cli/src/modules/common/handlers/codexSessions.ts new file mode 100644 index 0000000000..4bdb10314a --- /dev/null +++ b/cli/src/modules/common/handlers/codexSessions.ts @@ -0,0 +1,29 @@ +import { logger } from '@/ui/logger'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listCodexSessions, + type ListCodexSessionsRequest, + type ListCodexSessionsResponse +} from '../codexSessions'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerCodexSessionHandlers( + rpcHandlerManager: RpcHandlerManager, + pathAllowed?: (path: string | null) => boolean | Promise +): void { + rpcHandlerManager.registerHandler('listCodexSessions', async (data) => { + logger.debug('List Codex sessions request'); + + try { + const result = await listCodexSessions(data ?? {}, pathAllowed); + return { + success: true, + sessions: result.sessions, + nextCursor: result.nextCursor + }; + } catch (error) { + logger.debug('Failed to list Codex sessions:', error); + return rpcError(getErrorMessage(error, 'Failed to list Codex sessions')); + } + }); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index b555593af6..c7d6103a9b 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' +import { registerCodexSessionHandlers } from './handlers/codexSessions' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' import { registerDirectoryHandlers } from './handlers/directories' @@ -12,9 +13,14 @@ import { registerSlashCommandHandlers } from './handlers/slashCommands' import { registerSkillsHandlers } from './handlers/skills' import { registerUploadHandlers } from './handlers/uploads' -export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { +export function registerCommonHandlers( + rpcHandlerManager: RpcHandlerManager, + workingDirectory: string, + options: { codexSessionPathAllowed?: (path: string | null) => boolean | Promise } = {} +): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) + registerCodexSessionHandlers(rpcHandlerManager, options.codexSessionPathAllowed) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 11c5f07686..5b7f0fa8d7 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -5,6 +5,7 @@ export interface SpawnSessionOptions { directory: string sessionId?: string resumeSessionId?: string + importHistory?: boolean approvedNewDirectoryCreation?: boolean agent?: AgentFlavor model?: string diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts index 3532265af2..daae54de08 100644 --- a/cli/src/pi/schemas.ts +++ b/cli/src/pi/schemas.ts @@ -17,20 +17,30 @@ import type { PiModelSummary } from '@hapi/protocol/apiTypes'; // 字段级容错 schema // ============================================================================ -/** 提取 string 值,非 string 返回 undefined */ -const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); +/** 提取 string 值,非 string 返回 undefined(字段 optional 时省略) */ +const asOptStr = z.preprocess( + (v) => (typeof v === 'string' ? v : undefined), + z.union([z.string(), z.undefined()]), +); /** 提取 number 值,非 number 返回 undefined */ -const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); +const asOptNum = z.preprocess( + (v) => (typeof v === 'number' ? v : undefined), + z.union([z.number(), z.undefined()]), +); /** 提取 boolean 值,非 boolean 返回 undefined */ -const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); +const asOptBool = z.preprocess( + (v) => (typeof v === 'boolean' ? v : undefined), + z.union([z.boolean(), z.undefined()]), +); /** 提取 string 值,非 string 返回指定默认值 */ -const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); +const asStrOrDef = (def: string) => + z.union([z.string(), z.undefined(), z.null()]).transform(v => typeof v === 'string' ? v : def); /** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ -const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { +const asOptThinkingLevelMap = z.preprocess((v): Record | undefined => { if (typeof v !== 'object' || v === null) return undefined; const map: Record = {}; for (const [key, val] of Object.entries(v as Record)) { @@ -38,7 +48,7 @@ const asOptThinkingLevelMap = z.unknown().transform((v): Record 0 ? map : undefined; -}); +}, z.union([z.record(z.string(), z.union([z.string(), z.null()])), z.undefined()])); // ============================================================================ // Pi Agent Event (stdin JSONL → event) @@ -78,12 +88,11 @@ const PiCommandSummarySchema = z.object({ /** 单条 command 的容错 schema:非法字段静默修正,空 name 返回 null */ const PiCommandEntrySchema = z.object({ - name: asStrOrDef(''), + name: asStrOrDef('').default(''), description: asOptStr, - source: z.unknown().transform(v => - VALID_COMMAND_SOURCES.includes(v as PiCommandSource) - ? (v as PiCommandSource) - : ('skill' as const), + source: z.preprocess( + (v) => (VALID_COMMAND_SOURCES.includes(v as PiCommandSource) ? v : 'skill'), + z.enum(VALID_COMMAND_SOURCES), ), }).passthrough().transform((c) => { if (!c.name) return null; @@ -110,8 +119,8 @@ const PiCommandsResponseDataSchema = z.object({ /** 单条 model 的容错 schema:非法字段静默丢弃,空 id 返回 null */ const PiModelEntrySchema = z.object({ - id: asStrOrDef(''), - provider: asStrOrDef('unknown'), + id: asStrOrDef('').default(''), + provider: asStrOrDef('unknown').default('unknown'), name: asOptStr, contextWindow: asOptNum, reasoning: asOptBool, @@ -195,9 +204,11 @@ export const PiAssistantMessageEventSchema = z.object({ // ============================================================================ export function parsePiCommands(data: unknown) { - return PiCommandsResponseDataSchema.safeParse(data).data ?? []; + const result = PiCommandsResponseDataSchema.safeParse(data); + return result.success ? result.data : []; } export function parsePiModels(data: unknown) { - return PiModelsResponseDataSchema.safeParse(data).data ?? []; + const result = PiModelsResponseDataSchema.safeParse(data); + return result.success ? result.data : []; } diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index fb6d764895..052ac289e8 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -138,4 +138,14 @@ describe('buildCliArgs', () => { expect(args).toContain('--effort') expect(args).toContain('high') }) + + it('adds Codex history import flag only for Codex resume', () => { + const args = buildCliArgs('codex', { + directory: '/tmp', + resumeSessionId: 'thread-1', + importHistory: true, + }) + + expect(args).toEqual(expect.arrayContaining(['codex', 'resume', 'thread-1', '--hapi-import-history'])) + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 694ad7197b..1c6c60cb14 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -244,28 +244,39 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): logger.debug(`[RUNNER RUN] Resolved session awaiter for PID ${pid}`); } } else if (!existingSession) { - // No tracked session for this PID. Two possibilities: + // No tracked session for this PID. Three possibilities: // 1. The child was spawned externally from a terminal (legitimate). // 2. The child was runner-spawned but already had its tracking // entry removed because its webhook arrived after the timeout - // (orphaned / ghost-session case). + // (orphaned / same-runner-instance ghost-session case). The + // timeout handler should have already tree-killed the PID, so + // the process is dead by now. + // 3. The child was runner-spawned by a PRIOR runner instance and + // survived a runner restart because the runner systemd unit + // uses `KillMode=process` (per docs/guide/installation.md + // installer; closes #915 / closes #929). The PID is alive, + // legitimately wants to be re-attached. // - // Differentiate via the webhook's own `startedBy` field: genuine - // terminal-launched children report `startedBy: 'terminal'`, so - // anything claiming `'runner'` here must be the second case and - // should be ignored + terminated instead of silently promoted. + // Differentiate cases 2 and 3 via process liveness: dead PID is + // case 2 (no-op, the timeout already handled it); alive PID is + // case 3 (adopt and re-attach). if (sessionMetadata.startedBy === 'runner') { + if (isProcessAlive(pid)) { + const adoptedSession: TrackedSession = { + startedBy: 'runner', + happySessionId: sessionId, + happySessionMetadataFromLocalWebhook: sessionMetadata, + pid + }; + pidToTrackedSession.set(pid, adoptedSession); + logger.debug( + `[RUNNER RUN] Adopting orphaned runner-spawned session ${sessionId} (PID ${pid}). Likely a survivor of a prior runner restart.` + ); + return; + } logger.debug( - `[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.` + `[RUNNER RUN] Ignoring late webhook from dead orphaned runner-spawned PID ${pid} (session ${sessionId}). Process already gone.` ); - // Use killProcess (SIGTERM → SIGKILL escalation) rather than a - // bare process.kill() so the orphan is reliably reaped even if - // it ignores SIGTERM. We don't have a ChildProcess reference - // here (tracking entry was already removed by the timeout - // handler), so tree-kill via killProcessByChildProcess is not - // available — but the timeout handler should have already - // tree-killed the process group; this is defence-in-depth. - void killProcess(pid); return; } @@ -669,8 +680,8 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } } - logger.debug(`[RUNNER RUN] Session ${sessionId} not found`); - return false; + logger.debug(`[RUNNER RUN] Session ${sessionId} not found (already stopped)`); + return true; }; // Handle child process exit @@ -1090,6 +1101,9 @@ export function buildCliArgs( if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); + if (options.importHistory) { + args.push('--hapi-import-history'); + } } else if (agent === 'cursor') { args.push('--resume', options.resumeSessionId); } else if (agent === 'pi') { diff --git a/cli/src/runner/runner.integration.test.ts b/cli/src/runner/runner.integration.test.ts index 9d3f1547c4..fe8754f405 100644 --- a/cli/src/runner/runner.integration.test.ts +++ b/cli/src/runner/runner.integration.test.ts @@ -176,9 +176,13 @@ describe.skipIf(!await isServerHealthy())('Runner Integration Tests', { timeout: const sessions = await listRunnerSessions(); expect(sessions).toHaveLength(sessionCount); - // Stop all sessions - const stopResults = await Promise.all(sessionIds.map(sessionId => stopRunnerSession(sessionId))); - expect(stopResults.every(r => r), 'Not all sessions reported stopped').toBe(true); + // Stop serially — parallel /stop-session under load races the control + // server and flakes with intermittent HTTP failures on busy hosts. + const stopResults: boolean[] = [] + for (const sessionId of sessionIds) { + stopResults.push(await stopRunnerSession(sessionId)) + } + expect(stopResults.every(r => r), 'Not all sessions reported stopped').toBe(true) // Verify all sessions are stopped const emptySessions = await listRunnerSessions(); diff --git a/cli/src/ui/logger.test.ts b/cli/src/ui/logger.test.ts new file mode 100644 index 0000000000..48a7ef9205 --- /dev/null +++ b/cli/src/ui/logger.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { readFileSync, rmSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const tmpDirs: string[] = [] + +function freshLogPath(): string { + const dir = mkdtempSync(join(tmpdir(), 'hapi-logger-test-')) + tmpDirs.push(dir) + return join(dir, 'test.log') +} + +afterEach(() => { + while (tmpDirs.length) { + const dir = tmpDirs.pop()! + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // best effort + } + } +}) + +describe('logger Error serialization', () => { + it('logs an Error argument with its message and stack, not "{}"', async () => { + const { Logger } = await import('./logger') + const logPath = freshLogPath() + const logger = new Logger(logPath) + + const error = new Error('Session 6f0c4551 is currently running as a background agent (bg).') + logger.debug('[remote]: launch error', error) + + const contents = readFileSync(logPath, 'utf8') + expect(contents).toContain('is currently running as a background agent') + expect(contents).toContain('"name"') + expect(contents).toContain('"stack"') + // The whole point: the old `JSON.stringify(error)` collapsed to "{}". + expect(contents).not.toContain('[remote]: launch error {}') + }) + + it('surfaces Errors nested inside objects via debugLargeJson', async () => { + const prevDebug = process.env.DEBUG + process.env.DEBUG = '1' + try { + const { Logger } = await import('./logger') + const logPath = freshLogPath() + const logger = new Logger(logPath) + + logger.debugLargeJson('[remote]: payload', { + cause: new Error('boom failure detail') + }) + + const contents = readFileSync(logPath, 'utf8') + expect(contents).toContain('boom failure detail') + expect(contents).toContain('"message"') + } finally { + if (prevDebug === undefined) { + delete process.env.DEBUG + } else { + process.env.DEBUG = prevDebug + } + } + }) +}) diff --git a/cli/src/ui/logger.ts b/cli/src/ui/logger.ts index 3ebb34d802..b9c4bc7e2a 100644 --- a/cli/src/ui/logger.ts +++ b/cli/src/ui/logger.ts @@ -12,6 +12,25 @@ import { existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' import { readRunnerState } from '@/persistence' +/** + * Convert a value into something JSON-serializable for logs. + * + * `JSON.stringify(new Error(...))` returns `"{}"` because Error's own + * properties are non-enumerable, which silently swallowed real failures + * (e.g. "response stream error {}"). Surface name/message/stack instead so + * the true cause reaches the logs and, downstream, the web UI. + */ +function serializeLogArg(arg: unknown): unknown { + if (arg instanceof Error) { + return { + name: arg.name, + message: arg.message, + stack: arg.stack + } + } + return arg +} + /** * Consistent date/time formatting functions */ @@ -44,7 +63,7 @@ function getSessionLogPath(): string { return join(configuration.logsDir, filename) } -class Logger { +export class Logger { private dangerouslyUnencryptedServerLoggingUrl: string | undefined constructor( @@ -89,6 +108,10 @@ class Logger { // Some of our messages are huge, but we still want to show them in the logs const truncateStrings = (obj: unknown): unknown => { + if (obj instanceof Error) { + return truncateStrings(serializeLogArg(obj)) + } + if (typeof obj === 'string') { return obj.length > maxStringLength ? obj.substring(0, maxStringLength) + '... [truncated for logs]' @@ -187,8 +210,8 @@ class Logger { body: JSON.stringify({ timestamp: new Date().toISOString(), level, - message: `${message} ${args.map(a => - typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a) + message: `${message} ${args.map(a => + typeof a === 'object' ? JSON.stringify(serializeLogArg(a), null, 2) : String(a) ).join(' ')}`, source: 'cli', platform: process.platform @@ -200,8 +223,8 @@ class Logger { } private logToFile(prefix: string, message: string, ...args: unknown[]): void { - const logLine = `${prefix} ${message} ${args.map(arg => - typeof arg === 'string' ? arg : JSON.stringify(arg) + const logLine = `${prefix} ${message} ${args.map(arg => + typeof arg === 'string' ? arg : JSON.stringify(serializeLogArg(arg)) ).join(' ')}\n` // Send to remote server if configured diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md new file mode 100644 index 0000000000..589a50dd9e --- /dev/null +++ b/docs/api/native-companion-contract.md @@ -0,0 +1,102 @@ +# Native companion API contract (phone + Wear) + +**Audience:** Implementers of native companion apps (Android phone + Wear OS, iOS, etc.) that pair with a hapi hub via FCM. + +**Auth:** Same JWT as the web client (`POST /api/bind` → `Authorization: Bearer`). + +## Scope + +A companion implementing this contract is a **native client to the same hub the PWA talks to**, surfacing notifications and reply / approve actions on a phone or wearable. Hub topology is unchanged - the hub still runs on the operator's dev machine. + +--- + +## Device registration (FCM) + +### Register + +`POST /api/devices/register` + +```json +{ + "token": "", + "platform": "phone", + "deviceId": "" +} +``` + +`platform`: `"phone"` | `"wear"` + +**Response:** `{ "ok": true }` + +Upsert on `(namespace, deviceId, platform)` - same device re-registering replaces the FCM token. + +### Unregister + +`DELETE /api/devices/register` + +```json +{ + "token": "" +} +``` + +--- + +## Outbound push (hub → device) + +Hub sends FCM HTTP v1 whenever a notification event is emitted for a +namespace with registered native devices and FCM is configured. The native +companion is treated as the canonical wrist-first surface, so FCM fires +**unconditionally** (independent of whether a PWA tab happens to be +foreground / visible via SSE) - that's deliberate, see +`FcmNotificationChannel.deliver()`. Web Push is suppressed for the same +namespace to avoid duplicate OS notifications. + +### Data payload (all platforms) + +| Key | Example | Purpose | +|-----|---------|---------| +| `type` | `ready` | `ready`, `permission-request`, `task-notification` | +| `sessionId` | uuid | Target session | +| `sessionName` | string | Display name (`agent - project`) | +| `url` | `/sessions/{id}` | Deep link path | +| `requestId` | uuid | Permission only - approve/deny | +| `title` | string | Notification title | +| `body` | string | Notification body | +| `severity` | `info` | `info` (ready), `warning` (permission), `success` / `error` (task) | +| `notifySummary` | JSON string | Optional: parsed `AGENT_NOTIFY_SUMMARY` line from agent text | + +Native apps **must** handle `data` for Wear; notification block is for display. + +### Client actions (native - not hub) + +| User action | Hub API | +|-------------|---------| +| Send text | `POST /api/sessions/:id/messages` `{ "text": "...", "localId": "..." }` | +| Allow | `POST /api/sessions/:id/permissions/:requestId/approve` | +| Deny | `POST /api/sessions/:id/permissions/:requestId/deny` | + +`sentFrom` extension (optional future): `android-phone`, `android-wear`. + +--- + +## Environment (hub operator) + +```bash +FCM_SERVICE_ACCOUNT_PATH=/path/to/service-account.json +FCM_PROJECT_ID=your-firebase-project-id +``` + +When unset, hub skips FCM channel (Web Push / Telegram unchanged). + +The native push channel is **opt-in**: operators who don't run a companion +app see no behavior change. When at least one device is registered for a +namespace, the existing Web Push channel suppresses its fallback for that +namespace to avoid double-notifying (one in the native app, one from the +PWA service worker). PWA-only operators are unaffected. + +--- + +## Versioning + +Contract version **1**. Breaking changes require `data.contractVersion` in FCM payload and doc update. diff --git a/docs/tooling/mermaid-lightbox-dogfood.md b/docs/tooling/mermaid-lightbox-dogfood.md new file mode 100644 index 0000000000..081f07e4f3 --- /dev/null +++ b/docs/tooling/mermaid-lightbox-dogfood.md @@ -0,0 +1,56 @@ +# Mermaid lightbox dogfood (Playwright) + +Two Playwright targets: + +| Target | What it exercises | Command | +|--------|-------------------|---------| +| **Component (Vite)** | `MermaidDiagram` in isolation on dev server | `npm run test:mermaid-lightbox:playwright` | +| **Live session (hub)** | Real chat thread, click-to-zoom | `npm run test:mermaid-lightbox:live` | + +## Live session (production-shaped) + +**Session URL (after seed):** + +`{HAPI_URL}/sessions/a7370000-0000-4000-8000-000000000737` + +Default `HAPI_URL` for live tests: `http://127.0.0.1:3006` (daily driver). +For tailnet: `HAPI_URL=https://hapi.tail9944ee.ts.net` (seed **that** hub's DB first). + +### 1. Seed fixtures (hub DB) + +On the machine that owns `HAPI_DB_PATH` (usually `~/.hapi/hapi.db`): + +```bash +bun run seed:mermaid-lightbox:session +``` + +Inserts 15 assistant messages (one per diagram type). Re-run to replace messages in that session. + +### 2. Deploy web with your branch + +```bash +hapi-driver-rebuild --build-web +# activate soup when ready (restarts hub) +``` + +Hard-refresh the browser after web changes. + +### 3. Run live Playwright + +```bash +HAPI_LIVE=1 HAPI_URL=http://127.0.0.1:3006 npm run test:mermaid-lightbox:live +``` + +Requires `~/.hapi/settings.json` `cliApiToken` (or `HAPI_ACCESS_TOKEN`). + +**Pass criteria:** dialog opens, SVG in **shadow root** (`[data-mermaid-lightbox]`), expands vs inline, sequence has multiple actors/lines. + +If tests report `legacy` or `empty` lightbox, the served web bundle predates the shadow-DOM fix — rebuild driver. + +## Isolation page (not chat) + +Only for component regression; **not** the same as chat: + +`http://127.0.0.1:5173/mermaid-lightbox-e2e.html?case=sequence` (Vite dev, not on tailnet dist unless you add the HTML to a build). + +Diagram sources: `web/src/dev/mermaid-lightbox-cases.ts` diff --git a/e2e/events-debug.spec.ts b/e2e/events-debug.spec.ts new file mode 100644 index 0000000000..09ecc5596c --- /dev/null +++ b/e2e/events-debug.spec.ts @@ -0,0 +1,53 @@ +/* + * Playwright smoke for EventsDebugControls (#22). Uses the isolated vite + * fixture (no hub auth). After hapi-driver-rebuild, the same panel lives + * at Settings → Voice → Advanced on :3006. + */ + +import { test, expect } from '@playwright/test' + +test.describe('events debug viewer e2e', () => { + test('expands, loads fixture events, refresh works', async ({ page }) => { + await page.goto('/e2e-fixtures/events-debug-fixture.html') + await expect(page.getByTestId('events-debug-fixture')).toBeVisible() + + const toggle = page.getByRole('button', { name: 'Overseer events (debug)' }) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(page.getByText('Substrate smoke event')).toBeVisible() + await expect(page.getByText('1 rows')).toBeVisible() + + await page.evaluate(() => { + window.__eventsDebugE2E!.setEvents([ + { + id: 2, + ts: Date.UTC(2026, 5, 19, 13, 0, 0), + sourceKind: 'hub', + sourceRef: null, + eventType: 'stale', + attentionCandidate: 1, + summary: 'Refreshed row', + provenance: 'session_end_fallback', + relatedSessionId: 'sess-smoke-02', + payloadJson: null, + severity: null, + }, + ]) + }) + + await page.getByRole('button', { name: 'Refresh' }).click() + await expect(page.getByText('Refreshed row')).toBeVisible() + await expect(page.getByText('attention')).toBeVisible() + }) + + test('shows error state when fetch fails', async ({ page }) => { + await page.goto('/e2e-fixtures/events-debug-fixture.html') + await page.evaluate(() => window.__eventsDebugE2E!.setError('boom')) + + const toggle = page.getByRole('button', { name: 'Overseer events (debug)' }) + await toggle.click() + await expect(page.getByText('fixture fetch failed')).toBeVisible() + }) +}) diff --git a/e2e/inbox-debug.spec.ts b/e2e/inbox-debug.spec.ts new file mode 100644 index 0000000000..e0154981bb --- /dev/null +++ b/e2e/inbox-debug.spec.ts @@ -0,0 +1,35 @@ +/* + * Playwright smoke for InboxDebugControls (#23). + */ + +import { test, expect } from '@playwright/test' + +test.describe('inbox debug viewer e2e', () => { + test('expands, loads fixture items, action buttons work', async ({ page }) => { + await page.goto('/e2e-fixtures/inbox-debug-fixture.html') + await expect(page.getByTestId('inbox-debug-fixture')).toBeVisible() + + const toggle = page.getByRole('button', { name: 'Attention inbox (debug)' }) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(page.getByText('CI auth failed on push')).toBeVisible() + await expect(page.getByText('feat: inbox substrate')).toBeVisible() + await expect(page.getByText('1 items')).toBeVisible() + await expect(page.getByText('BLOCKED tier')).toBeVisible() + + await page.getByRole('button', { name: 'done', exact: true }).click() + await page.getByRole('button', { name: 'Refresh' }).click() + await expect(page.getByText('1 items')).toBeVisible() + }) + + test('shows error state when fetch fails', async ({ page }) => { + await page.goto('/e2e-fixtures/inbox-debug-fixture.html') + await page.evaluate(() => window.__inboxDebugE2E!.setError('boom')) + + const toggle = page.getByRole('button', { name: 'Attention inbox (debug)' }) + await toggle.click() + await expect(page.getByText('fixture fetch failed')).toBeVisible() + }) +}) diff --git a/hub/src/cursor/cursorImporter.ts b/hub/src/cursor/cursorImporter.ts new file mode 100644 index 0000000000..d17557babb --- /dev/null +++ b/hub/src/cursor/cursorImporter.ts @@ -0,0 +1,701 @@ +/** + * Hub-side importer for cursor chats discovered on the local + * `~/.cursor/{chats,acp-sessions}` filesystem. + * + * Companion module to the cursor flavor of the multi-agent import picker + * (`hub/src/web/routes/cursorImport.ts`). The legacy → ACP transplant + * primitive shipped upstream in `tiann/hapi#844` + * (`hub/src/cursor/cursorLegacyMigrator.ts`), but that primitive operates on + * an existing HAPI session row that already references the cursor uuid. + * For the IMPORT flow there is no pre-existing HAPI row yet — and the + * spec's strict refusal contract forbids creating one until the + * verify-probe has passed. This module therefore reuses the verify-probe + * + transplant pattern in standalone form, mirroring the per-chat code + * path of `scripts/audit-cursor-acp-verify.ts` (which is committed at + * branch HEAD and ran 391/391 = 100% pass on the maintainer's + * real-world chat library before this code shipped). + * + * Refusal contract (strict ACP-only): + * - The cursor flavor is STRICTLY ACP-only. Verify must pass before + * any HAPI row is created. No fallback to stream-json, ever. + * - Refusal cases (mirrored in `CursorImportRefusalReason`): + * verify_load_failed, missing_on_disk_store, target_already_exists, + * already_imported, agent_binary_not_found, verify_timeout, + * corrupted_store, ambiguous_legacy_store, internal_error + * - On refusal: legacy `store.db` is untouched, no HAPI row is created, + * structured error returned to the caller. + * + * Discovery covers two on-disk shapes: + * - legacy: `~/.cursor/chats///store.db` + * - acp: `~/.cursor/acp-sessions//{store.db, meta.json}` + * + * Imports of `legacy` rows transplant to the ACP location via the same + * cp + meta.json + verify dance the migrator uses. Imports of `acp` rows + * are no-ops on disk — just a HAPI row pointing at the existing dir. + */ + +import { Database } from 'bun:sqlite' +import { randomUUID, createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { homedir, hostname, platform, tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AcpVerifyProbe, type AcpProbeOptions } from './acpVerifyProbe' +import { listLegacyChatStoreCandidates, readLegacyMetaLastUsedModel } from './cursorLegacyMigrator' +import type { + CursorImportableSessionSummary, + CursorImportRefusalReason, + CursorImportRowOutcome, + CursorImportSourceFormat +} from '../web/routes/_agentImport/types' +import type { Store } from '../store' +import type { SyncEngine } from '../sync/syncEngine' + +// UUID-ish basename validation: same rule the migrator uses to refuse +// path-traversal in `//store.db`. Importer-facing +// uuids must pass this gate too. See `cursorLegacyMigrator` +// CURSOR_SESSION_ID_RE. +const CURSOR_SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/ + +const AUTH_FILES = ['cli-config.json', 'agent-cli-state.json', 'acp-config.json'] +const DEFAULT_INIT_TIMEOUT_MS = 20_000 +const DEFAULT_LOAD_TIMEOUT_MS = 30_000 +const DEFAULT_REPLAY_DRAIN_MS = 1_500 +const DEFAULT_VERIFY_TIMEOUT_MS = 60_000 + +const DEFAULT_LIST_LIMIT = 500 + +export interface CursorImporterDeps { + /** Resolve the operator's HOME dir. Override in tests. */ + homeDir?: () => string + /** Recorded hostname (recorded into HAPI session metadata.host). */ + hostName?: () => string + /** Where to allocate the verify staging temp dir. Default: os.tmpdir(). */ + tmpDir?: () => string + /** Time source for telemetry. Default: Date.now. */ + now?: () => number + /** + * Spawn factory for the verify probe. Override in tests to inject a + * mock. The second arg is the operator's $HOME so the probe can + * resolve `agent` under `/.local/bin` even on service-account + * hub deployments. Mirrors the migrator's `createProbe` factory. + */ + createProbe?: (env: NodeJS.ProcessEnv, agentLookupHome: string) => AcpVerifyProbe + /** Override the verify per-RPC + total timeouts. */ + verifyTimeoutMs?: number + /** Logger sink. Default: silent. */ + logger?: { + debug: (msg: string, ctx?: unknown) => void + info: (msg: string, ctx?: unknown) => void + warn: (msg: string, ctx?: unknown) => void + error: (msg: string, ctx?: unknown) => void + } +} + +function noopLogger(): NonNullable { + return { debug() {}, info() {}, warn() {}, error() {} } +} + +function reverseLookupWorkspacePath(workspaceHash: string, candidatePaths: string[]): string | null { + // Cursor's drawer hash is `md5(workspacePath)`. We do not have a + // reverse map; the dialog accepts the operator-provided path on + // import for the canonical-drawer check. At discovery time we leave + // workspacePath null for legacy chats whose meta record does not + // carry one (older cursor-agent versions). + for (const path of candidatePaths) { + if (createHash('md5').update(path).digest('hex') === workspaceHash) { + return path + } + } + return null +} + +function readAcpMetaJson(metaPath: string): { schemaVersion?: number; cwd?: string; title?: string } | null { + try { + const raw = readFileSync(metaPath, 'utf-8') + const parsed = JSON.parse(raw) as Record + return { + schemaVersion: typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : undefined, + cwd: typeof parsed.cwd === 'string' ? parsed.cwd : undefined, + title: typeof parsed.title === 'string' ? parsed.title : undefined + } + } catch { + return null + } +} + +function sanityCheckStore(storeDbPath: string): { ok: true } | { ok: false; message: string } { + let db: Database | null = null + try { + db = new Database(storeDbPath, { readonly: true }) + db.query("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").get() + return { ok: true } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) } + } finally { + try { db?.close() } catch {} + } +} + +function summarizeSession(args: { + uuid: string + storeDbPath: string + sourceFormat: CursorImportSourceFormat + workspacePath: string | null + title: string | null + sizeBytes: number + mtimeMs: number + alreadyImportedHapiSessionId: string | null +}): CursorImportableSessionSummary { + const fallbackTitle = args.title ?? `cursor:${args.uuid.slice(0, 8)}` + return { + id: args.uuid, + title: fallbackTitle, + firstUserMessage: null, + workspacePath: args.workspacePath, + storeDbPath: args.storeDbPath, + sourceFormat: args.sourceFormat, + modifiedAt: args.mtimeMs, + sizeBytes: args.sizeBytes, + alreadyImportedHapiSessionId: args.alreadyImportedHapiSessionId + } +} + +function readMetaTitleSafe(storeDbPath: string): string | null { + const meta = readLegacyMetaLastUsedModel(storeDbPath) + return meta?.name?.trim() ? meta.name.trim() : null +} + +function buildAlreadyImportedIndex(store: Store, namespace: string): Map { + // Map cursorSessionId -> hapiSessionId for every existing cursor-flavored + // session row in this namespace. Used to flag rows the dialog should + // render as "already imported" (read-only chip). + const map = new Map() + for (const session of store.sessions.getSessionsByNamespace(namespace)) { + const metadata = session.metadata as Record | null + if (!metadata) continue + if (metadata.flavor !== 'cursor') continue + const csid = metadata.cursorSessionId + if (typeof csid === 'string' && csid.length > 0) { + map.set(csid, session.id) + } + } + return map +} + +/** + * Discover importable cursor sessions from both the legacy and ACP + * on-disk locations. Returns a deduped, mtime-sorted list capped at + * `limit` entries. ACP entries take precedence over legacy entries for + * the same uuid (a successful prior migration should not surface the + * legacy store as a separate import candidate). + */ +export function listImportableCursorSessions(options: { + store: Store + namespace: string + home: string + limit?: number + candidateWorkspacePaths?: string[] +}): CursorImportableSessionSummary[] { + const home = options.home + const limit = options.limit ?? DEFAULT_LIST_LIMIT + const alreadyImportedById = buildAlreadyImportedIndex(options.store, options.namespace) + const byUuid = new Map() + + // ACP entries first. + const acpRoot = join(home, '.cursor', 'acp-sessions') + if (existsSync(acpRoot)) { + let entries: string[] = [] + try { + entries = readdirSync(acpRoot) + } catch { + entries = [] + } + for (const uuid of entries) { + if (!CURSOR_SESSION_ID_RE.test(uuid) || uuid === '.' || uuid === '..') continue + const sessionDir = join(acpRoot, uuid) + const storeDbPath = join(sessionDir, 'store.db') + const metaPath = join(sessionDir, 'meta.json') + let stStore + try { + stStore = statSync(storeDbPath) + if (!stStore.isFile()) continue + } catch { + continue + } + const meta = readAcpMetaJson(metaPath) + const title = meta?.title ?? readMetaTitleSafe(storeDbPath) + const workspacePath = meta?.cwd ?? null + byUuid.set(uuid, summarizeSession({ + uuid, + storeDbPath, + sourceFormat: 'acp', + workspacePath, + title, + sizeBytes: stStore.size, + mtimeMs: stStore.mtimeMs, + alreadyImportedHapiSessionId: alreadyImportedById.get(uuid) ?? null + })) + } + } + + // Legacy entries — only when an ACP entry for the same uuid is absent. + const chatsRoot = join(home, '.cursor', 'chats') + if (existsSync(chatsRoot)) { + let wshEntries: string[] = [] + try { + wshEntries = readdirSync(chatsRoot) + } catch { + wshEntries = [] + } + for (const wsh of wshEntries) { + const wshDir = join(chatsRoot, wsh) + let wshStat + try { + wshStat = statSync(wshDir) + } catch { + continue + } + if (!wshStat.isDirectory()) continue + let uuidEntries: string[] = [] + try { + uuidEntries = readdirSync(wshDir) + } catch { + continue + } + for (const uuid of uuidEntries) { + if (!CURSOR_SESSION_ID_RE.test(uuid) || uuid === '.' || uuid === '..') continue + if (byUuid.has(uuid)) continue // ACP entry already covers this uuid + const storeDbPath = join(wshDir, uuid, 'store.db') + let st + try { + st = statSync(storeDbPath) + if (!st.isFile()) continue + } catch { + continue + } + const title = readMetaTitleSafe(storeDbPath) + const workspacePath = reverseLookupWorkspacePath(wsh, options.candidateWorkspacePaths ?? []) + byUuid.set(uuid, summarizeSession({ + uuid, + storeDbPath, + sourceFormat: 'legacy', + workspacePath, + title, + sizeBytes: st.size, + mtimeMs: st.mtimeMs, + alreadyImportedHapiSessionId: alreadyImportedById.get(uuid) ?? null + })) + } + } + } + + return Array.from(byUuid.values()) + .sort((a, b) => b.modifiedAt - a.modifiedAt) + .slice(0, limit) +} + +function buildImportedSessionMetadata(args: { + uuid: string + workspacePath: string | null + title: string + hostName: string +}): Record { + const now = Date.now() + return { + // MetadataSchema (shared/src/schemas.ts) requires path + host. + path: args.workspacePath ?? '', + host: args.hostName, + os: platform(), + name: args.title, + summary: { text: args.title, updatedAt: now }, + flavor: 'cursor', + cursorSessionId: args.uuid, + // STRICT REFUSAL CONTRACT: any HAPI row created by this path is ACP + // from birth. Verify-probe must have passed before we reach this + // code; the cursorAcpRemoteLauncher (already shipped upstream) reads + // this protocol value to decide which backend to spawn on resume. + cursorSessionProtocol: 'acp', + lifecycleState: 'imported', + lifecycleStateSince: now + } +} + +function rmtreeSafe(path: string): void { + try { + rmSync(path, { recursive: true, force: true }) + } catch { + // best-effort + } +} + +/** + * Verify a cursor `store.db` is loadable by `agent acp` in an isolated + * `$HOME`. Mirrors the audit harness shape (scripts/audit-cursor-acp-verify.ts) + * and the migrator's `verifyInTempHome`. Returns a structured outcome + * — never throws unless the probe spawn fails in a non-recoverable way. + */ +async function verifyCursorStore(args: { + uuid: string + storeDbPath: string + cwd: string + sourceHome: string + deps: CursorImporterDeps +}): Promise<{ kind: 'ok' } | { kind: 'init_failed'; message: string } | { kind: 'load_failed'; message: string } | { kind: 'timeout'; message: string } | { kind: 'spawn_failed'; message: string }> { + const tmpDir = args.deps.tmpDir ?? (() => tmpdir()) + const verifyTimeoutMs = args.deps.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS + const tmpRoot = mkdtempSync(join(tmpDir(), `hapi-cursor-import-verify-${args.uuid.slice(0, 8)}-`)) + const fakeAcpSessionDir = join(tmpRoot, '.cursor', 'acp-sessions', args.uuid) + try { + mkdirSync(fakeAcpSessionDir, { recursive: true }) + copyFileSync(args.storeDbPath, join(fakeAcpSessionDir, 'store.db')) + writeFileSync( + join(fakeAcpSessionDir, 'meta.json'), + JSON.stringify({ schemaVersion: 1, cwd: tmpRoot }) + ) + // Best-effort copy auth files so session/load has credentials to + // resolve any prior `session/set_model` echo; session/load itself + // does not need auth, but stderr is quieter when present. + const realCursor = join(args.sourceHome, '.cursor') + const fakeCursor = join(tmpRoot, '.cursor') + for (const f of AUTH_FILES) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tmpRoot, + HAPI_HOME: tmpRoot, + NO_COLOR: '1' + } + if (process.platform === 'win32') { + env.USERPROFILE = tmpRoot + const driveMatch = /^[A-Za-z]:/.exec(tmpRoot) + if (driveMatch) { + env.HOMEDRIVE = driveMatch[0] + env.HOMEPATH = tmpRoot.slice(2) + } else { + env.HOMEDRIVE = '' + env.HOMEPATH = tmpRoot + } + } + + const probeFactory = args.deps.createProbe ?? ((env: NodeJS.ProcessEnv, agentLookupHome: string): AcpVerifyProbe => { + const opts: AcpProbeOptions = { + env, + hapiHome: tmpRoot, + agentLookupHome, + timeoutMs: DEFAULT_INIT_TIMEOUT_MS + } + return new AcpVerifyProbe(opts) + }) + const probe = probeFactory(env, args.sourceHome) + + try { + try { + probe.start() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { kind: 'spawn_failed', message: msg } + } + const deadline = Date.now() + verifyTimeoutMs + const initTimeout = Math.max(1_000, Math.min(DEFAULT_INIT_TIMEOUT_MS, deadline - Date.now())) + const initResp = await probe.initialize(initTimeout) + if (!initResp.ok) { + const msg = initResp.error.message + if (/^timeout /.test(msg)) return { kind: 'timeout', message: msg } + return { kind: 'init_failed', message: msg } + } + const loadTimeout = Math.max(1_000, Math.min(DEFAULT_LOAD_TIMEOUT_MS, deadline - Date.now())) + const loadOut = await probe.loadSession( + { sessionId: args.uuid, cwd: args.cwd, mcpServers: [] }, + DEFAULT_REPLAY_DRAIN_MS, + loadTimeout + ) + if (!loadOut.response.ok) { + const msg = loadOut.response.error.message + if (/^timeout /.test(msg)) return { kind: 'timeout', message: msg } + return { kind: 'load_failed', message: msg } + } + return { kind: 'ok' } + } finally { + await probe.stop() + } + } finally { + rmtreeSafe(tmpRoot) + } +} + +function findAgentBinary(home: string): string | null { + const candidates = [ + join(home, '.local', 'bin', 'agent'), + join(home, '.npm-global', 'bin', 'agent') + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + // Fallback: PATH lookup is handled inside AcpVerifyProbe.start; we + // only refuse here if both common install dirs miss AND the PATH + // also lacks a hit. The probe's own ENOENT becomes spawn_failed and + // we translate that to agent_binary_not_found at the call site. + const pathEnv = process.env.PATH ?? '' + const dirs = pathEnv.split(process.platform === 'win32' ? ';' : ':') + for (const dir of dirs) { + if (!dir) continue + const candidate = join(dir, process.platform === 'win32' ? 'agent.exe' : 'agent') + if (existsSync(candidate)) return candidate + } + return null +} + +/** + * Import a single cursor session. Strict ACP-only refusal: any failure + * before the HAPI row is written returns a structured outcome with no + * mutation of disk state outside the per-verify temp dir. + */ +export async function importCursorSession(options: { + uuid: string + workspacePath?: string | null + store: Store + namespace: string + home: string + getSyncEngine?: () => SyncEngine | null + deps?: CursorImporterDeps +}): Promise { + const deps = options.deps ?? {} + const now = deps.now ?? (() => Date.now()) + const hostNameFn = deps.hostName ?? (() => process.env.HAPI_HOSTNAME?.trim() || hostname()) + const log = deps.logger ?? noopLogger() + const start = now() + + const failure = (reason: CursorImportRefusalReason, message: string): CursorImportRowOutcome => ({ + ok: false, + uuid: options.uuid, + reason, + message, + durationMs: now() - start + }) + + if (!CURSOR_SESSION_ID_RE.test(options.uuid) || options.uuid === '.' || options.uuid === '..') { + return failure('missing_on_disk_store', `cursor uuid '${options.uuid}' fails basename validation`) + } + + // Pre-flight: refuse if a HAPI row in this namespace already references this uuid. + const existing = buildAlreadyImportedIndex(options.store, options.namespace) + const alreadyHapi = existing.get(options.uuid) + if (alreadyHapi) { + return failure('already_imported', `cursor session ${options.uuid} is already imported as Hapi session ${alreadyHapi}`) + } + + // Probe disk for source format. Prefer ACP over legacy when both exist + // (a prior successful migration removes the legacy source, but a + // --keep-source migration leaves both — treat the ACP entry as canonical). + const acpSessionDir = join(options.home, '.cursor', 'acp-sessions', options.uuid) + const acpStorePath = join(acpSessionDir, 'store.db') + const acpMetaPath = join(acpSessionDir, 'meta.json') + let sourceFormat: CursorImportSourceFormat + let sourceStorePath: string + let resolvedWorkspacePath: string | null = options.workspacePath ?? null + if (existsSync(acpStorePath)) { + sourceFormat = 'acp' + sourceStorePath = acpStorePath + if (!resolvedWorkspacePath) { + const meta = readAcpMetaJson(acpMetaPath) + resolvedWorkspacePath = meta?.cwd ?? null + } + } else { + const legacy = listLegacyChatStoreCandidates(options.uuid, options.home) + if (legacy.length === 0) { + return failure('missing_on_disk_store', `~/.cursor/{chats,acp-sessions} contains no store.db for uuid ${options.uuid} (looked under ${options.home})`) + } + if (legacy.length > 1 && !resolvedWorkspacePath) { + const summary = legacy.map((c) => `${c.workspaceHash} (size=${c.sizeBytes}, mtimeMs=${c.mtimeMs})`).join('; ') + return failure('ambiguous_legacy_store', `cursor session ${options.uuid} exists in ${legacy.length} workspace-hash drawers; resolve by providing workspacePath. Candidates: ${summary}`) + } + if (legacy.length === 1) { + sourceFormat = 'legacy' + sourceStorePath = legacy[0].storeDbPath + } else { + const canonicalHash = createHash('md5').update(resolvedWorkspacePath!).digest('hex') + const picked = legacy.find((c) => c.workspaceHash === canonicalHash) + if (!picked) { + const summary = legacy.map((c) => `${c.workspaceHash} (size=${c.sizeBytes}, mtimeMs=${c.mtimeMs})`).join('; ') + return failure('ambiguous_legacy_store', `cursor session ${options.uuid}: provided workspacePath did not resolve to any of the on-disk drawers. Candidates: ${summary}`) + } + sourceFormat = 'legacy' + sourceStorePath = picked.storeDbPath + } + } + + // Cheap sanity: store.db opens as SQLite + has at least one table. + // Avoids spending a verify spawn on a corrupted/truncated file. + const sanity = sanityCheckStore(sourceStorePath) + if (!sanity.ok) { + return failure('corrupted_store', `cursor session ${options.uuid}: ${sanity.message}`) + } + + // For legacy: refuse if the ACP target dir already exists (race or partial prior import). + if (sourceFormat === 'legacy' && existsSync(acpSessionDir)) { + return failure('target_already_exists', `~/.cursor/acp-sessions/${options.uuid}/ already exists; refusing to overwrite`) + } + + // Pre-flight: refuse early if the `agent` binary is not findable. The + // probe would otherwise spawn_failed with ENOENT; this hint is + // cleaner for the operator's "fix your PATH" toast. + if (!findAgentBinary(options.home)) { + const pathHint = process.env.PATH ?? '' + return failure('agent_binary_not_found', `\`agent\` binary not found under ${options.home}/.local/bin, ${options.home}/.npm-global/bin, or PATH (${pathHint.length > 0 ? pathHint : ''})`) + } + + // Verify-probe against an isolated $HOME. STRICT REFUSAL CONTRACT: + // any non-ok outcome aborts before creating a HAPI row. + const verifyCwd = resolvedWorkspacePath && resolvedWorkspacePath.length > 0 + ? resolvedWorkspacePath + : options.home + const verifyOut = await verifyCursorStore({ + uuid: options.uuid, + storeDbPath: sourceStorePath, + cwd: verifyCwd, + sourceHome: options.home, + deps + }) + if (verifyOut.kind === 'spawn_failed') { + // ENOENT here is the agent_binary_not_found case; non-ENOENT is + // internal_error because the binary existed in pre-flight but + // could not be spawned. + if (/ENOENT|not found|could not be spawned/i.test(verifyOut.message)) { + return failure('agent_binary_not_found', verifyOut.message) + } + return failure('internal_error', `verify-probe spawn failed: ${verifyOut.message}`) + } + if (verifyOut.kind === 'init_failed') { + return failure('verify_load_failed', `agent acp initialize failed: ${verifyOut.message}`) + } + if (verifyOut.kind === 'load_failed') { + return failure('verify_load_failed', `agent acp session/load failed: ${verifyOut.message}`) + } + if (verifyOut.kind === 'timeout') { + return failure('verify_timeout', verifyOut.message) + } + + // Verify passed. For legacy sessions, transplant store.db → ACP dir. + // Mirrors the migrator's atomic-mkdir + 0o700 mode + 0o600 store.db + // mode (see cursorLegacyMigrator.migrateOneWithLock) — these + // permissions matter on multi-user hosts where ~/.cursor is not + // owner-private. + if (sourceFormat === 'legacy') { + try { + mkdirSync(join(options.home, '.cursor', 'acp-sessions'), { recursive: true }) + try { + mkdirSync(acpSessionDir, { recursive: false, mode: 0o700 }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (/EEXIST/.test(msg)) { + return failure('target_already_exists', `~/.cursor/acp-sessions/${options.uuid}/ already exists (race with concurrent import); refusing to overwrite`) + } + throw err + } + copyFileSync(sourceStorePath, join(acpSessionDir, 'store.db')) + try { chmodSync(join(acpSessionDir, 'store.db'), 0o600) } catch {} + const titleFromMeta = readMetaTitleSafe(sourceStorePath) + const sidecar: Record = { + schemaVersion: 1, + cwd: resolvedWorkspacePath ?? options.home + } + if (titleFromMeta) sidecar.title = titleFromMeta + writeFileSync(join(acpSessionDir, 'meta.json'), JSON.stringify(sidecar), { mode: 0o600 }) + log.info('[cursor-import] transplanted legacy store to ACP location', { + uuid: options.uuid, + acpStorePath: join(acpSessionDir, 'store.db') + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Rollback our partial dir. + rmtreeSafe(acpSessionDir) + return failure('internal_error', `failed to place ACP session dir: ${msg}`) + } + } + + // Create the HAPI session row. The strict-ACP contract is now satisfied + // (verify passed AND, for legacy, transplant succeeded), so this row + // is ACP from birth — no stream-json HAPI row was ever a possibility. + const title = readMetaTitleSafe(join(acpSessionDir, 'store.db')) ?? readMetaTitleSafe(sourceStorePath) ?? `cursor:${options.uuid.slice(0, 8)}` + const metadata = buildImportedSessionMetadata({ + uuid: options.uuid, + workspacePath: resolvedWorkspacePath, + title, + hostName: hostNameFn() + }) + let hapiSessionId: string + try { + const engine = options.getSyncEngine?.() ?? null + const created = engine?.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + hapiSessionId = created.id + log.info('[cursor-import] created Hapi session for cursor uuid', { + uuid: options.uuid, + hapiSessionId, + sourceFormat + }) + } catch (err) { + // Roll back the transplant if we did one but the HAPI row write failed. + if (sourceFormat === 'legacy') { + rmtreeSafe(acpSessionDir) + } + return failure('internal_error', `failed to create Hapi session row: ${err instanceof Error ? err.message : String(err)}`) + } + + return { + ok: true, + uuid: options.uuid, + hapiSessionId, + sourceFormat, + durationMs: now() - start + } +} + +/** + * Batch-import wrapper: each row's outcome is independent — one failing + * does not abort the batch. Mirrors the codex importer's + * `importSelectedCodexSessions` shape so the dialog can render per-row + * results uniformly. + */ +export async function importSelectedCursorSessions(options: { + uuids: string[] + workspacePath?: string | null + store: Store + namespace: string + home: string + getSyncEngine?: () => SyncEngine | null + deps?: CursorImporterDeps +}): Promise<{ results: CursorImportRowOutcome[]; importedCount: number }> { + const results: CursorImportRowOutcome[] = [] + for (const uuid of options.uuids) { + const outcome = await importCursorSession({ + uuid, + workspacePath: options.workspacePath, + store: options.store, + namespace: options.namespace, + home: options.home, + getSyncEngine: options.getSyncEngine, + deps: options.deps + }) + results.push(outcome) + } + const importedCount = results.filter((r) => r.ok).length + return { results, importedCount } +} diff --git a/hub/src/fcm/fcmAuth.ts b/hub/src/fcm/fcmAuth.ts new file mode 100644 index 0000000000..9049bb2070 --- /dev/null +++ b/hub/src/fcm/fcmAuth.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs' +import * as jose from 'jose' + +export type ServiceAccount = { + client_email: string + private_key: string + project_id?: string +} + +const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' +export const FCM_REQUEST_TIMEOUT_MS = 10_000 + +let cachedToken: { accessToken: string; expiresAtMs: number } | null = null + +export function loadServiceAccount(path: string): ServiceAccount { + const raw = readFileSync(path, 'utf8') + const parsed = JSON.parse(raw) as ServiceAccount + if (!parsed.client_email || !parsed.private_key) { + throw new Error('FCM service account JSON missing client_email or private_key') + } + return parsed +} + +export async function getFcmAccessToken(serviceAccount: ServiceAccount): Promise { + const nowMs = Date.now() + if (cachedToken && cachedToken.expiresAtMs > nowMs + 60_000) { + return cachedToken.accessToken + } + + const nowSec = Math.floor(nowMs / 1000) + const key = await jose.importPKCS8(serviceAccount.private_key, 'RS256') + const assertion = await new jose.SignJWT({ scope: FCM_SCOPE }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(serviceAccount.client_email) + .setSubject(serviceAccount.client_email) + .setAudience('https://oauth2.googleapis.com/token') + .setIssuedAt(nowSec) + .setExpirationTime(nowSec + 3600) + .sign(key) + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion + }), + signal: AbortSignal.timeout(FCM_REQUEST_TIMEOUT_MS) + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`FCM OAuth token exchange failed: ${response.status} ${body}`) + } + + const json = await response.json() as { access_token?: string; expires_in?: number } + if (!json.access_token) { + throw new Error('FCM OAuth response missing access_token') + } + + const expiresInSec = json.expires_in ?? 3600 + cachedToken = { + accessToken: json.access_token, + expiresAtMs: nowMs + expiresInSec * 1000 + } + return cachedToken.accessToken +} + +/** Test helper */ +export function clearFcmAccessTokenCache(): void { + cachedToken = null +} diff --git a/hub/src/fcm/fcmConfig.ts b/hub/src/fcm/fcmConfig.ts new file mode 100644 index 0000000000..626c016bb4 --- /dev/null +++ b/hub/src/fcm/fcmConfig.ts @@ -0,0 +1,26 @@ +import { existsSync } from 'node:fs' +import { loadServiceAccount } from './fcmAuth' + +export type FcmConfig = { + projectId: string + serviceAccountPath: string + serviceAccount: ReturnType +} + +export function resolveFcmConfig(): FcmConfig | null { + const serviceAccountPath = process.env.FCM_SERVICE_ACCOUNT_PATH?.trim() + if (!serviceAccountPath || !existsSync(serviceAccountPath)) { + return null + } + + const serviceAccount = loadServiceAccount(serviceAccountPath) + const projectId = process.env.FCM_PROJECT_ID?.trim() + || serviceAccount.project_id + || null + if (!projectId) { + console.warn('[Fcm] FCM_PROJECT_ID unset and service account JSON has no project_id') + return null + } + + return { projectId, serviceAccountPath, serviceAccount } +} diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts new file mode 100644 index 0000000000..2444a89f27 --- /dev/null +++ b/hub/src/fcm/fcmNotificationChannel.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '../sync/syncEngine' +import { FcmNotificationChannel } from './fcmNotificationChannel' +import type { FcmSendPayload } from './fcmService' + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-ready', + namespace: 'default', + name: 'Demo', + active: true, + metadata: { flavor: 'codex', name: 'Demo' }, + ...overrides + } as Session +} + +describe('FcmNotificationChannel', () => { + it('always fires FCM regardless of PWA visibility (wrist-first)', async () => { + const sent: FcmSendPayload[] = [] + const toasts: unknown[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event) + return 1 + } + } as never, + { + hasVisibleConnection: () => true + } as never + ) + + await channel.sendReady(createSession()) + + // The watch is the canonical surface when a native companion is + // registered. The previous behaviour silently swallowed FCM when + // the PWA was foreground - that broke the wrist-first UX. We now + // fire FCM unconditionally and let the PWA's own SyncEngine event + // stream handle in-page toasts (or not, per UX preference). + expect(sent).toHaveLength(1) + expect(toasts).toHaveLength(0) + }) + + it('includes requestId on permission-request payloads', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-42': { tool: 'Bash', arguments: {} } + } + } + })) + + expect(sent).toHaveLength(1) + expect(sent[0].data.type).toBe('permission-request') + expect(sent[0].data.requestId).toBe('req-42') + expect(sent[0].data.contractVersion).toBe('1') + }) + + it('enriches permission-request body with tool args (Edit)', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-99': { + tool: 'Edit', + arguments: { + file_path: '/home/u/proj/hub/src/server.ts', + old_string: 'foo', + new_string: 'bar' + } + } + } + } + })) + + expect(sent).toHaveLength(1) + const body = sent[0].body ?? '' + const dataBody = sent[0].data.body ?? '' + // Glance line: agent + tool + compact arg (last two path segments). + expect(body).toContain('Edit') + expect(body).toContain('hub/src/server.ts') + // Detail: full file path on its own line, plus old/new previews - + // visible when the watch operator taps to expand. + expect(body).toContain('File: /home/u/proj/hub/src/server.ts') + expect(body).toContain('Old: "foo"') + expect(body).toContain('New: "bar"') + // data.body must mirror notification body so the watch sees the same + // text the FCM `notification` field would. + expect(dataBody).toBe(body) + }) + + it('truncates long Grep permission patterns for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-grep': { + tool: 'Grep', + arguments: { pattern: 'P'.repeat(500), path: '/tmp' } + } + } + } + })) + + expect(sent[0].body).toContain('Pattern:') + expect(sent[0].body).toContain('...') + expect(sent[0].data.body.length).toBeLessThan(600) + }) + + it('falls back gracefully when no tool args are present', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never + ) + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { + 'req-1': { tool: 'NewExperimentalTool', arguments: { foo: 'bar' } } + } + } + })) + + const body = sent[0].body ?? '' + // Compact returns '' for tools not in its switch table, so the glance + // line collapses to bare " " - confirming we never emit + // " : " with a dangling colon. + expect(body.split('\n')[0]).toMatch(/NewExperimentalTool$/) + expect(body).not.toContain(': \n') + }) + + function makeStoreWithMessages(messages: Array<{ content: unknown }>) { + // Minimal store stub: only the bits FcmNotificationChannel touches. + // Mirrors the real `getMessages` contract: callers receive the last N + // rows in ASCENDING seq order (oldest first, latest last). + return { + messages: { + getMessages: (_sessionId: string, _limit: number) => messages.map((m, i) => ({ + id: `m-${i}`, + sessionId: 'session-ready', + content: m.content, + createdAt: i, + seq: i + 1, + localId: null, + invokedAt: null, + scheduledAt: null + })) + } + } as never + } + + it('sendReady prefers AGENT_NOTIFY_SUMMARY when last assistant message has one', async () => { + const sent: FcmSendPayload[] = [] + const store = makeStoreWithMessages([ + // DESC order: index 0 = latest. Latest assistant text contains a summary. + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Did the work.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"Tokens revoked","action":"Upload preview","status":"done"}' + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent).toHaveLength(1) + const p = sent[0] + expect(p.title).toBe('Codex - Demo') + expect(p.body).toBe('Tokens revoked\n-> Upload preview') + expect(p.data.notifySummary).toBeDefined() + const parsed = JSON.parse(p.data.notifySummary as string) + expect(parsed.summary).toBe('Tokens revoked') + expect(parsed.action).toBe('Upload preview') + }) + + it('sendReady caps long AGENT_NOTIFY_SUMMARY text for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const longSummary = 'S'.repeat(400) + const longAction = 'A'.repeat(400) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: `Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"${longSummary}","action":"${longAction}","status":"done"}` + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent).toHaveLength(1) + expect(sent[0].body.length).toBeLessThanOrEqual(280) + const parsed = JSON.parse(sent[0].data.notifySummary as string) + expect(parsed.summary.length).toBeLessThanOrEqual(280) + expect(parsed.action.length).toBeLessThanOrEqual(280) + }) + + it('sendReady respects tiny action budget when summary fills the glance limit', async () => { + const sent: FcmSendPayload[] = [] + const summary278 = 'S'.repeat(278) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: `Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"${summary278}","action":"ACTION","status":"done"}` + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].body.length).toBeLessThanOrEqual(280) + expect(sent[0].body).not.toContain('ACTION') + }) + + it('sendReady caps auxiliary notifySummary fields for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const longAgent = 'G'.repeat(200) + const longProject = 'P'.repeat(200) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: `Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"summary":"ok","action":"go","status":"${'x'.repeat(80)}","agent":"${longAgent}","project":"${longProject}"}` + } + } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + const parsed = JSON.parse(sent[0].data.notifySummary as string) + expect(parsed.status.length).toBeLessThanOrEqual(32) + expect(parsed.agent.length).toBeLessThanOrEqual(80) + expect(parsed.project.length).toBeLessThanOrEqual(80) + }) + + it('sendReady truncates last assistant text when no summary is present', async () => { + const sent: FcmSendPayload[] = [] + const longText = 'A'.repeat(500) + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: longText } } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + const body = sent[0].body + expect(body.length).toBeLessThanOrEqual(280) + expect(body.endsWith('...')).toBe(true) + expect(sent[0].data.notifySummary).toBeUndefined() + }) + + it('sendReady skips tool-call messages and uses the last assistant TEXT message', async () => { + const sent: FcmSendPayload[] = [] + // Real getMessages returns ASC (oldest first, newest last). The newest + // here is a tool-call-result; the channel must walk back past two + // tool-call frames to find the actual assistant text. + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'The actual reply.' } } + } + }, + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'tool-call', name: 'Bash', callId: 'x', input: {} } } + } + }, + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'tool-call-result', output: {} } } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].body).toBe('The actual reply.') + }) + + it('sendReady picks the LATEST assistant text when multiple text messages exist (ASC ordering regression guard)', async () => { + const sent: FcmSendPayload[] = [] + // Two text messages, oldest first - the channel must return the + // last one ("Latest reply.") not the first ("Older reply."). + // This guards against a real bug where we walked the array + // assuming DESC ordering and picked the oldest. + const store = makeStoreWithMessages([ + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'Older reply.' } } + } + }, + { + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'Latest reply.' } } + } + } + ]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].body).toBe('Latest reply.') + }) + + it('sendReady falls back to "is waiting" line when no agent text exists', async () => { + const sent: FcmSendPayload[] = [] + const store = makeStoreWithMessages([]) + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never, + store + ) + + await channel.sendReady(createSession()) + + expect(sent[0].title).toBe('Ready for input') + expect(sent[0].body).toBe('Codex is waiting in Demo') + }) + + it('sendReady falls back when the channel has no store (test/legacy wiring)', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + // store omitted on purpose + ) + + await channel.sendReady(createSession()) + + expect(sent[0].title).toBe('Ready for input') + expect(sent[0].body).toBe('Codex is waiting in Demo') + }) + + it('sets severity=info on ready notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendReady(createSession()) + expect(sent[0].data.severity).toBe('info') + }) + + it('sets nativeGate.sent when FCM delivers at least one message', async () => { + const gate = { sent: false } + const channel = new FcmNotificationChannel( + { + sendToNamespace: async () => ({ sent: 1, failed: 0, invalidTokens: [] }) + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + await channel.sendReady(createSession(), { nativeGate: gate }) + + expect(gate.sent).toBe(true) + }) + + it('leaves nativeGate.sent false when FCM sends zero messages', async () => { + const gate = { sent: false } + const channel = new FcmNotificationChannel( + { + sendToNamespace: async () => ({ sent: 0, failed: 1, invalidTokens: [] }) + } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + await channel.sendReady(createSession(), { nativeGate: gate }) + + expect(gate.sent).toBe(false) + }) + + it('sets severity=warning on permission-request notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendPermissionRequest(createSession({ + agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } + })) + expect(sent[0].data.severity).toBe('warning') + }) + + it('sets severity=success on completed task notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendTaskNotification(createSession(), { status: 'completed', summary: 'Tests passed' }) + expect(sent[0].data.severity).toBe('success') + }) + + it('sets severity=error on failed task notifications', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + // The hub's failure detection catches 'failed' / 'error' / 'killed' / 'aborted'. + for (const status of ['failed', 'error', 'killed', 'aborted']) { + sent.length = 0 + await channel.sendTaskNotification(createSession(), { status, summary: 'oh no' }) + expect(sent[0].data.severity).toBe('error') + } + }) + + it('sendTaskNotification caps long task summaries for FCM data limits', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + await channel.sendTaskNotification(createSession(), { + status: 'completed', + summary: 'T'.repeat(500) + }) + expect(sent[0].body).toContain('...') + expect(sent[0].body.length).toBeLessThan(350) + }) + + it('sendModelError fires FCM with severity=error even when PWA is foreground', async () => { + const sent: FcmSendPayload[] = [] + const toasts: unknown[] = [] + const channel = new FcmNotificationChannel( + { + sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => { + sent.push(payload) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event) + return 1 + } + } as never, + { + hasVisibleConnection: () => true + } as never + ) + + await channel.sendModelError(createSession(), { + kind: 'quota_exhausted', + transient: false, + rawSnippet: 'You have hit your usage limit', + priorAssistantClaimsDone: true, + atTs: 1710000000000 + }) + + expect(sent).toHaveLength(1) + expect(toasts).toHaveLength(0) + expect(sent[0].data.type).toBe('model-error') + expect(sent[0].data.severity).toBe('error') + expect(sent[0].tag).toBe('model-error-session-ready-1710000000000') + expect(sent[0].title).toBe('Quota exhausted') + expect(sent[0].body).toContain('Codex') + expect(sent[0].body).toContain('Demo') + }) + + it('sendModelError uses distinct tags per atTs so errors do not collapse', async () => { + const sent: FcmSendPayload[] = [] + const channel = new FcmNotificationChannel( + { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never, + { sendToast: async () => 0 } as never, + { hasVisibleConnection: () => false } as never + ) + + const base = { + kind: 'rate_limited', + transient: true, + rawSnippet: 'slow down', + priorAssistantClaimsDone: false + } + + await channel.sendModelError(createSession(), { ...base, atTs: 1 }) + await channel.sendModelError(createSession(), { ...base, atTs: 2 }) + + expect(sent[0].tag).toBe('model-error-session-ready-1') + expect(sent[1].tag).toBe('model-error-session-ready-2') + expect(sent[0].data.type).toBe('model-error') + expect(sent[1].data.severity).toBe('error') + }) +}) diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts new file mode 100644 index 0000000000..3cee69a42d --- /dev/null +++ b/hub/src/fcm/fcmNotificationChannel.ts @@ -0,0 +1,340 @@ +import type { Session } from '../sync/syncEngine' +import type { ModelErrorNotification, NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import type { NotificationSendContext } from '../notifications/notificationSendContext' +import { formatModelErrorBody, formatModelErrorTitle } from '../notifications/modelErrorCopy' +import { getAgentName, getSessionName } from '../notifications/sessionInfo' +import { formatToolArgumentsCompact, formatToolArgumentsDetailed } from '../notifications/toolArgs' +import { extractAssistantPlainText, extractNotifySummary, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import { stripAgentContract } from '@hapi/protocol' +import type { Store } from '../store' +import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' +import type { FcmSendPayload, FcmService } from './fcmService' + +const CONTRACT_VERSION = '1' + +/** + * `ready` body content limit for the wrist-glance line on the watch. + * BigTextStyle still expands when the operator taps, so this cap only + * affects the collapsed glance. ~280 chars matches the watch's three-line + * collapsed render at default font scale. + */ +const READY_BODY_GLANCE_LIMIT = 280 + +export class FcmNotificationChannel implements NotificationChannel { + constructor( + private readonly fcmService: FcmService, + private readonly sseManager: SSEManager, + private readonly visibilityTracker: VisibilityTracker, + private readonly store?: Store + ) {} + + async sendPermissionRequest(session: Session, ctx?: NotificationSendContext): Promise { + if (!session.active) { + return + } + + const name = getSessionName(session) + const agentName = getAgentName(session) + const requests = session.agentState?.requests ?? null + const requestEntries = requests ? Object.entries(requests) : [] + const [requestId, request] = requestEntries[0] ?? [undefined, null] + + // Glance line: keep brutally short so the wrist-collapsed + // notification still shows the first ~40 chars without truncation. + // Format: " : " e.g. "Claude Edit: .../hub/server.ts" + // Fallback when args aren't useful: " " e.g. "Cursor Bash" + const toolName = request?.tool ?? '' + const compact = request ? formatToolArgumentsCompact(request.tool, request.arguments) : '' + const glance = toolName + ? (compact ? `${agentName} ${toolName}: ${compact}` : `${agentName} ${toolName}`) + : `${agentName} - ${name}` + + // Detailed body: rendered when the operator taps the notification on + // Wear OS (BigTextStyle on the watch side). Lines after the first + // are hidden in the collapsed glance, so we can be generous here. + const detailed = request + ? formatToolArgumentsDetailed(request.tool, request.arguments, { maxArgLength: 120 }) + : '' + const bodyLines = [glance] + if (name && name !== glance) { + bodyLines.push(`Session: ${name}`) + } + if (detailed) { + bodyLines.push(detailed) + } + + const path = this.buildSessionPath(session.id) + + const payload = this.buildPayload({ + title: 'Permission Request', + body: bodyLines.join('\n'), + tag: `permission-${session.id}`, + type: 'permission-request', + sessionId: session.id, + sessionName: name, + url: path, + requestId, + severity: 'warning' + }) + + await this.deliver(session, payload, ctx) + } + + async sendReady(session: Session, ctx?: NotificationSendContext): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const name = getSessionName(session) + const path = this.buildSessionPath(session.id) + + const composed = this.composeReadyBody(session, agentName, name) + + const payload = this.buildPayload({ + title: composed.title, + body: composed.body, + tag: `ready-${session.id}`, + type: 'ready', + sessionId: session.id, + sessionName: name, + url: path, + severity: 'info' + }) + + if (composed.notifySummary) { + payload.data.notifySummary = JSON.stringify(composed.notifySummary) + } + + await this.deliver(session, payload, ctx) + } + + /** + * Build the title/body for a `ready` notification. + * + * Strategy: + * 1. If the operator's `AGENTS.md` has the agent emit + * `AGENT_NOTIFY_SUMMARY {...json...}` as the trailing line, parse it + * and use `summary` (+ `action` on a second line) for the body. + * Title becomes ` - ` so the summary text owns the + * body. + * 2. Otherwise fall back to the first ~280 chars of the most recent + * assistant text. Same title pattern. + * 3. If no assistant text can be found at all (cold start, store + * unavailable, all recent messages are tool calls), fall back to + * the previous " is waiting in " content so we + * never regress to a worse notification than today. + */ + private composeReadyBody( + session: Session, + agentName: string, + sessionName: string + ): { title: string; body: string; notifySummary?: Record } { + const fallback = { + title: 'Ready for input', + body: `${agentName} is waiting in ${sessionName}` + } + + if (!this.store) return fallback + + const lastText = this.findLastAssistantPlainText(session.id) + if (!lastText) return fallback + + const summary = extractNotifySummary(lastText) + const headerTitle = `${agentName} - ${sessionName}` + + if (summary?.summary) { + const summaryLine = this.truncateReadyText(summary.summary, READY_BODY_GLANCE_LIMIT) + const actionLine = summary.action && summary.action !== summary.summary + ? this.truncateReadyText( + `-> ${summary.action}`, + Math.max(0, READY_BODY_GLANCE_LIMIT - summaryLine.length - 1) + ) + : '' + const body = [summaryLine, actionLine].filter(Boolean).join('\n') + const notifySummary = { + ...(typeof summary.version === 'number' ? { version: summary.version } : {}), + summary: summaryLine, + ...(summary.action + ? { action: this.truncateReadyText(summary.action, READY_BODY_GLANCE_LIMIT) } + : {}), + ...(summary.status ? { status: this.truncateReadyText(summary.status, 32) } : {}), + ...(summary.agent ? { agent: this.truncateReadyText(summary.agent, 80) } : {}), + ...(summary.project ? { project: this.truncateReadyText(summary.project, 80) } : {}) + } + return { + title: headerTitle, + body, + notifySummary + } + } + + // No parseable summary: fall back to the raw reply text, but strip the + // machine contract first so a short reply does not leak the trailing + // AGENT_NOTIFY_SUMMARY line into the push notification body. Note we + // parse the summary above from the RAW lastText (line ~143) - only this + // human-facing fallback body is stripped. + const trimmed = stripAgentContract(lastText).trim() + if (trimmed.length === 0) return fallback + + // -3 leaves room for the '...' suffix so the body still fits the + // glance limit. Without this it would tip over by 2 characters. + const body = trimmed.length > READY_BODY_GLANCE_LIMIT + ? trimmed.slice(0, READY_BODY_GLANCE_LIMIT - 3).trimEnd() + '...' + : trimmed + + return { title: headerTitle, body } + } + + private truncateReadyText(text: string, limit: number): string { + const trimmed = text.trim() + if (limit <= 0 || trimmed.length === 0) { + return '' + } + if (trimmed.length <= limit) { + return trimmed + } + if (limit <= 3) { + return '.'.repeat(limit) + } + return trimmed.slice(0, limit - 3).trimEnd() + '...' + } + + /** + * Walk the most recent stored messages and return the plain text of + * the latest assistant message that *has* text content. Tool calls, + * tool results, and reasoning blocks are skipped (they have no + * text body, they would just show as `null` and force the fallback). + */ + private findLastAssistantPlainText(sessionId: string): string | null { + if (!this.store) return null + + let messages + try { + // 20 is generous: most ready events fire 1-3 messages after + // the latest assistant text, and we cap to 20 to avoid + // pathological scans on long sessions. + messages = this.store.messages.getMessages(sessionId, 20) + } catch { + return null + } + + // getMessages returns the LAST `limit` rows in ASCENDING seq order + // (it queries DESC then reverses for caller convenience), so the + // freshest message lives at the END of the array. Walk backwards + // so we hit the latest assistant text first. + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i] + const record = unwrapRoleWrappedRecordEnvelope(msg.content) + if (record?.role !== 'agent') continue + const text = extractAssistantPlainText(record.content) + if (text && text.trim().length > 0) { + return text + } + } + return null + } + + async sendTaskNotification(session: Session, notification: TaskNotification, ctx?: NotificationSendContext): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const name = getSessionName(session) + const normalizedStatus = notification.status?.trim().toLowerCase() + const isFailure = normalizedStatus === 'failed' + || normalizedStatus === 'error' + || normalizedStatus === 'killed' + || normalizedStatus === 'aborted' + const path = this.buildSessionPath(session.id) + const taskSummary = this.truncateReadyText(notification.summary, READY_BODY_GLANCE_LIMIT) + + const payload = this.buildPayload({ + title: isFailure ? 'Task failed' : 'Task completed', + body: `${agentName} · ${name} · ${taskSummary}`, + type: 'task-notification', + sessionId: session.id, + sessionName: name, + url: path, + severity: isFailure ? 'error' : 'success' + }) + + await this.deliver(session, payload, ctx) + } + + async sendModelError(session: Session, notification: ModelErrorNotification): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const sessionName = getSessionName(session) + const title = formatModelErrorTitle(notification.kind) + const body = formatModelErrorBody(notification, { agentName, sessionName }) + const path = this.buildSessionPath(session.id) + + const payload = this.buildPayload({ + title, + body, + tag: `model-error-${session.id}-${notification.atTs}`, + type: 'model-error', + sessionId: session.id, + sessionName, + url: path, + severity: 'error' + }) + + await this.deliver(session, payload) + } + + private buildPayload(input: { + title: string + body: string + tag?: string + type: string + sessionId: string + sessionName: string + url: string + requestId?: string + severity?: 'info' | 'success' | 'warning' | 'error' + }): FcmSendPayload { + return { + title: input.title, + body: input.body, + tag: input.tag, + data: { + type: input.type, + sessionId: input.sessionId, + sessionName: input.sessionName, + url: input.url, + requestId: input.requestId, + title: input.title, + body: input.body, + contractVersion: CONTRACT_VERSION, + severity: input.severity + } + } + } + + private async deliver(session: Session, payload: FcmSendPayload, ctx?: NotificationSendContext): Promise { + // Native companion is the canonical surface: always fire FCM when the + // hub asks us to. The previous SSE-toast shortcut here meant that + // when the operator had the PWA open in foreground, the watch got + // NOTHING - the in-page React toast was the only signal. That broke + // the wrist-first UX (the whole point of installing a watch app) + // and confused the operator about whether the agent was making + // progress. SSE in-page toasts are still emitted by the PWA's own + // SyncEngine event stream for users who want them; this channel's + // job is to reach the wrist, period. + const result = await this.fcmService.sendToNamespace(session.namespace, payload) + if ((result?.sent ?? 0) > 0 && ctx?.nativeGate) { + ctx.nativeGate.sent = true + } + } + + private buildSessionPath(sessionId: string): string { + return `/sessions/${sessionId}` + } +} diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts new file mode 100644 index 0000000000..d2526a47d0 --- /dev/null +++ b/hub/src/fcm/fcmService.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { FcmService, type FcmSendPayload } from './fcmService' + +mock.module('./fcmAuth', () => ({ + getFcmAccessToken: async () => 'test-access-token', + loadServiceAccount: () => ({ client_email: 'x', private_key: 'y' }) +})) + +type FakeStore = { + fcm: { + getDevicesByNamespace: ReturnType + removeDeviceByToken: ReturnType + } +} + +function makeStore(devices: Array<{ token: string; platform: 'phone' | 'wear'; deviceId: string; namespace: string }>): FakeStore { + return { + fcm: { + getDevicesByNamespace: mock((ns: string) => + devices + .filter(d => d.namespace === ns) + .map(d => ({ + id: 0, + namespace: d.namespace, + token: d.token, + platform: d.platform, + deviceId: d.deviceId, + createdAt: 0, + updatedAt: 0 + })) + ), + removeDeviceByToken: mock(() => {}) + } + } +} + +function makePayload(overrides: Partial = {}): FcmSendPayload { + return { + title: 'T', + body: 'B', + data: { + type: 'ready', + sessionId: 'sess-1', + sessionName: 'Demo', + url: 'https://hapi.example.com/sessions/sess-1', + title: 'T', + body: 'B', + contractVersion: '1', + ...overrides + } + } +} + +describe('FcmService.sendToNamespace', () => { + let originalFetch: typeof globalThis.fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('removes the device row when FCM returns 404 UNREGISTERED (token rotated)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'rotated-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(0) + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual(['rotated-token']) + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'rotated-token') + }) + + it('keeps the device row on generic 404 NOT_FOUND (bad project/resource config)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"NOT_FOUND","message":"Requested entity was not found."}}', { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual([]) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('removes the device row on canonical 404 NOT_FOUND + FcmError UNREGISTERED', async () => { + const store = makeStore([ + { namespace: 'default', token: 'dead-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ + error: { + status: 'NOT_FOUND', + details: [{ + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + errorCode: 'UNREGISTERED' + }] + } + }), { status: 404 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.invalidTokens).toEqual(['dead-token']) + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'dead-token') + }) + + it('keeps the device row on 400 INVALID_ARGUMENT without token field violation', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ + error: { + status: 'INVALID_ARGUMENT', + details: [{ + fieldViolations: [{ field: 'message.data.body', description: 'too long' }] + }] + } + }), { status: 400 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row on transient 429 (rate limit) - regression for HAPI Bot finding', async () => { + const store = makeStore([ + { namespace: 'default', token: 'rate-limited-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"RESOURCE_EXHAUSTED"}}', { status: 429 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(0) + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual([]) + // Critical: must NOT remove the device on a transient failure. + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row on transient 503 (server error)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'wear', deviceId: 'w1' } + ]) + globalThis.fetch = mock(async () => + new Response('Service Unavailable', { status: 503 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(result.invalidTokens).toEqual([]) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row on 401 auth glitch (our problem, not the device\'s)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"error":{"status":"UNAUTHENTICATED"}}', { status: 401 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('keeps the device row when fetch itself throws (network error)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('treats timed-out FCM send as transient failure', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async (_url, init) => { + expect(init?.signal).toBeDefined() + throw new DOMException('The operation was aborted.', 'AbortError') + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.failed).toBe(1) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('counts a 200 response as sent', async () => { + const store = makeStore([ + { namespace: 'default', token: 'live-token', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"name":"projects/proj-id/messages/0:1234567890"}', { status: 200 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(1) + expect(result.failed).toBe(0) + expect(store.fcm.removeDeviceByToken).not.toHaveBeenCalled() + }) + + it('mixed batch: removes invalid token, keeps device with transient failure, counts good send', async () => { + const store = makeStore([ + { namespace: 'default', token: 'good-token', platform: 'phone', deviceId: 'p1' }, + { namespace: 'default', token: 'rotated-token', platform: 'phone', deviceId: 'p2' }, + { namespace: 'default', token: 'rate-limited-token', platform: 'wear', deviceId: 'w1' } + ]) + + const responseFor: Record Response> = { + 'good-token': () => new Response('{"name":"ok"}', { status: 200 }), + 'rotated-token': () => new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }), + 'rate-limited-token': () => new Response('{"error":{"status":"RESOURCE_EXHAUSTED"}}', { status: 429 }) + } + globalThis.fetch = mock(async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse((init?.body as string) ?? '{}') as { message?: { token?: string } } + const token = body.message?.token ?? '' + const fn = responseFor[token] + return fn ? fn() : new Response('unknown', { status: 500 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('default', makePayload()) + + expect(result.sent).toBe(1) + expect(result.failed).toBe(2) + expect(result.invalidTokens).toEqual(['rotated-token']) + // Only the truly-rotated token gets unregistered. The rate-limited + // device must survive to be retried on the next notification. + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledTimes(1) + expect(store.fcm.removeDeviceByToken).toHaveBeenCalledWith('default', 'rotated-token') + }) + + it('returns zero counts when namespace has no devices', async () => { + const store = makeStore([]) + globalThis.fetch = mock(async () => new Response('should-not-be-called', { status: 200 })) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + const result = await svc.sendToNamespace('empty-ns', makePayload()) + + expect(result).toEqual({ sent: 0, failed: 0, invalidTokens: [] }) + expect(globalThis.fetch).not.toHaveBeenCalled() + }) +}) + +describe('FcmService.isHealthy (rolling outcome window)', () => { + let originalFetch: typeof globalThis.fetch + beforeEach(() => { + originalFetch = globalThis.fetch + }) + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('starts UNHEALTHY with an empty outcome buffer (no positive evidence yet)', () => { + // Cold-start invariant (HAPI Bot Major fix on PR #803): the gate + // requires at least one observed success before suppressing + // web-push. Otherwise a hub started with broken FCM credentials + // silently drops the first N notifications while waiting for the + // failure threshold to trip. + const store = makeStore([]) + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + expect(svc.isHealthy()).toBe(false) + }) + + it('flips to healthy after the first successful send', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('{"name":"ok"}', { status: 200 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + expect(svc.isHealthy()).toBe(false) + await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + }) + + it('stays unhealthy across a run of failures with no successes (broken-FCM cold start)', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + globalThis.fetch = mock(async () => + new Response('Service Unavailable', { status: 503 }) + ) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // Without any prior success the gate must stay unhealthy regardless + // of where we are in the failure-threshold count. This is the exact + // silent-blackhole window the bot flagged. + for (let i = 0; i < 5; i += 1) { + await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(false) + } + }) + + it('flips back to unhealthy when failures stack past threshold after prior successes', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + let callCount = 0 + globalThis.fetch = mock(async () => { + callCount += 1 + // First 3 succeed, then 503s + if (callCount <= 3) return new Response('{"name":"ok"}', { status: 200 }) + return new Response('Service Unavailable', { status: 503 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // 3 successes establish health + for (let i = 0; i < 3; i += 1) await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // 4 failures: window is [S,S,S,F,F,F,F] - 4 < 5 -> still healthy + for (let i = 0; i < 4; i += 1) await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // 5th failure: [S,S,S,F,F,F,F,F] - 5 >= 5 -> unhealthy + await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(false) + }) + + it('recovers to healthy as recent successes age out the failure tail', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + let callCount = 0 + globalThis.fetch = mock(async () => { + callCount += 1 + // First 5 calls fail (503), rest succeed + if (callCount <= 5) { + return new Response('Service Unavailable', { status: 503 }) + } + return new Response('{"name":"ok"}', { status: 200 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + for (let i = 0; i < 5; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(false) + + // 4 successes after 5 failures: window is [F,F,F,F,F,S,S,S,S] -> trim + // to last 8: [F,F,F,F,S,S,S,S] -> 4 failures, threshold 5 -> healthy. + for (let i = 0; i < 4; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(true) + }) + + it('does NOT count invalid-token responses against health (per-device fact, not pipeline failure)', async () => { + const store = makeStore([ + { namespace: 'default', token: 'good', platform: 'phone', deviceId: 'p1' }, + { namespace: 'default', token: 'rotated', platform: 'phone', deviceId: 'p2' } + ]) + globalThis.fetch = mock(async (url: unknown, init?: unknown) => { + // Different responses per device token. We use the request + // body to discriminate - both calls go to the same URL. + const body = JSON.parse(((init as { body?: string })?.body) ?? '{}') + const token = body?.message?.token + if (token === 'good') return new Response('{"name":"ok"}', { status: 200 }) + return new Response('{"error":{"status":"UNREGISTERED"}}', { status: 404 }) + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // First send produces 1 sent + 1 invalid. After this the rotated + // token is removed from the store, leaving only the good one. + await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // Subsequent successful sends do not record additional outcomes + // for the (now-pruned) invalid token. Health stays true. + for (let i = 0; i < 10; i += 1) { + await svc.sendToNamespace('default', makePayload()) + } + expect(svc.isHealthy()).toBe(true) + }) + + it('counts fetch-throw (network error) as a health failure', async () => { + const store = makeStore([ + { namespace: 'default', token: 't1', platform: 'phone', deviceId: 'p1' } + ]) + let callCount = 0 + globalThis.fetch = mock(async () => { + callCount += 1 + // First few succeed (establish health), rest throw network error + if (callCount <= 3) return new Response('{"name":"ok"}', { status: 200 }) + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never) + + // Establish health with 3 successes + for (let i = 0; i < 3; i += 1) await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(true) + + // 5 network errors stack past threshold and flip health + for (let i = 0; i < 5; i += 1) await svc.sendToNamespace('default', makePayload()) + expect(svc.isHealthy()).toBe(false) + }) +}) diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts new file mode 100644 index 0000000000..fb0cf925e0 --- /dev/null +++ b/hub/src/fcm/fcmService.ts @@ -0,0 +1,277 @@ +import type { Store } from '../store' +import { getFcmAccessToken, FCM_REQUEST_TIMEOUT_MS, type ServiceAccount } from './fcmAuth' + +export type FcmDataPayload = { + type: string + sessionId: string + sessionName: string + url: string + requestId?: string + title: string + body: string + contractVersion: string + /** + * Visual urgency hint for the client. Drives the notification accent + * color on Wear OS / phone, and may be used for sound channel routing + * later. Independent of `type` because `task-notification` is one + * type that splits across success and failure outcomes. + * + * - `info` ready / ambient ('no action needed') -> blue + * - `success` task completed -> green + * - `warning` permission request -> amber + * - `error` task failed / aborted -> red + */ + severity?: 'info' | 'success' | 'warning' | 'error' + /** + * JSON-stringified `AGENT_NOTIFY_SUMMARY` object when the agent emitted + * one as the trailing line of its last message. The companion app may + * use this for richer rendering or future event-bus routing; absent + * when the agent did not emit a summary. + */ + notifySummary?: string +} + +export type FcmSendPayload = { + title: string + body: string + tag?: string + data: FcmDataPayload +} + +type FcmSendResult = { + sent: number + failed: number + invalidTokens: string[] +} + +/** + * Outcome of a single FCM send. We split `failed` into: + * - `invalid`: token is dead and will never succeed (uninstall, rotation, + * malformed). Safe to remove from the device registry. + * - `failed`: transient or out-of-band error (rate limit, 5xx, auth + * glitch). MUST NOT be treated as token death - we'd silently + * unregister live devices on every Google blip. + */ +type FcmTokenSendResult = 'sent' | 'invalid' | 'failed' + +export class FcmService { + /** + * Rolling window of the last N send outcomes. Drives `isHealthy()`, + * which the native-fallback probe consults to decide whether suppressing + * web-push for this namespace is still safe. We deliberately do NOT + * count `invalid` here - an invalid token is a per-device fact, not an + * FCM-pipeline-broken signal (FCM was reachable, it just rejected one + * stale token). Only `sent` and `failed` populate the buffer. + */ + private recentOutcomes: Array<'sent' | 'failed'> = [] + private static readonly HEALTH_WINDOW = 8 + private static readonly HEALTH_FAILURE_THRESHOLD = 5 + + constructor( + private readonly projectId: string, + private readonly serviceAccount: ServiceAccount, + private readonly store: Store + ) {} + + /** + * Health gate for the native-fallback probe. Returns true only when the + * recent-outcome window contains at least one positive datapoint AND + * failures have not stacked past the threshold. When unhealthy, the + * probe lets web-push fire as a last-resort surface for this namespace. + * + * "Needs positive evidence" semantics intentionally: an empty buffer + * (cold-start) and a buffer dominated by failures-only both render + * unhealthy. This closes the silent-blackhole window where a hub with + * broken Firebase credentials would suppress web-push for the first N + * events while waiting for failures to accumulate past the threshold. + * + * Trade-off: one duplicated notification per hub restart per namespace + * (web-push + FCM both fire on event #1; FCM success records `sent` and + * the gate engages from event #2 onward). Worth it for guaranteed + * delivery on cold start. + * + * Addresses HAPI Bot Major review on PR #803. + */ + isHealthy(): boolean { + const successes = this.recentOutcomes.filter((o) => o === 'sent').length + if (successes === 0) return false + const failures = this.recentOutcomes.filter((o) => o === 'failed').length + return failures < FcmService.HEALTH_FAILURE_THRESHOLD + } + + private recordOutcome(outcome: 'sent' | 'failed'): void { + this.recentOutcomes.push(outcome) + if (this.recentOutcomes.length > FcmService.HEALTH_WINDOW) { + this.recentOutcomes.shift() + } + } + + async sendToNamespace(namespace: string, payload: FcmSendPayload): Promise { + const devices = this.store.fcm.getDevicesByNamespace(namespace) + if (devices.length === 0) { + return { sent: 0, failed: 0, invalidTokens: [] } + } + + let accessToken: string + try { + accessToken = await getFcmAccessToken(this.serviceAccount) + } catch (e) { + // Token-fetch failure (expired service account key, OAuth + // outage, network) - count one health-failure (not one per + // device, that would over-weight the buffer) and return. + console.error('[FcmService] Token fetch failed:', e instanceof Error ? e.message : e) + this.recordOutcome('failed') + return { sent: 0, failed: devices.length, invalidTokens: [] } + } + + const invalidTokens: string[] = [] + let sent = 0 + let failed = 0 + + await Promise.all(devices.map(async (device) => { + const result = await this.sendToToken(accessToken, device.token, payload, device.platform) + // `invalid` is a per-device fact, not a pipeline signal - + // exclude it from the health buffer (see field doc above). + if (result === 'sent') { + this.recordOutcome('sent') + } else if (result === 'failed') { + this.recordOutcome('failed') + } + if (result === 'sent') { + sent += 1 + return + } + failed += 1 + if (result === 'invalid') { + invalidTokens.push(device.token) + this.store.fcm.removeDeviceByToken(namespace, device.token) + } + })) + + return { sent, failed, invalidTokens } + } + + private async sendToToken( + accessToken: string, + token: string, + payload: FcmSendPayload, + platform: 'phone' | 'wear' + ): Promise { + const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send` + const dataRecord: Record = { + type: payload.data.type, + sessionId: payload.data.sessionId, + sessionName: payload.data.sessionName, + url: payload.data.url, + title: payload.data.title, + body: payload.data.body, + contractVersion: payload.data.contractVersion + } + if (payload.data.requestId) { + dataRecord.requestId = payload.data.requestId + } + if (payload.data.severity) { + dataRecord.severity = payload.data.severity + } + if (payload.data.notifySummary) { + dataRecord.notifySummary = payload.data.notifySummary + } + + // Data-only: if we also send `notification`, Android does not call + // onMessageReceived while backgrounded — Wear relay never runs. + const message: Record = { + token, + data: dataRecord, + android: { + priority: 'HIGH' + } + } + + if (platform === 'wear') { + message.android = { + ...(message.android as Record), + direct_boot_ok: true + } + } + + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ message }), + signal: AbortSignal.timeout(FCM_REQUEST_TIMEOUT_MS) + }) + } catch (e) { + // Network error (DNS, TCP, TLS) - transient, never a token-death signal. + console.error('[FcmService] Send threw:', e instanceof Error ? e.message : e) + return 'failed' + } + + if (response.ok) { + return 'sent' + } + + const body = await response.text().catch(() => '') + const invalid = this.isInvalidFcmTokenResponse(response.status, body) + if (!invalid) { + console.error('[FcmService] Send failed (transient):', response.status, body.slice(0, 200)) + } + return invalid ? 'invalid' : 'failed' + } + + /** + * Parse FCM v1 error JSON and decide whether the token itself is dead. + * Generic 404/NOT_FOUND (bad project id, missing resource) must not + * unregister live devices — only explicit UNREGISTERED or token-field + * INVALID_ARGUMENT qualifies. + */ + private isInvalidFcmTokenResponse(status: number, body: string): boolean { + type FcmErrorDetail = { + '@type'?: string + errorCode?: string + fieldViolations?: Array<{ field?: string }> + } + + const parsedError = ((): { + error?: { + status?: string + details?: FcmErrorDetail[] + } + } | null => { + try { + return JSON.parse(body) as { + error?: { + status?: string + details?: FcmErrorDetail[] + } + } + } catch { + return null + } + })() + + const errorStatus = parsedError?.error?.status ?? '' + const details = parsedError?.error?.details ?? [] + const fcmErrorCode = details.find((detail) => + detail['@type'] === 'type.googleapis.com/google.firebase.fcm.v1.FcmError' + )?.errorCode ?? '' + const tokenFieldViolation = details.some((detail) => + detail.fieldViolations?.some((violation) => + /message\.token|token/i.test(violation.field ?? '') + ) + ) + + const isUnregistered = status === 404 && ( + fcmErrorCode === 'UNREGISTERED' || errorStatus === 'UNREGISTERED' + ) + const isMalformedToken = status === 400 + && errorStatus === 'INVALID_ARGUMENT' + && (fcmErrorCode === 'INVALID_ARGUMENT' || tokenFieldViolation) + + return isUnregistered || isMalformedToken + } +} diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index bfe109abf7..f9d627e1e7 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -1,6 +1,7 @@ import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' import type { NotificationChannel, NotificationHubOptions, TaskNotification } from './notificationTypes' +import type { NotificationSendContext } from './notificationSendContext' import { extractMessageEventType, extractTaskNotification } from './eventParsing' export class NotificationHub { @@ -182,9 +183,10 @@ export class NotificationHub { } private async notifyReady(session: Session): Promise { + const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { - await channel.sendReady(session) + await channel.sendReady(session, ctx) } catch (error) { console.error('[NotificationHub] Failed to send ready notification:', error) } @@ -192,9 +194,10 @@ export class NotificationHub { } private async notifyPermission(session: Session): Promise { + const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { - await channel.sendPermissionRequest(session) + await channel.sendPermissionRequest(session, ctx) } catch (error) { console.error('[NotificationHub] Failed to send permission notification:', error) } @@ -202,9 +205,10 @@ export class NotificationHub { } private async notifyTask(session: Session, notification: TaskNotification): Promise { + const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { - await channel.sendTaskNotification(session, notification) + await channel.sendTaskNotification(session, notification, ctx) } catch (error) { console.error('[NotificationHub] Failed to send task notification:', error) } diff --git a/hub/src/notifications/notificationSendContext.ts b/hub/src/notifications/notificationSendContext.ts new file mode 100644 index 0000000000..baef669a08 --- /dev/null +++ b/hub/src/notifications/notificationSendContext.ts @@ -0,0 +1,14 @@ +/** + * Per-notification dispatch context shared across channels in one + * NotificationHub notify* call. FcmNotificationChannel runs first and + * sets `nativeGate.sent` when FCM actually delivers; PushNotificationChannel + * consults the same gate before suppressing web-push/SSE (never on stale + * registration/health probes alone). + */ +export type NativeDeliveryGate = { + sent: boolean +} + +export type NotificationSendContext = { + nativeGate?: NativeDeliveryGate +} diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts index 07e2b642c9..6130cf30bd 100644 --- a/hub/src/notifications/notificationTypes.ts +++ b/hub/src/notifications/notificationTypes.ts @@ -1,5 +1,6 @@ import type { Session } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' +import type { NotificationSendContext } from './notificationSendContext' export type TaskNotification = { summary: string @@ -7,9 +8,9 @@ export type TaskNotification = { } export type NotificationChannel = { - sendReady: (session: Session) => Promise - sendPermissionRequest: (session: Session) => Promise - sendTaskNotification: (session: Session, notification: TaskNotification) => Promise + sendReady: (session: Session, ctx?: NotificationSendContext) => Promise + sendPermissionRequest: (session: Session, ctx?: NotificationSendContext) => Promise + sendTaskNotification: (session: Session, notification: TaskNotification, ctx?: NotificationSendContext) => Promise sendSessionCompletion?: (session: Session, reason: SessionEndReason) => Promise } diff --git a/hub/src/notifications/toolArgs.ts b/hub/src/notifications/toolArgs.ts new file mode 100644 index 0000000000..a8092dc6ae --- /dev/null +++ b/hub/src/notifications/toolArgs.ts @@ -0,0 +1,157 @@ +/** + * Tool argument formatters shared across notification channels. + * + * Originally lived inside hub/src/telegram/sessionView.ts. Lifted into + * notifications/ so the FCM (Wear OS) channel can reuse the same + * tool-aware extraction without forking the switch table - keeping + * Telegram and Wear notifications in sync as we add tools. + * + * Two surfaces are exposed: + * + * - `formatToolArgumentsDetailed` - multi-line, Telegram-grade detail. + * Renders inside Telegram bot messages where vertical space is cheap. + * Also rendered by Wear OS when the operator taps the notification + * to expand (BigTextStyle). + * + * - `formatToolArgumentsCompact` - single-line, glance-friendly. + * Squeezed into the wrist's collapsed notification line (~40 chars + * before truncation). The detailed form is the source of truth; the + * compact form is a deliberately-brutal summary of just enough to + * know which file/cmd/url is at stake. + */ + +const DEFAULT_DETAIL_MAX_ARG_LENGTH = 150 + +function truncate(text: string, maxLen: number): string { + if (!text) return '' + if (text.length <= maxLen) return text + return text.slice(0, Math.max(0, maxLen - 3)) + '...' +} + +function shortPath(file: string): string { + if (!file) return '' + const segs = file.split('/') + if (segs.length <= 2) return file + return `.../${segs.slice(-2).join('/')}` +} + +export function formatToolArgumentsDetailed( + tool: string, + args: unknown, + opts: { maxArgLength?: number } = {} +): string { + if (!args || typeof args !== 'object') return '' + const maxLen = opts.maxArgLength ?? DEFAULT_DETAIL_MAX_ARG_LENGTH + const a = args as Record + + try { + switch (tool) { + case 'Edit': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) ?? 'unknown' + const oldStr = a.old_string ? truncate(String(a.old_string), 50) : '' + const newStr = a.new_string ? truncate(String(a.new_string), 50) : '' + let result = `File: ${truncate(file, maxLen)}` + if (oldStr) result += `\nOld: "${oldStr}"` + if (newStr) result += `\nNew: "${newStr}"` + return result + } + case 'Write': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) ?? 'unknown' + const content = a.content ? `${String(a.content).length} chars` : '' + return `File: ${truncate(file, maxLen)}${content ? ` (${content})` : ''}` + } + case 'Read': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) ?? 'unknown' + return `File: ${truncate(file, maxLen)}` + } + case 'Bash': { + const cmd = (a.command as string | undefined) ?? '' + return `Command: ${truncate(cmd, maxLen)}` + } + case 'Agent': + case 'Task': { + const desc = (a.description as string | undefined) ?? (a.prompt as string | undefined) ?? '' + return `Task: ${truncate(desc, maxLen)}` + } + case 'Grep': + case 'Glob': { + const pattern = (a.pattern as string | undefined) ?? '' + const path = (a.path as string | undefined) ?? '' + let result = `Pattern: ${truncate(pattern, maxLen)}` + if (path) result += `\nPath: ${truncate(path, 80)}` + return result + } + case 'WebFetch': { + const url = (a.url as string | undefined) ?? '' + return `URL: ${truncate(url, maxLen)}` + } + case 'TodoWrite': { + const todos = a.todos as unknown[] | undefined + const count = todos?.length ?? 0 + return `Updating ${count} todo items` + } + default: { + const argStr = JSON.stringify(args) + if (argStr && argStr.length > 10) { + return `Args: ${truncate(argStr, maxLen)}` + } + return '' + } + } + } catch { + return '' + } +} + +/** + * Single-line summary tuned for the Wear OS collapsed notification line + * (~40 chars displayable before the system truncates). Always returns + * a one-liner with no embedded newlines. Empty string means we have no + * useful summary to show beyond the tool name itself. + */ +export function formatToolArgumentsCompact(tool: string, args: unknown): string { + if (!args || typeof args !== 'object') return '' + const a = args as Record + + try { + switch (tool) { + case 'Edit': + case 'Write': + case 'Read': { + const file = (a.file_path as string | undefined) ?? (a.path as string | undefined) + if (!file) return '' + return shortPath(file) + } + case 'Bash': { + const cmd = (a.command as string | undefined) ?? '' + return truncate(cmd, 60) + } + case 'Agent': + case 'Task': { + const desc = (a.description as string | undefined) ?? (a.prompt as string | undefined) ?? '' + return truncate(desc, 60) + } + case 'Grep': + case 'Glob': { + const pattern = (a.pattern as string | undefined) ?? '' + return truncate(pattern, 60) + } + case 'WebFetch': { + const url = (a.url as string | undefined) ?? '' + try { + if (url) return new URL(url).host + } catch { /* fall through */ } + return truncate(url, 60) + } + case 'TodoWrite': { + const todos = a.todos as unknown[] | undefined + const count = todos?.length ?? 0 + return `${count} items` + } + default: + return '' + } + } catch { + return '' + } +} diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts index 8ba04c7fbd..bd0d0c330b 100644 --- a/hub/src/push/pushNotificationChannel.test.ts +++ b/hub/src/push/pushNotificationChannel.test.ts @@ -75,4 +75,120 @@ describe('PushNotificationChannel', () => { expect(pushed[0].payload.tag).toBeUndefined() expect(pushed[1].payload.tag).toBeUndefined() }) + + it('skips web-push when native FCM delivered in the same dispatch', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '' + ) + + const ctx = { nativeGate: { sent: true } } + + await channel.sendPermissionRequest(createSession({ + agentState: { + requests: { 'req-1': { tool: 'Bash', arguments: {} } } + } + }), ctx) + await channel.sendReady(createSession(), ctx) + await channel.sendTaskNotification(createSession(), { + status: 'completed', + summary: 'Done' + }, ctx) + + expect(pushed).toHaveLength(0) + }) + + it('falls back to web-push when native gate is unset (FCM failed or absent)', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '' + ) + + await channel.sendReady(createSession(), { nativeGate: { sent: false } }) + + expect(pushed).toHaveLength(1) + }) + + it('still sends web-push when no native gate is provided', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '' + ) + + await channel.sendReady(createSession()) + + expect(pushed).toHaveLength(1) + }) + + it('also skips SSE in-page toast when native gate reports delivery', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const toasts: unknown[] = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async (_namespace: string, event: unknown) => { + toasts.push(event) + return 99 + } + } as never, + { + hasVisibleConnection: () => true + } as never, + '' + ) + + const ctx = { nativeGate: { sent: true } } + + await channel.sendReady(createSession(), ctx) + await channel.sendPermissionRequest(createSession({ + agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } + }), ctx) + await channel.sendTaskNotification(createSession(), { + status: 'completed', + summary: 'Done' + }, ctx) + + // Even when the PWA is foreground/visible, the operator asked to mute + // it - the in-page React toast and the OS web-push are both dropped + // when an FCM companion is on the wrist. + expect(toasts).toHaveLength(0) + expect(pushed).toHaveLength(0) + }) }) diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts index de3dbe8889..bfe751f557 100644 --- a/hub/src/push/pushNotificationChannel.ts +++ b/hub/src/push/pushNotificationChannel.ts @@ -1,5 +1,6 @@ import type { Session } from '../sync/syncEngine' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import type { NotificationSendContext } from '../notifications/notificationSendContext' import { getAgentName, getSessionName } from '../notifications/sessionInfo' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' @@ -13,15 +14,27 @@ export class PushNotificationChannel implements NotificationChannel { _appUrl: string ) {} - async sendPermissionRequest(session: Session): Promise { + /** + * Debug observability: gated on `HAPI_NOTIFY_DEBUG=1`. Lets the operator + * see which branch each notification took so we can root-cause "still + * getting PWA notifications" reports without committing permanent log + * spam to the hub journal. + */ + private logBranch(method: string, namespace: string, branch: string, extra: string = ''): void { + if (process.env.HAPI_NOTIFY_DEBUG !== '1') return + const note = extra ? ` ${extra}` : '' + console.log(`[Push.${method}] ns=${namespace} ${branch}${note}`) + } + + async sendPermissionRequest(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } const name = getSessionName(session) - const request = session.agentState?.requests - ? Object.values(session.agentState.requests)[0] - : null + const requests = session.agentState?.requests ?? null + const requestEntries = requests ? Object.entries(requests) : [] + const [requestId, request] = requestEntries[0] ?? [undefined, null] const toolName = request?.tool ? ` (${request.tool})` : '' const payload: PushPayload = { @@ -31,30 +44,15 @@ export class PushNotificationChannel implements NotificationChannel { data: { type: 'permission-request', sessionId: session.id, - url: this.buildSessionPath(session.id) + url: this.buildSessionPath(session.id), + requestId } } - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - return - } - } - - await this.pushService.sendToNamespace(session.namespace, payload) + await this.deliverWebOrToast(session, payload, ctx, 'permission') } - async sendReady(session: Session): Promise { + async sendReady(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -73,26 +71,10 @@ export class PushNotificationChannel implements NotificationChannel { } } - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - return - } - } - - await this.pushService.sendToNamespace(session.namespace, payload) + await this.deliverWebOrToast(session, payload, ctx, 'ready') } - async sendTaskNotification(session: Session, notification: TaskNotification): Promise { + async sendTaskNotification(session: Session, notification: TaskNotification, ctx?: NotificationSendContext): Promise { if (!session.active) { return } @@ -115,6 +97,20 @@ export class PushNotificationChannel implements NotificationChannel { } } + await this.deliverWebOrToast(session, payload, ctx, 'task') + } + + private async deliverWebOrToast( + session: Session, + payload: PushPayload, + ctx: NotificationSendContext | undefined, + method: 'permission' | 'ready' | 'task' + ): Promise { + if (ctx?.nativeGate?.sent) { + this.logBranch(method, session.namespace, 'defer-to-native', 'fcm-delivered-this-dispatch') + return + } + const url = payload.data?.url ?? this.buildSessionPath(session.id) if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { const delivered = await this.sseManager.sendToast(session.namespace, { @@ -127,10 +123,15 @@ export class PushNotificationChannel implements NotificationChannel { } }) if (delivered > 0) { + this.logBranch(method, session.namespace, 'sse-toast-delivered', `count=${delivered}`) return } + this.logBranch(method, session.namespace, 'sse-toast-zero', 'visible but delivered=0') + } else { + this.logBranch(method, session.namespace, 'not-visible') } + this.logBranch(method, session.namespace, 'web-push-fired') await this.pushService.sendToNamespace(session.namespace, payload) } diff --git a/hub/src/push/pushService.ts b/hub/src/push/pushService.ts index 3a02d1d92e..e44a8fd9ef 100644 --- a/hub/src/push/pushService.ts +++ b/hub/src/push/pushService.ts @@ -10,6 +10,8 @@ export type PushPayload = { type: string sessionId: string url: string + /** First pending permission request id (permission-request pushes only). */ + requestId?: string } } diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 428299ce29..7b443ac47c 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -65,6 +65,155 @@ describe('cli session handlers', () => { expect(webEvents).toHaveLength(0) }) + it('emits a structured todos patch when a TodoWrite message lands (closes second half of #884)', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('todos-session', { path: '/tmp', host: 'h' }, null, 'default') + const socket = new FakeSocket() + const webEvents: SyncEvent[] = [] + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { + throw new Error('unexpected access error') + }, + onWebappEvent: (event) => { + webEvents.push(event) + } + }) + + socket.trigger('message', { + sid: session.id, + message: { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + name: 'TodoWrite', + input: { + todos: [ + { content: 'pending thing', status: 'pending' }, + { content: 'done thing', status: 'completed' } + ] + } + } + ] + } + } + } + } + }) + + const sessionUpdated = webEvents.find((e) => e.type === 'session-updated') + expect(sessionUpdated).toBeDefined() + if (!sessionUpdated || sessionUpdated.type !== 'session-updated') return + expect(sessionUpdated.data).toMatchObject({ + todos: [ + { content: 'pending thing', status: 'pending' }, + { content: 'done thing', status: 'completed' } + ] + }) + expect(typeof (sessionUpdated.data as { updatedAt?: number }).updatedAt).toBe('number') + expect((sessionUpdated.data as { updatedAt?: number }).updatedAt).toBeGreaterThan(0) + }) + + it('emits a structured metadata patch on update-metadata RPC (closes second half of #884)', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession( + 'metadata-patch-session', + { path: '/tmp/project', host: 'example' }, + null, + 'default' + ) + const socket = new FakeSocket() + const webEvents: SyncEvent[] = [] + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { + throw new Error('unexpected access error') + }, + onWebappEvent: (event) => { + webEvents.push(event) + } + }) + + socket.trigger( + 'update-metadata', + { + sid: session.id, + expectedVersion: session.metadataVersion, + metadata: { lifecycleState: 'archived' } + }, + () => {} + ) + + const sessionUpdated = webEvents.find((e) => e.type === 'session-updated') + expect(sessionUpdated).toBeDefined() + if (!sessionUpdated || sessionUpdated.type !== 'session-updated') return + const data = sessionUpdated.data as { metadata?: { version: number; value: Record }; updatedAt?: number } | undefined + expect(data?.metadata?.version).toBe(session.metadataVersion + 1) + // Merged value: original path/host preserved + new lifecycleState applied. + expect(data?.metadata?.value).toMatchObject({ + path: '/tmp/project', + host: 'example', + lifecycleState: 'archived' + }) + expect(typeof data?.updatedAt).toBe('number') + expect(data?.updatedAt).toBeGreaterThanOrEqual(session.updatedAt) + }) + + it('emits a structured agentState patch on update-state RPC (closes second half of #884)', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession( + 'agent-state-patch-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + const socket = new FakeSocket() + const webEvents: SyncEvent[] = [] + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { + throw new Error('unexpected access error') + }, + onWebappEvent: (event) => { + webEvents.push(event) + } + }) + + socket.trigger( + 'update-state', + { + sid: session.id, + expectedVersion: session.agentStateVersion, + agentState: { controlledByUser: true } + }, + () => {} + ) + + const sessionUpdated = webEvents.find((e) => e.type === 'session-updated') + expect(sessionUpdated).toBeDefined() + if (!sessionUpdated || sessionUpdated.type !== 'session-updated') return + const data = sessionUpdated.data as { + agentState?: { version: number; value: { controlledByUser?: boolean } } + updatedAt?: number + } | undefined + expect(data?.agentState?.version).toBe(session.agentStateVersion + 1) + expect(data?.agentState?.value).toMatchObject({ controlledByUser: true }) + expect(typeof data?.updatedAt).toBe('number') + expect(data?.updatedAt).toBeGreaterThanOrEqual(session.updatedAt) + }) + it('update-metadata broadcasts the merged value, not the pre-merge payload', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession( diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 7ffec6dbbe..cf012763e5 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { AgentState, CodexCollaborationMode, Metadata, PermissionMode } from '@hapi/protocol/types' import { isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' @@ -121,7 +121,12 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session if (todos) { const updated = store.sessions.setSessionTodos(sid, todos, msg.createdAt, session.namespace) if (updated) { - onWebappEvent?.({ type: 'session-updated', sessionId: sid }) + const stored = store.sessions.getSession(sid) + onWebappEvent?.({ + type: 'session-updated', + sessionId: sid, + data: { todos, updatedAt: stored?.updatedAt ?? msg.createdAt } + }) } } @@ -132,7 +137,18 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session const newTeamState = applyTeamStateDelta(existingTeamState ?? null, teamDelta) const updated = store.sessions.setSessionTeamState(sid, newTeamState, msg.createdAt, session.namespace) if (updated) { - onWebappEvent?.({ type: 'session-updated', sessionId: sid }) + const stored = store.sessions.getSession(sid) + // Preserve null in the wire payload so TeamDelete events + // tell consumers "clear this" instead of collapsing to an + // empty patch on JSON serialization (`undefined` drops the + // key, leaving consumers to fall back to REST invalidation + // — the very storm path this PR closes). See SessionPatch + // schema comment for the `null` discriminator contract. + onWebappEvent?.({ + type: 'session-updated', + sessionId: sid, + data: { teamState: newTeamState, updatedAt: stored?.updatedAt ?? msg.createdAt } + }) } } @@ -202,6 +218,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } if (result.result === 'success') { + const stored = store.sessions.getSession(sid) const update = { id: randomUUID(), seq: Date.now(), @@ -220,7 +237,19 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } } socket.to(`session:${sid}`).emit('update', update) - onWebappEvent?.({ type: 'session-updated', sessionId: sid }) + onWebappEvent?.({ + type: 'session-updated', + sessionId: sid, + // The unknown-cast here mirrors the schema's MetadataSchema.nullable() + // shape: the store returns raw JSON, the wire schema parses it on + // both ends. Keeping the broadcast shape identical to the socket.io + // `update-session` body (line ~213) lets the same patch travel + // through both fan-out channels without divergence. + data: { + metadata: { version: result.version, value: result.value as Metadata | null }, + updatedAt: stored?.updatedAt ?? Date.now() + } + }) } } @@ -255,6 +284,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } if (result.result === 'success') { + const stored = store.sessions.getSession(sid) const update = { id: randomUUID(), seq: Date.now(), @@ -267,7 +297,14 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } } socket.to(`session:${sid}`).emit('update', update) - onWebappEvent?.({ type: 'session-updated', sessionId: sid }) + onWebappEvent?.({ + type: 'session-updated', + sessionId: sid, + data: { + agentState: { version: result.version, value: agentState as AgentState | null }, + updatedAt: stored?.updatedAt ?? Date.now() + } + }) } } diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 58a4734477..a5df16dbdc 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -11,6 +11,9 @@ import { SSEManager } from './sse/sseManager' import { getOrCreateVapidKeys } from './config/vapidKeys' import { PushService } from './push/pushService' import { PushNotificationChannel } from './push/pushNotificationChannel' +import { FcmService } from './fcm/fcmService' +import { FcmNotificationChannel } from './fcm/fcmNotificationChannel' +import { resolveFcmConfig } from './fcm/fcmConfig' import { VisibilityTracker } from './visibility/visibilityTracker' import { TunnelManager } from './tunnel' import { waitForTunnelTlsReady } from './tunnel/tlsGate' @@ -196,9 +199,33 @@ export async function startHub(options: StartHubOptions = {}): Promise = [] + + if (options.sessionId) { + clauses.push('related_session_id = ?') + params.push(options.sessionId) + } + if (options.attentionCandidate !== undefined && options.attentionCandidate !== null) { + clauses.push('attention_candidate = ?') + params.push(options.attentionCandidate) + } + if (options.eventType) { + clauses.push('event_type = ?') + params.push(options.eventType) + } + if (options.beforeId) { + clauses.push('id < ?') + params.push(options.beforeId) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const rows = db.prepare( + `SELECT * FROM events ${where} ORDER BY id DESC LIMIT ?` + ).all(...params, limit) as SystemEventRow[] + + return rows.map(mapRow) +} + +/** + * Read-only extended event query for the Overseer. Additive over + * {@link listSystemEvents}; the existing route/promotion paths are untouched. + */ +export function queryEvents(db: Database, options: QueryEventsOptions = {}): StoredSystemEvent[] { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + const clauses: string[] = [] + const params: Array = [] + + if (options.sessionId) { + clauses.push('related_session_id = ?') + params.push(options.sessionId) + } + if (options.attentionCandidate !== undefined && options.attentionCandidate !== null) { + clauses.push('attention_candidate = ?') + params.push(options.attentionCandidate) + } + if (options.eventType) { + clauses.push('event_type = ?') + params.push(options.eventType) + } + if (options.sourceKind) { + clauses.push('source_kind = ?') + params.push(options.sourceKind) + } + if (options.severityMin !== undefined && options.severityMin !== null) { + clauses.push('severity >= ?') + params.push(options.severityMin) + } + if (options.sinceTs !== undefined && options.sinceTs !== null) { + clauses.push('ts >= ?') + params.push(options.sinceTs) + } + if (options.untilTs !== undefined && options.untilTs !== null) { + clauses.push('ts <= ?') + params.push(options.untilTs) + } + if (options.project) { + // Denormalized session.project lives in payload_json (#22). + clauses.push("json_extract(payload_json, '$.session.project') = ?") + params.push(options.project) + } + if (options.beforeId) { + clauses.push('id < ?') + params.push(options.beforeId) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const rows = db.prepare( + `SELECT * FROM events ${where} ORDER BY id DESC LIMIT ?` + ).all(...params, limit) as SystemEventRow[] + + return rows.map(mapRow) +} + +export function insertEventLink( + db: Database, + input: { + fromEventId: number + toEventId: number + relationType: string + createdAt: number + metadataJson?: string | null + } +): string { + const id = randomUUID() + db.prepare(` + INSERT INTO event_links (id, from_event_id, to_event_id, relation_type, created_at, metadata_json) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + id, + input.fromEventId, + input.toEventId, + input.relationType, + input.createdAt, + input.metadataJson ?? null + ) + return id +} + +export function countSystemEvents(db: Database): number { + const row = db.prepare('SELECT COUNT(*) AS count FROM events').get() as { count: number } + return row.count +} + +/** + * Idempotent Overseer events DDL — runs on every Store init, NOT gated on SCHEMA_VERSION. + * Additive Overseer tables must never own a version step (soup composability). + */ +export function ensureOverseerEventsSchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + sink_kind TEXT, + sink_ref TEXT, + event_type TEXT NOT NULL, + attention_candidate INTEGER NOT NULL DEFAULT 0, + operator_action_required INTEGER NOT NULL DEFAULT 0, + risk_detected INTEGER NOT NULL DEFAULT 0, + summary TEXT NOT NULL, + payload_json TEXT, + artifact_refs TEXT, + tags TEXT, + related_session_id TEXT REFERENCES sessions(id), + related_event_id INTEGER REFERENCES events(id), + dedupe_key TEXT, + expires_at INTEGER, + provenance TEXT, + idempotency_key TEXT, + confidence REAL, + severity INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_events_session_ts ON events(related_session_id, ts DESC); + CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(event_type, ts DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_dedupe_key ON events(dedupe_key) WHERE dedupe_key IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idempotency_key ON events(idempotency_key) WHERE idempotency_key IS NOT NULL; + + CREATE TABLE IF NOT EXISTS event_links ( + id TEXT PRIMARY KEY, + from_event_id INTEGER NOT NULL REFERENCES events(id), + to_event_id INTEGER NOT NULL REFERENCES events(id), + relation_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_event_links_from ON event_links(from_event_id); + CREATE INDEX IF NOT EXISTS idx_event_links_to ON event_links(to_event_id); + + CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + summary, + tags, + payload_json, + tokenize = 'porter' + ); + `) + + // Recreate triggers every boot so a live DB with dropped/broken triggers self-heals. + db.exec(` + DROP TRIGGER IF EXISTS events_fts_insert; + DROP TRIGGER IF EXISTS events_fts_delete; + DROP TRIGGER IF EXISTS events_fts_update; + + CREATE TRIGGER events_fts_insert AFTER INSERT ON events BEGIN + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + + CREATE TRIGGER events_fts_delete AFTER DELETE ON events BEGIN + DELETE FROM events_fts WHERE rowid = old.id; + END; + + CREATE TRIGGER events_fts_update AFTER UPDATE ON events BEGIN + DELETE FROM events_fts WHERE rowid = old.id; + INSERT INTO events_fts(rowid, summary, tags, payload_json) + VALUES (new.id, new.summary, COALESCE(new.tags, ''), COALESCE(new.payload_json, '')); + END; + `) +} + +/** @deprecated use ensureOverseerEventsSchema */ +export const createEventsSchemaV11 = ensureOverseerEventsSchema + +export function ensureDeletedSessionsSchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS deleted_sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + name TEXT, + project TEXT, + flavor TEXT, + deleted_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deleted_sessions_deleted_at + ON deleted_sessions(deleted_at DESC); + `) +} + +export function tombstoneDeletedSession( + db: Database, + identity: OverseerSessionIdentity, + deletedAt: number +): void { + db.prepare(` + INSERT INTO deleted_sessions (id, tag, name, project, flavor, deleted_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + tag = excluded.tag, + name = excluded.name, + project = excluded.project, + flavor = excluded.flavor, + deleted_at = excluded.deleted_at + `).run( + identity.id, + identity.tag, + identity.name, + identity.project, + identity.flavor, + deletedAt + ) +} + +/** Drop Overseer events tables (db-prep / layer removal). Does NOT change user_version. */ +export function dropOverseerEventsSchema(db: Database): void { + db.exec(` + DROP TRIGGER IF EXISTS events_fts_delete; + DROP TRIGGER IF EXISTS events_fts_update; + DROP TRIGGER IF EXISTS events_fts_insert; + DROP TABLE IF EXISTS events_fts; + DROP INDEX IF EXISTS idx_events_idempotency_key; + DROP INDEX IF EXISTS idx_event_links_to; + DROP INDEX IF EXISTS idx_event_links_from; + DROP TABLE IF EXISTS event_links; + DROP INDEX IF EXISTS idx_events_dedupe_key; + DROP INDEX IF EXISTS idx_events_type_ts; + DROP INDEX IF EXISTS idx_events_session_ts; + DROP TABLE IF EXISTS events; + DROP TABLE IF EXISTS deleted_sessions; + `) +} + +/** @deprecated use dropOverseerEventsSchema — events no longer own SCHEMA_VERSION */ +export function downgradeEventsSchemaV11ToV10(db: Database): void { + dropOverseerEventsSchema(db) +} diff --git a/hub/src/store/fcmDevices.test.ts b/hub/src/store/fcmDevices.test.ts new file mode 100644 index 0000000000..59e25f892b --- /dev/null +++ b/hub/src/store/fcmDevices.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' + +describe('fcmDevices upsert', () => { + it('moves a token to a new namespace and removes the old namespace row', () => { + const store = new Store(':memory:') + const device = { token: 'shared-token', platform: 'phone' as const, deviceId: 'pixel-1' } + + store.fcm.upsertDevice('namespace-a', device) + store.fcm.upsertDevice('namespace-b', device) + + expect(store.fcm.getDevicesByNamespace('namespace-a')).toHaveLength(0) + expect(store.fcm.getDevicesByNamespace('namespace-b')).toHaveLength(1) + expect(store.fcm.getDevicesByNamespace('namespace-b')[0].token).toBe('shared-token') + }) +}) diff --git a/hub/src/store/fcmDevices.ts b/hub/src/store/fcmDevices.ts new file mode 100644 index 0000000000..a451bb001e --- /dev/null +++ b/hub/src/store/fcmDevices.ts @@ -0,0 +1,75 @@ +import type { Database } from 'bun:sqlite' + +import type { StoredFcmDevice } from './types' + +type DbFcmDeviceRow = { + id: number + namespace: string + token: string + platform: string + device_id: string + created_at: number + updated_at: number +} + +function toStoredFcmDevice(row: DbFcmDeviceRow): StoredFcmDevice { + return { + id: row.id, + namespace: row.namespace, + token: row.token, + platform: row.platform as StoredFcmDevice['platform'], + deviceId: row.device_id, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function upsertFcmDevice( + db: Database, + namespace: string, + device: { token: string; platform: 'phone' | 'wear'; deviceId: string } +): void { + const now = Date.now() + const params = { + namespace, + token: device.token, + platform: device.platform, + device_id: device.deviceId, + created_at: now, + updated_at: now + } + + db.transaction(() => { + // One FCM token must not deliver across namespaces. Re-pairing the + // same native install under a new namespace drops stale rows that + // still reference this token elsewhere. + db.prepare(` + DELETE FROM fcm_devices + WHERE token = @token + AND (namespace != @namespace OR device_id != @device_id OR platform != @platform) + `).run(params) + + db.prepare(` + INSERT INTO fcm_devices ( + namespace, token, platform, device_id, created_at, updated_at + ) VALUES ( + @namespace, @token, @platform, @device_id, @created_at, @updated_at + ) + ON CONFLICT(namespace, device_id, platform) + DO UPDATE SET + token = excluded.token, + updated_at = excluded.updated_at + `).run(params) + })() +} + +export function removeFcmDeviceByToken(db: Database, namespace: string, token: string): void { + db.prepare('DELETE FROM fcm_devices WHERE namespace = ? AND token = ?').run(namespace, token) +} + +export function getFcmDevicesByNamespace(db: Database, namespace: string): StoredFcmDevice[] { + const rows = db.prepare( + 'SELECT * FROM fcm_devices WHERE namespace = ? ORDER BY updated_at DESC' + ).all(namespace) as DbFcmDeviceRow[] + return rows.map(toStoredFcmDevice) +} diff --git a/hub/src/store/fcmStore.ts b/hub/src/store/fcmStore.ts new file mode 100644 index 0000000000..b90ca1bbe6 --- /dev/null +++ b/hub/src/store/fcmStore.ts @@ -0,0 +1,23 @@ +import type { Database } from 'bun:sqlite' + +import type { StoredFcmDevice } from './types' +import { getFcmDevicesByNamespace, removeFcmDeviceByToken, upsertFcmDevice } from './fcmDevices' + +export class FcmStore { + constructor(private readonly db: Database) {} + + upsertDevice( + namespace: string, + device: { token: string; platform: 'phone' | 'wear'; deviceId: string } + ): void { + upsertFcmDevice(this.db, namespace, device) + } + + removeDeviceByToken(namespace: string, token: string): void { + removeFcmDeviceByToken(this.db, namespace, token) + } + + getDevicesByNamespace(namespace: string): StoredFcmDevice[] { + return getFcmDevicesByNamespace(this.db, namespace) + } +} diff --git a/hub/src/store/inboxItems.test.ts b/hub/src/store/inboxItems.test.ts new file mode 100644 index 0000000000..b01933ed86 --- /dev/null +++ b/hub/src/store/inboxItems.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'bun:test' +import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hapi/protocol' +import { Store } from './index' +import type { StoredSession } from './types' +import { deleteSession } from './sessions' +import { Database } from 'bun:sqlite' + +function payloadForSession(session: StoredSession, extra: Record = {}): string { + const metadata = session.metadata as { flavor?: string; name?: string; path?: string } | null + return mergeEventPayloadWithSession(extra, buildOverseerSessionIdentity({ + id: session.id, + flavor: metadata?.flavor ?? 'codex', + tag: session.tag, + metadata + })) +} + +describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => { + it('fresh DB has inbox tables after Store init', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('inbox_items', 'inbox_item_source_events', 'inbox_operator_actions')" + ).all() as Array<{ name: string }> + const names = new Set(tables.map((row) => row.name)) + expect(names.has('inbox_items')).toBe(true) + expect(names.has('inbox_item_source_events')).toBe(true) + expect(names.has('inbox_operator_actions')).toBe(true) + }) + + it('promotes attention events into one active item per session', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('inbox-promo', { flavor: 'codex', name: 'peer-x' }, null, 'default') + + const blocked = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'CI failed', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + expect(blocked).not.toBeNull() + store.inbox.promoteAttentionEvent(blocked!) + + let items = store.inbox.list({ activeOnly: true }) + expect(items).toHaveLength(1) + expect(items[0]?.category).toBe('BLOCKED') + expect(items[0]?.title).toBe('peer-x') + expect(items[0]?.sourceEventIds).toEqual([blocked!.id]) + + const review = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'needs_review', + attentionCandidate: 1, + summary: 'Please review diff', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(review!) + + items = store.inbox.list({ activeOnly: true }) + expect(items).toHaveLength(1) + expect(items[0]?.sourceEventIds.sort()).toEqual([blocked!.id, review!.id].sort()) + expect(items[0]?.category).toBe('REVIEW') + }) + + it('orders active items by coarse rank then oldest-first within tier', () => { + const store = new Store(':memory:') + const sessionA = store.sessions.getOrCreateSession('sess-a', { name: 'a' }, null, 'default') + const sessionB = store.sessions.getOrCreateSession('sess-b', { name: 'b' }, null, 'default') + const sessionC = store.sessions.getOrCreateSession('sess-c', { name: 'c' }, null, 'default') + + const oldBlocked = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'old blocked', + relatedSessionId: sessionA.id, + payloadJson: payloadForSession(sessionA), + provenance: 'test' + }) + const newBlocked = store.events.insert({ + ts: 5000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'new blocked', + relatedSessionId: sessionB.id, + payloadJson: payloadForSession(sessionB), + provenance: 'test' + }) + const approval = store.events.insert({ + ts: 3000, + sourceKind: 'worker', + eventType: 'approval_requested', + attentionCandidate: 1, + summary: 'approve push', + relatedSessionId: sessionC.id, + payloadJson: payloadForSession(sessionC), + provenance: 'test' + }) + + store.inbox.promoteAttentionEvent(oldBlocked!) + store.inbox.promoteAttentionEvent(newBlocked!) + store.inbox.promoteAttentionEvent(approval!) + + const items = store.inbox.list({ activeOnly: true }) + expect(items.map((item) => item.title)).toEqual(['c', 'a', 'b']) + }) + + it('uses artifact ref as title when present', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('artifact', { name: 'fallback-name' }, null, 'default') + const refs = JSON.stringify([{ kind: 'github_pr', title: 'feat: inbox substrate' }]) + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'needs_decision', + attentionCandidate: 1, + summary: 'Need merge approval', + artifactRefs: refs, + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(event!) + const item = store.inbox.list()[0] + expect(item?.title).toBe('feat: inbox substrate') + expect(item?.reasonForPriority).toContain('QUESTION tier') + }) + + it('records operator actions as training labels', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('actions', { name: 'actions' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'blocked', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(event!) + const itemId = store.inbox.list()[0]!.id + + const updated = store.inbox.recordOperatorAction(itemId, 'done', 'handled offline') + expect(updated?.status).toBe('resolved') + expect(updated?.operatorFeedback).toBe('handled offline') + + const db: Database = (store as unknown as { db: Database }).db + const actions = db.prepare( + 'SELECT action, status_after FROM inbox_operator_actions WHERE inbox_item_id = ?' + ).all(itemId) as Array<{ action: string; status_after: string }> + expect(actions).toEqual([{ action: 'done', status_after: 'resolved' }]) + }) + + it('deleteSession detaches inbox_items instead of FK-failing', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('inbox-del-tag', {}, null, 'default') + const db: Database = (store as unknown as { db: Database }).db + db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, source_event_ids, related_inbox_ids, + attention_class, created_at, updated_at, related_session_id, title, category, summary + ) VALUES ( + 'new', 10, 10, '[]', '[]', 'live', 1, 1, ?, 't', 'BLOCKED', 's' + ) + `).run(session.id) + + expect(deleteSession(db, session.id, 'default')).toBe(true) + const row = db.prepare( + 'SELECT related_session_id FROM inbox_items LIMIT 1' + ).get() as { related_session_id: string | null } + expect(row.related_session_id).toBeNull() + }) + + it('source_event junction supports lookup by event id', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('join', { name: 'join' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'failed', + attentionCandidate: 1, + summary: 'failed', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!) + const db: Database = (store as unknown as { db: Database }).db + const link = db.prepare( + 'SELECT inbox_item_id FROM inbox_item_source_events WHERE event_id = ?' + ).get(event!.id) as { inbox_item_id: number } + expect(link.inbox_item_id).toBe(item!.id) + }) + + it('keeps denormalized title after session delete', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('gone', { name: 'meta HAPI triage' }, null, 'default') + const event = store.events.insert({ + ts: 1000, + sourceKind: 'worker', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'blocked', + relatedSessionId: session.id, + payloadJson: payloadForSession(session), + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!) + expect(item?.title).toBe('meta HAPI triage') + + expect(store.sessions.deleteSession(session.id, 'default')).toBe(true) + + const after = store.inbox.getById(item!.id) + expect(after?.title).toBe('meta HAPI triage') + expect(after?.relatedSessionId).toBeNull() + }) +}) diff --git a/hub/src/store/inboxItems.ts b/hub/src/store/inboxItems.ts new file mode 100644 index 0000000000..ed45580ceb --- /dev/null +++ b/hub/src/store/inboxItems.ts @@ -0,0 +1,416 @@ +import type { Database } from 'bun:sqlite' +import { + buildExplainPriority, + buildInboxTitleFromEvent, + computeCoarseBasePriority, + isActiveInboxStatus, + mapEventTypeToInboxCategory, + mapOperatorActionToStatus, + type InboxOperatorAction +} from '@hapi/protocol' +import type { StoredSystemEvent } from './events' +import { getSystemEventById, listSystemEvents } from './events' + +export type StoredInboxItem = { + id: number + status: string + priority: number + basePriority: number + agingFactor: number | null + timeCriticality: number | null + decayAfter: number | null + reasonForPriority: string | null + sourceEventIds: number[] + relatedInboxIds: number[] + artifactRefs: string | null + suggestedAction: string | null + deadline: number | null + operatorFeedback: string | null + surfacedAt: number | null + resolvedAt: number | null + snoozedUntil: number | null + attentionClass: string + breakpointClass: string | null + createdAt: number + updatedAt: number + relatedSessionId: string | null + title: string + category: string + summary: string +} + +export type StoredInboxOperatorAction = { + id: number + inboxItemId: number + action: InboxOperatorAction + statusAfter: string + feedback: string | null + createdAt: number +} + +export type ListInboxItemsOptions = { + limit?: number + activeOnly?: boolean + sessionId?: string | null + /** Explicit status allow-list (overrides activeOnly when set). */ + statuses?: string[] | null + category?: string | null +} + +type InboxItemRow = { + id: number + status: string + priority: number + base_priority: number + aging_factor: number | null + time_criticality: number | null + decay_after: number | null + reason_for_priority: string | null + source_event_ids: string | null + related_inbox_ids: string | null + artifact_refs: string | null + suggested_action: string | null + deadline: number | null + operator_feedback: string | null + surfaced_at: number | null + resolved_at: number | null + snoozed_until: number | null + attention_class: string + breakpoint_class: string | null + created_at: number + updated_at: number + related_session_id: string | null + title: string + category: string + summary: string +} + +function parseIdArray(raw: string | null | undefined): number[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((value): value is number => typeof value === 'number') + } catch { + return [] + } +} + +function mapRow(row: InboxItemRow): StoredInboxItem { + return { + id: row.id, + status: row.status, + priority: row.priority, + basePriority: row.base_priority, + agingFactor: row.aging_factor, + timeCriticality: row.time_criticality, + decayAfter: row.decay_after, + reasonForPriority: row.reason_for_priority, + sourceEventIds: parseIdArray(row.source_event_ids), + relatedInboxIds: parseIdArray(row.related_inbox_ids), + artifactRefs: row.artifact_refs, + suggestedAction: row.suggested_action, + deadline: row.deadline, + operatorFeedback: row.operator_feedback, + surfacedAt: row.surfaced_at, + resolvedAt: row.resolved_at, + snoozedUntil: row.snoozed_until, + attentionClass: row.attention_class, + breakpointClass: row.breakpoint_class, + createdAt: row.created_at, + updatedAt: row.updated_at, + relatedSessionId: row.related_session_id, + title: row.title, + category: row.category, + summary: row.summary + } +} + +function syncSourceEventLinks(db: Database, inboxItemId: number, eventIds: number[]): void { + db.prepare('DELETE FROM inbox_item_source_events WHERE inbox_item_id = ?').run(inboxItemId) + const insert = db.prepare( + 'INSERT OR IGNORE INTO inbox_item_source_events (inbox_item_id, event_id) VALUES (?, ?)' + ) + for (const eventId of eventIds) { + insert.run(inboxItemId, eventId) + } +} + +/** Clear session FK refs so DELETE FROM sessions succeeds (items are audit-retained). */ +export function detachSessionInboxItems(db: Database, sessionId: string): number { + const result = db.prepare( + 'UPDATE inbox_items SET related_session_id = NULL WHERE related_session_id = ?' + ).run(sessionId) + return result.changes +} + +export function repointSessionInboxItems(db: Database, fromSessionId: string, toSessionId: string): number { + if (fromSessionId === toSessionId) return 0 + const countRow = db.prepare( + 'SELECT COUNT(*) as count FROM inbox_items WHERE related_session_id = ?' + ).get(fromSessionId) as { count: number } + const pending = countRow.count + if (pending === 0) return 0 + db.prepare( + 'UPDATE inbox_items SET related_session_id = ? WHERE related_session_id = ?' + ).run(toSessionId, fromSessionId) + return pending +} + +export function getInboxItemById(db: Database, id: number): StoredInboxItem | null { + const row = db.prepare('SELECT * FROM inbox_items WHERE id = ?').get(id) as InboxItemRow | undefined + return row ? mapRow(row) : null +} + +export function countInboxItems(db: Database): number { + const row = db.prepare('SELECT COUNT(*) AS count FROM inbox_items').get() as { count: number } + return row.count +} + +export function listInboxItems(db: Database, options: ListInboxItemsOptions = {}): StoredInboxItem[] { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200) + const clauses: string[] = [] + const params: Array = [] + + if (options.statuses && options.statuses.length > 0) { + const placeholders = options.statuses.map(() => '?').join(', ') + clauses.push(`status IN (${placeholders})`) + params.push(...options.statuses) + } else if (options.activeOnly) { + clauses.push("status IN ('new', 'surfaced', 'deferred', 'snoozed')") + } + if (options.sessionId) { + clauses.push('related_session_id = ?') + params.push(options.sessionId) + } + if (options.category) { + clauses.push('category = ?') + params.push(options.category) + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const rows = db.prepare( + `SELECT * FROM inbox_items ${where} + ORDER BY base_priority ASC, created_at ASC + LIMIT ?` + ).all(...params, limit) as InboxItemRow[] + + return rows.map(mapRow) +} + +export function findActiveInboxItemForSession(db: Database, sessionId: string): StoredInboxItem | null { + const row = db.prepare(` + SELECT * FROM inbox_items + WHERE related_session_id = ? + AND status IN ('new', 'surfaced', 'deferred', 'snoozed') + ORDER BY updated_at DESC + LIMIT 1 + `).get(sessionId) as InboxItemRow | undefined + return row ? mapRow(row) : null +} + +export function promoteAttentionEvent( + db: Database, + event: StoredSystemEvent +): StoredInboxItem | null { + if (event.attentionCandidate !== 1) return null + if (!event.relatedSessionId) return null + + const now = Date.now() + const category = mapEventTypeToInboxCategory(event.eventType) + const basePriority = computeCoarseBasePriority(event.eventType) + const title = buildInboxTitleFromEvent(event.artifactRefs, event.payloadJson, event.summary) + const suggestedAction = extractSuggestedAction(event.payloadJson) + const existing = findActiveInboxItemForSession(db, event.relatedSessionId) + + const sourceEventIds = existing + ? Array.from(new Set([...existing.sourceEventIds, event.id])) + : [event.id] + + const reasonForPriority = buildExplainPriority(category, existing?.createdAt ?? event.ts, sourceEventIds, now) + + if (existing) { + db.prepare(` + UPDATE inbox_items SET + status = 'surfaced', + priority = ?, + base_priority = ?, + reason_for_priority = ?, + source_event_ids = ?, + artifact_refs = COALESCE(?, artifact_refs), + suggested_action = COALESCE(?, suggested_action), + title = ?, + category = ?, + summary = ?, + updated_at = ?, + surfaced_at = COALESCE(surfaced_at, ?) + WHERE id = ? + `).run( + basePriority, + basePriority, + reasonForPriority, + JSON.stringify(sourceEventIds), + event.artifactRefs, + suggestedAction, + title, + category, + event.summary, + now, + now, + existing.id + ) + syncSourceEventLinks(db, existing.id, sourceEventIds) + return getInboxItemById(db, existing.id) + } + + const insert = db.prepare(` + INSERT INTO inbox_items ( + status, priority, base_priority, aging_factor, time_criticality, decay_after, + reason_for_priority, source_event_ids, related_inbox_ids, artifact_refs, + suggested_action, deadline, operator_feedback, surfaced_at, resolved_at, + snoozed_until, attention_class, breakpoint_class, created_at, updated_at, + related_session_id, title, category, summary + ) VALUES ( + 'new', ?, ?, NULL, NULL, NULL, + ?, ?, '[]', ?, + ?, NULL, NULL, ?, NULL, + NULL, 'live', NULL, ?, ?, + ?, ?, ?, ? + ) + `) + + const result = insert.run( + basePriority, + basePriority, + reasonForPriority, + JSON.stringify(sourceEventIds), + event.artifactRefs, + suggestedAction, + now, + event.ts, + now, + event.relatedSessionId, + title, + category, + event.summary + ) + + const id = Number(result.lastInsertRowid) + syncSourceEventLinks(db, id, sourceEventIds) + return getInboxItemById(db, id) +} + +export function recordInboxOperatorAction( + db: Database, + inboxItemId: number, + action: InboxOperatorAction, + feedback: string | null = null, + snoozedUntil: number | null = null +): StoredInboxItem | null { + const item = getInboxItemById(db, inboxItemId) + if (!item) return null + + const now = Date.now() + const statusAfter = mapOperatorActionToStatus(action) + db.prepare(` + INSERT INTO inbox_operator_actions (inbox_item_id, action, status_after, feedback, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(inboxItemId, action, statusAfter, feedback, now) + + const resolvedAt = statusAfter === 'resolved' || statusAfter === 'obsoleted' ? now : null + db.prepare(` + UPDATE inbox_items SET + status = ?, + operator_feedback = ?, + updated_at = ?, + snoozed_until = ?, + resolved_at = COALESCE(?, resolved_at) + WHERE id = ? + `).run(statusAfter, feedback, now, snoozedUntil, resolvedAt, inboxItemId) + + return getInboxItemById(db, inboxItemId) +} + +function extractSuggestedAction(payloadJson: string | null): string | null { + if (!payloadJson) return null + try { + const payload = JSON.parse(payloadJson) as { suggested_action?: unknown; notify_summary?: { action?: unknown } } + if (typeof payload.suggested_action === 'string' && payload.suggested_action.trim()) { + return payload.suggested_action.trim() + } + if (typeof payload.notify_summary?.action === 'string' && payload.notify_summary.action.trim()) { + return payload.notify_summary.action.trim() + } + } catch { + return null + } + return null +} + +/** + * Idempotent Overseer inbox DDL — runs on every Store init, NOT gated on SCHEMA_VERSION. + */ +export function ensureOverseerInboxSchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS inbox_items ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + priority REAL NOT NULL, + base_priority REAL NOT NULL, + aging_factor REAL, + time_criticality REAL, + decay_after INTEGER, + reason_for_priority TEXT, + source_event_ids TEXT, + related_inbox_ids TEXT, + artifact_refs TEXT, + suggested_action TEXT, + deadline INTEGER, + operator_feedback TEXT, + surfaced_at INTEGER, + resolved_at INTEGER, + snoozed_until INTEGER, + attention_class TEXT NOT NULL DEFAULT 'live', + breakpoint_class TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + related_session_id TEXT REFERENCES sessions(id), + title TEXT NOT NULL, + category TEXT NOT NULL, + summary TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_inbox_items_session ON inbox_items(related_session_id); + CREATE INDEX IF NOT EXISTS idx_inbox_items_queue ON inbox_items(status, base_priority, created_at); + + CREATE TABLE IF NOT EXISTS inbox_item_source_events ( + inbox_item_id INTEGER NOT NULL REFERENCES inbox_items(id) ON DELETE CASCADE, + event_id INTEGER NOT NULL REFERENCES events(id), + PRIMARY KEY (inbox_item_id, event_id) + ); + CREATE INDEX IF NOT EXISTS idx_inbox_item_source_events_event ON inbox_item_source_events(event_id); + + CREATE TABLE IF NOT EXISTS inbox_operator_actions ( + id INTEGER PRIMARY KEY, + inbox_item_id INTEGER NOT NULL REFERENCES inbox_items(id) ON DELETE CASCADE, + action TEXT NOT NULL, + status_after TEXT NOT NULL, + feedback TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_inbox_operator_actions_item ON inbox_operator_actions(inbox_item_id, created_at DESC); + `) +} + +export function dropOverseerInboxSchema(db: Database): void { + db.exec(` + DROP INDEX IF EXISTS idx_inbox_operator_actions_item; + DROP TABLE IF EXISTS inbox_operator_actions; + DROP INDEX IF EXISTS idx_inbox_item_source_events_event; + DROP TABLE IF EXISTS inbox_item_source_events; + DROP INDEX IF EXISTS idx_inbox_items_queue; + DROP INDEX IF EXISTS idx_inbox_items_session; + DROP TABLE IF EXISTS inbox_items; + `) +} + +export { isActiveInboxStatus, listSystemEvents, getSystemEventById } diff --git a/hub/src/store/inboxStore.ts b/hub/src/store/inboxStore.ts new file mode 100644 index 0000000000..35e6d3ce7f --- /dev/null +++ b/hub/src/store/inboxStore.ts @@ -0,0 +1,53 @@ +import type { Database } from 'bun:sqlite' +import type { InboxOperatorAction } from '@hapi/protocol' +import type { StoredSystemEvent } from './events' +import { + countInboxItems, + findActiveInboxItemForSession, + getInboxItemById, + listInboxItems, + promoteAttentionEvent, + recordInboxOperatorAction, + repointSessionInboxItems, + type ListInboxItemsOptions, + type StoredInboxItem +} from './inboxItems' + +export type { ListInboxItemsOptions, StoredInboxItem } + +export class InboxStore { + constructor(private readonly db: Database) {} + + promoteAttentionEvent(event: StoredSystemEvent): StoredInboxItem | null { + return promoteAttentionEvent(this.db, event) + } + + list(options: ListInboxItemsOptions = {}): StoredInboxItem[] { + return listInboxItems(this.db, options) + } + + count(): number { + return countInboxItems(this.db) + } + + getById(id: number): StoredInboxItem | null { + return getInboxItemById(this.db, id) + } + + findActiveForSession(sessionId: string): StoredInboxItem | null { + return findActiveInboxItemForSession(this.db, sessionId) + } + + recordOperatorAction( + inboxItemId: number, + action: InboxOperatorAction, + feedback: string | null = null, + snoozedUntil: number | null = null + ): StoredInboxItem | null { + return recordInboxOperatorAction(this.db, inboxItemId, action, feedback, snoozedUntil) + } + + repointSession(fromSessionId: string, toSessionId: string): number { + return repointSessionInboxItems(this.db, fromSessionId, toSessionId) + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index b0b2c6b05d..366b9b3fac 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -5,13 +5,21 @@ import { dirname } from 'node:path' import { MachineStore } from './machineStore' import { MessageStore } from './messageStore' import { PushStore } from './pushStore' +import { FcmStore } from './fcmStore' +import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' +import { EventStore } from './eventStore' +import { InboxStore } from './inboxStore' +import { ensureOverseerEventsSchema, ensureDeletedSessionsSchema } from './events' +import { ensureOverseerInboxSchema } from './inboxItems' export type { StoredMachine, StoredMessage, StoredPushSubscription, + StoredFcmDevice, + StoredScratchlistEntry, StoredSession, StoredUser, VersionedUpdateResult @@ -20,16 +28,30 @@ export type { CancelQueuedMessageResult, LookupQueuedMessageResult } from './mes export { MachineStore } from './machineStore' export { MessageStore } from './messageStore' export { PushStore } from './pushStore' +export { FcmStore } from './fcmStore' +export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' +export { EventStore } from './eventStore' +export { InboxStore } from './inboxStore' +export type { InsertSystemEventInput, ListSystemEventsOptions, StoredSystemEvent } from './eventStore' +export type { ListInboxItemsOptions, StoredInboxItem } from './inboxStore' -const SCHEMA_VERSION: number = 10 +const SCHEMA_VERSION: number = 11 const REQUIRED_TABLES = [ 'sessions', 'machines', 'messages', 'users', - 'push_subscriptions' + 'push_subscriptions', + 'fcm_devices', + 'session_scratchlist', + 'events', + 'event_links', + 'deleted_sessions', + 'inbox_items', + 'inbox_item_source_events', + 'inbox_operator_actions' ] as const export class Store { @@ -42,6 +64,10 @@ export class Store { readonly messages: MessageStore readonly users: UserStore readonly push: PushStore + readonly fcm: FcmStore + readonly scratchlist: ScratchlistStore + readonly events: EventStore + readonly inbox: InboxStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -92,6 +118,10 @@ export class Store { this.messages = new MessageStore(this.db) this.users = new UserStore(this.db) this.push = new PushStore(this.db) + this.fcm = new FcmStore(this.db) + this.scratchlist = new ScratchlistStore(this.db) + this.events = new EventStore(this.db) + this.inbox = new InboxStore(this.db) } close(): void { @@ -124,6 +154,7 @@ export class Store { 7: () => this.migrateFromV7ToV8(), 8: () => this.migrateFromV8ToV9(), 9: () => this.migrateFromV9ToV10(), + 10: () => this.migrateFromV10ToV11(), }) if (currentVersion === 0) { @@ -143,11 +174,13 @@ export class Store { // a partially-built legacy DB may not have yet. this.createSchema() this.setUserVersion(SCHEMA_VERSION) + this.finishSchemaInit() return } this.createSchema() this.setUserVersion(SCHEMA_VERSION) + this.finishSchemaInit() return } @@ -159,6 +192,7 @@ export class Store { step() } this.setUserVersion(SCHEMA_VERSION) + this.finishSchemaInit() return } @@ -166,6 +200,14 @@ export class Store { throw this.buildSchemaMismatchError(currentVersion) } + this.finishSchemaInit() + } + + /** Idempotent Overseer self-heal + loud missing-table check on every boot path. */ + private finishSchemaInit(): void { + ensureOverseerEventsSchema(this.db) + ensureDeletedSessionsSchema(this.db) + ensureOverseerInboxSchema(this.db) this.assertRequiredTablesPresent() } @@ -252,6 +294,32 @@ export class Store { UNIQUE(namespace, endpoint) ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); + + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); + + CREATE TABLE IF NOT EXISTS session_scratchlist ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, entry_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created + ON session_scratchlist(session_id, created_at DESC); + `) } @@ -435,6 +503,35 @@ export class Store { } } + private migrateFromV10ToV11(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); + + CREATE TABLE IF NOT EXISTS session_scratchlist ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, entry_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created + ON session_scratchlist(session_id, created_at DESC); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v10.test.ts b/hub/src/store/migration-v10.test.ts new file mode 100644 index 0000000000..710f21e00a --- /dev/null +++ b/hub/src/store/migration-v10.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Store } from './index' + +describe('Store V10→V11 migration: fcm_devices', () => { + it('fresh DB has fcm_devices table', () => { + const store = new Store(':memory:') + expect(tableExists(store, 'fcm_devices')).toBe(true) + }) + + it('V10 DB migrates to V11: fcm_devices created', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-test-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + db.exec('PRAGMA user_version = 10') + db.close() + + store = new Store(dbPath) + expect(tableExists(store, 'fcm_devices')).toBe(true) + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('upsert replaces token for same namespace+deviceId+platform', () => { + const store = new Store(':memory:') + store.fcm.upsertDevice('default', { + token: 'tok-a', + platform: 'phone', + deviceId: 'pixel-1' + }) + store.fcm.upsertDevice('default', { + token: 'tok-b', + platform: 'phone', + deviceId: 'pixel-1' + }) + const devices = store.fcm.getDevicesByNamespace('default') + expect(devices).toHaveLength(1) + expect(devices[0].token).toBe('tok-b') + }) +}) + +function tableExists(store: Store, name: string): boolean { + const db: Database = (store as unknown as { db: Database }).db + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?" + ).get(name) as { name: string } | null + return row !== null +} + +function createV10Schema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', + machine_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER DEFAULT 1, + model TEXT, + model_reasoning_effort TEXT, + effort TEXT, + service_tier TEXT, + todos TEXT, + todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT, + invoked_at INTEGER, + scheduled_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + `) +} diff --git a/hub/src/store/migration-v11-soup-combined.test.ts b/hub/src/store/migration-v11-soup-combined.test.ts new file mode 100644 index 0000000000..d89b1b1cd5 --- /dev/null +++ b/hub/src/store/migration-v11-soup-combined.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Store } from './index' +import { applySoupV10ToV11Migration, SOUP_V11_TABLES } from './schemaV11Soup' + +describe('Store soup combined V10→V11 migration', () => { + it('applySoupV10ToV11Migration plus Store init creates full soup v11 surface', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-soup-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + db.exec('PRAGMA user_version = 10') + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + applySoupV10ToV11Migration(db) + db.exec('PRAGMA user_version = 11') + db.close() + + store = new Store(dbPath) + const dbAfter: Database = (store as unknown as { db: Database }).db + for (const table of SOUP_V11_TABLES) { + expect(tableExists(dbAfter, table), `missing ${table}`).toBe(true) + } + + dbAfter.exec(`INSERT INTO fcm_devices (namespace, token, platform, device_id, created_at, updated_at) + VALUES ('default', 'tok', 'phone', 'dev-1', 1000, 1000)`) + dbAfter.exec(`INSERT INTO session_scratchlist (session_id, entry_id, text, created_at, updated_at) + VALUES ('s1', 'e1', 'note', 1000, 1000)`) + const event = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'test', + eventType: 'completed', + attentionCandidate: 0, + summary: 'soup smoke', + relatedSessionId: 's1', + provenance: 'test' + }) + expect(event?.summary).toBe('soup smoke') + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +function tableExists(db: Database, name: string): boolean { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name = ? LIMIT 1" + ).get(name) as { name: string } | null + return row !== null +} + +function createV10Schema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', + machine_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER DEFAULT 1, + model TEXT, + model_reasoning_effort TEXT, + effort TEXT, + service_tier TEXT, + todos TEXT, + todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT, + invoked_at INTEGER, + scheduled_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + `) +} diff --git a/hub/src/store/migration-v11.test.ts b/hub/src/store/migration-v11.test.ts new file mode 100644 index 0000000000..46a77d64e1 --- /dev/null +++ b/hub/src/store/migration-v11.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '@hapi/protocol/types' +import { Store } from './index' +import { dropOverseerEventsSchema, ensureDeletedSessionsSchema, ensureOverseerEventsSchema, repointSessionEvents } from './events' +import { ensureOverseerInboxSchema } from './inboxItems' +import { deleteSession } from './sessions' +import { applySoupV10ToV11Migration } from './schemaV11Soup' +import { OverseerEventRecorder, toSessionSnapshot } from '../sync/overseerEventRecorder' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +describe('Overseer events schema (init-gated, not SCHEMA_VERSION)', () => { + it('fresh DB has events, inbox, and deleted_sessions tables after Store init', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') AND name IN ('events', 'event_links', 'events_fts', 'deleted_sessions', 'inbox_items', 'inbox_item_source_events', 'inbox_operator_actions')" + ).all() as Array<{ name: string }> + const names = new Set(tables.map((row) => row.name)) + expect(names.has('events')).toBe(true) + expect(names.has('event_links')).toBe(true) + expect(names.has('events_fts')).toBe(true) + expect(names.has('deleted_sessions')).toBe(true) + expect(names.has('inbox_items')).toBe(true) + expect(names.has('inbox_item_source_events')).toBe(true) + expect(names.has('inbox_operator_actions')).toBe(true) + }) + + it('v11 DB stamped without events self-heals on Store open (incident regression)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-incident-v11-no-events-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + applySoupV10ToV11Migration(db) + db.exec('PRAGMA user_version = 11') + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.close() + + store = new Store(dbPath) + const event = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'test-agent', + eventType: 'completed', + attentionCandidate: 0, + summary: 'Self-healed after v11 stamp', + relatedSessionId: 's1', + provenance: 'test' + }) + expect(event?.summary).toBe('Self-healed after v11 stamp') + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('V10 DB gets events on Store open without running v11 migration step', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-events-init-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + db.exec('PRAGMA user_version = 10') + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.close() + + store = new Store(dbPath) + const event = store.events.insert({ + ts: 2000, + sourceKind: 'worker', + sourceRef: 'test-agent', + eventType: 'completed', + attentionCandidate: 0, + summary: 'Init-gated event', + relatedSessionId: 's1', + provenance: 'test' + }) + expect(event?.summary).toBe('Init-gated event') + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('dropOverseerEventsSchema removes events tables without changing user_version', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + db.exec('PRAGMA user_version = 11') + dropOverseerEventsSchema(db) + const version = db.prepare('PRAGMA user_version').get() as { user_version: number } + expect(version.user_version).toBe(11) + const events = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='events'" + ).get() + expect(events).toBeNull() + }) + + it('events_fts delete and update triggers use content-storing form', () => { + const db = new Database(':memory:') + createV10Schema(db) + ensureOverseerEventsSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) + VALUES (2000, 'worker', 'completed', 0, 'fts probe', 's1')`) + + db.exec(`UPDATE events SET summary = 'fts updated' WHERE id = 1`) + const updated = db.prepare( + "SELECT summary FROM events_fts WHERE rowid = 1" + ).get() as { summary: string } | undefined + expect(updated?.summary).toBe('fts updated') + + db.exec('DELETE FROM events WHERE id = 1') + const deleted = db.prepare( + "SELECT rowid FROM events_fts WHERE rowid = 1" + ).get() + expect(deleted).toBeNull() + }) + + it('ensureOverseerEventsSchema recreates broken delete/update triggers', () => { + const db = new Database(':memory:') + createV10Schema(db) + ensureOverseerEventsSchema(db) + db.exec('DROP TRIGGER IF EXISTS events_fts_delete') + db.exec('DROP TRIGGER IF EXISTS events_fts_update') + + ensureOverseerEventsSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary) + VALUES (2000, 'worker', 'completed', 0, 'before delete')`) + + expect(() => db.exec('DELETE FROM events WHERE id = 1')).not.toThrow() + }) + + it('deleteSession detaches overseer events instead of FK-failing', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + ensureOverseerEventsSchema(db) + ensureDeletedSessionsSchema(db) + ensureOverseerInboxSchema(db) + db.exec(`INSERT INTO sessions (id, tag, namespace, created_at, updated_at, seq, metadata) + VALUES ('s-del', 'del-tag', 'default', 1000, 1000, 0, + '{"flavor":"codex","path":"/coding/hapi","name":"meta HAPI triage","host":"local"}')`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) + VALUES (2000, 'system', 'stale', 0, 'No agent output', 's-del')`) + + expect(deleteSession(db, 's-del', 'default')).toBe(true) + const event = db.prepare('SELECT related_session_id FROM events WHERE id = 1').get() as { + related_session_id: string | null + } + expect(event.related_session_id).toBeNull() + expect(db.prepare("SELECT id FROM sessions WHERE id = 's-del'").get()).toBeNull() + + const tombstone = db.prepare( + 'SELECT id, tag, name, project, flavor FROM deleted_sessions WHERE id = ?' + ).get('s-del') as { id: string; tag: string; name: string; project: string; flavor: string } + expect(tombstone.tag).toBe('del-tag') + expect(tombstone.name).toBe('meta HAPI triage') + expect(tombstone.project).toBe('hapi') + expect(tombstone.flavor).toBe('codex') + }) + + it('event retains session identity in payload after deleteSession', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events) + const stored = store.sessions.getOrCreateSession( + 'meta-triage', + { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, + null, + 'default' + ) + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"overseer","project":"hapi","status":"done","action":"","summary":"Triage complete"}' + } + } + } + + recorder.onAgentMessage( + toSessionSnapshot( + { + id: stored.id, + namespace: 'default', + seq: 0, + createdAt: stored.createdAt, + updatedAt: stored.updatedAt, + active: true, + activeAt: stored.activeAt ?? Date.now(), + metadata: stored.metadata as Session['metadata'], + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null + }, + stored.tag + ), + 'msg-del', + content, + Date.now() + ) + + expect(store.sessions.deleteSession(stored.id, 'default')).toBe(true) + + const events = store.events.list() + expect(events).toHaveLength(1) + expect(events[0]?.relatedSessionId).toBeNull() + + const payload = JSON.parse(events[0]!.payloadJson!) as { + session: { name: string | null; id: string; project: string | null; flavor: string } + } + expect(payload.session.name).toBe('meta HAPI triage') + expect(payload.session.id).toBe(stored.id) + expect(payload.session.project).toBe('hapi') + expect(payload.session.flavor).toBe('codex') + + const db: Database = (store as unknown as { db: Database }).db + const tombstone = db.prepare('SELECT name FROM deleted_sessions WHERE id = ?').get(stored.id) as { + name: string + } + expect(tombstone.name).toBe('meta HAPI triage') + expect(store.sessions.getSession(stored.id)).toBeNull() + }) + + it('repointSessionEvents moves FK refs for merge/reopen id swap', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + createV10Schema(db) + ensureOverseerEventsSchema(db) + ensureDeletedSessionsSchema(db) + ensureOverseerInboxSchema(db) + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s-old', 'default', 1000, 1000, 0), + ('s-new', 'default', 1000, 1000, 0)`) + db.exec(`INSERT INTO events (ts, source_kind, event_type, attention_candidate, summary, related_session_id) + VALUES (2000, 'system', 'stale', 0, 'stale probe', 's-old'), + (3000, 'worker', 'completed', 0, 'done probe', 's-old')`) + + expect(repointSessionEvents(db, 's-old', 's-new')).toBe(2) + expect(deleteSession(db, 's-old', 'default')).toBe(true) + + const rows = db.prepare( + 'SELECT related_session_id FROM events ORDER BY id' + ).all() as Array<{ related_session_id: string | null }> + expect(rows).toEqual([{ related_session_id: 's-new' }, { related_session_id: 's-new' }]) + }) +}) + +function createV10Schema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', + machine_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER DEFAULT 1, + model TEXT, + model_reasoning_effort TEXT, + effort TEXT, + service_tier TEXT, + todos TEXT, + todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT, + invoked_at INTEGER, + scheduled_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + `) +} diff --git a/hub/src/store/schemaV11Soup.ts b/hub/src/store/schemaV11Soup.ts new file mode 100644 index 0000000000..dd314bed74 --- /dev/null +++ b/hub/src/store/schemaV11Soup.ts @@ -0,0 +1,48 @@ +import type { Database } from 'bun:sqlite' + +/** + * Soup-only combined v10→v11 step: fcm_devices + session_scratchlist. + * + * Overseer events tables are NOT version-gated — Store.init calls + * ensureOverseerEventsSchema() on every boot regardless of user_version. + */ +export function applySoupV10ToV11Migration(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS fcm_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT NOT NULL, + device_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(namespace, device_id, platform) + ); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_namespace ON fcm_devices(namespace); + CREATE INDEX IF NOT EXISTS idx_fcm_devices_token ON fcm_devices(token); + + CREATE TABLE IF NOT EXISTS session_scratchlist ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, entry_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created + ON session_scratchlist(session_id, created_at DESC); + `) +} + +export const SOUP_V11_TABLES = [ + 'fcm_devices', + 'session_scratchlist', + 'events', + 'event_links', + 'events_fts', + 'deleted_sessions', + 'inbox_items', + 'inbox_item_source_events', + 'inbox_operator_actions' +] as const diff --git a/hub/src/store/scratchlist.ts b/hub/src/store/scratchlist.ts new file mode 100644 index 0000000000..e8305ce579 --- /dev/null +++ b/hub/src/store/scratchlist.ts @@ -0,0 +1,181 @@ +import type { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' + +import type { StoredScratchlistEntry } from './types' + +/** + * Per-session scratchlist storage (tiann/hapi#893, scratchlist v2). + * + * The hub is the source of truth for scratchlist entries; web treats + * `localStorage` as an offline cache only. All queries are scoped by + * `session_id` + (where it matters) the session's namespace - the latter + * is enforced one layer up in `SyncEngine` / web routes via + * `requireSessionFromParam`, so the SQL layer here treats `session_id` + * as the primary scope. + * + * Mental model carried from v1 (#798): scratchlist != queue. Entries are + * notes / drafts / parking-lot ideas, never auto-sent. The hub-side + * representation is deliberately lean: + * + * - `text` plain string (no markdown rendering planned for v2) + * - `created_at` immutable since insert + * - `updated_at` bumped on edits to drive the SSE patch token + * - cascade-delete from `sessions(id)` covers the delete-session path + * + * Per-session caps live in `@hapi/protocol/apiTypes` + * (`SCRATCHLIST_MAX_ENTRIES`, `SCRATCHLIST_MAX_TEXT_LENGTH`); the route + * layer enforces them at write time. The SQL layer accepts whatever it's + * given - the cap is policy, not schema. + */ + +type DbScratchlistRow = { + session_id: string + entry_id: string + text: string + created_at: number + updated_at: number +} + +function toStoredEntry(row: DbScratchlistRow): StoredScratchlistEntry { + return { + sessionId: row.session_id, + entryId: row.entry_id, + text: row.text, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function listScratchlistEntries( + db: Database, + sessionId: string +): StoredScratchlistEntry[] { + const rows = db.prepare( + `SELECT session_id, entry_id, text, created_at, updated_at + FROM session_scratchlist + WHERE session_id = ? + ORDER BY created_at DESC, entry_id DESC` + ).all(sessionId) as DbScratchlistRow[] + return rows.map(toStoredEntry) +} + +export function countScratchlistEntries(db: Database, sessionId: string): number { + const row = db.prepare( + 'SELECT COUNT(*) AS n FROM session_scratchlist WHERE session_id = ?' + ).get(sessionId) as { n: number } | undefined + return row?.n ?? 0 +} + +export function getScratchlistEntry( + db: Database, + sessionId: string, + entryId: string +): StoredScratchlistEntry | null { + const row = db.prepare( + `SELECT session_id, entry_id, text, created_at, updated_at + FROM session_scratchlist + WHERE session_id = ? AND entry_id = ?` + ).get(sessionId, entryId) as DbScratchlistRow | undefined + return row ? toStoredEntry(row) : null +} + +/** + * Insert a new scratchlist entry. Returns the stored row on success, or + * `{ outcome: 'duplicate' }` when the supplied `entryId` already exists + * (the migration path can collide on retry; clients should treat that as + * idempotent). `{ outcome: 'session-not-found' }` is returned when the FK + * to `sessions` would fail - keeps the route handler from having to + * pre-check session existence. + */ +export type CreateScratchlistResult = + | { outcome: 'created'; entry: StoredScratchlistEntry } + | { outcome: 'duplicate'; entry: StoredScratchlistEntry } + | { outcome: 'session-not-found' } + +export function createScratchlistEntry( + db: Database, + sessionId: string, + text: string, + options?: { entryId?: string; createdAt?: number } +): CreateScratchlistResult { + const now = Date.now() + const entryId = options?.entryId ?? randomUUID() + const createdAt = options?.createdAt ?? now + const updatedAt = now + + // Pre-check FK so the route layer can return a clean 404. Doing this + // before the INSERT keeps the error-handling path narrower (no + // SQLite-error string parsing). + const sessionExists = db.prepare( + 'SELECT 1 FROM sessions WHERE id = ? LIMIT 1' + ).get(sessionId) as { 1: number } | undefined + if (!sessionExists) { + return { outcome: 'session-not-found' } + } + + const existing = getScratchlistEntry(db, sessionId, entryId) + if (existing) { + return { outcome: 'duplicate', entry: existing } + } + + db.prepare( + `INSERT INTO session_scratchlist + (session_id, entry_id, text, created_at, updated_at) + VALUES (@session_id, @entry_id, @text, @created_at, @updated_at)` + ).run({ + session_id: sessionId, + entry_id: entryId, + text, + created_at: createdAt, + updated_at: updatedAt + }) + + const created = getScratchlistEntry(db, sessionId, entryId) + if (!created) { + // Should be unreachable: we just inserted under the same scope. + throw new Error('Failed to read scratchlist entry after insert') + } + return { outcome: 'created', entry: created } +} + +/** + * Update an existing entry's `text`. Bumps `updated_at` to `Date.now()`. + * Returns `null` when the entry does not exist (route layer turns into a + * 404). Note: `created_at` is intentionally NOT updated. + */ +export function updateScratchlistEntry( + db: Database, + sessionId: string, + entryId: string, + text: string +): StoredScratchlistEntry | null { + const now = Date.now() + const result = db.prepare( + `UPDATE session_scratchlist + SET text = @text, + updated_at = @updated_at + WHERE session_id = @session_id + AND entry_id = @entry_id` + ).run({ + session_id: sessionId, + entry_id: entryId, + text, + updated_at: now + }) + if (result.changes === 0) { + return null + } + return getScratchlistEntry(db, sessionId, entryId) +} + +export function deleteScratchlistEntry( + db: Database, + sessionId: string, + entryId: string +): boolean { + const result = db.prepare( + `DELETE FROM session_scratchlist + WHERE session_id = ? AND entry_id = ?` + ).run(sessionId, entryId) + return result.changes > 0 +} diff --git a/hub/src/store/scratchlistStore.ts b/hub/src/store/scratchlistStore.ts new file mode 100644 index 0000000000..fd285ca6d0 --- /dev/null +++ b/hub/src/store/scratchlistStore.ts @@ -0,0 +1,52 @@ +import type { Database } from 'bun:sqlite' + +import type { StoredScratchlistEntry } from './types' +import { + countScratchlistEntries, + createScratchlistEntry, + deleteScratchlistEntry, + getScratchlistEntry, + listScratchlistEntries, + updateScratchlistEntry, + type CreateScratchlistResult +} from './scratchlist' + +export class ScratchlistStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + list(sessionId: string): StoredScratchlistEntry[] { + return listScratchlistEntries(this.db, sessionId) + } + + count(sessionId: string): number { + return countScratchlistEntries(this.db, sessionId) + } + + get(sessionId: string, entryId: string): StoredScratchlistEntry | null { + return getScratchlistEntry(this.db, sessionId, entryId) + } + + create( + sessionId: string, + text: string, + options?: { entryId?: string; createdAt?: number } + ): CreateScratchlistResult { + return createScratchlistEntry(this.db, sessionId, text, options) + } + + update( + sessionId: string, + entryId: string, + text: string + ): StoredScratchlistEntry | null { + return updateScratchlistEntry(this.db, sessionId, entryId, text) + } + + delete(sessionId: string, entryId: string): boolean { + return deleteScratchlistEntry(this.db, sessionId, entryId) + } +} diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index 0e18a859dd..469c47eaa8 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -11,6 +11,7 @@ import { setSessionEffort, setSessionModel, setSessionModelReasoningEffort, + setImportedSessionActivity, setSessionServiceTier, setSessionTeamState, setSessionTodos, @@ -90,6 +91,10 @@ export class SessionStore { return touchSessionUpdatedAt(this.db, id, updatedAt, namespace) } + setImportedSessionActivity(id: string, updatedAt: number, namespace: string): boolean { + return setImportedSessionActivity(this.db, id, updatedAt, namespace) + } + getSession(id: string): StoredSession | null { return getSession(this.db, id) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 537216c0ca..e90f68de94 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -1,6 +1,10 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' +import { detachSessionEvents, tombstoneDeletedSession } from './events' +import { detachSessionInboxItems } from './inboxItems' +import { buildOverseerSessionIdentity } from '@hapi/protocol' +import type { Metadata } from '@hapi/protocol/types' import type { StoredSession, VersionedUpdateResult } from './types' import { safeJsonParse } from './json' import { updateVersionedField } from './versionedUpdates' @@ -540,6 +544,38 @@ export function touchSessionUpdatedAt( } } +// 中文注释:transcript 导入新建会话时,会话出生时间是 Date.now()(今天),而真实最后活动在历史里。 +// touchSessionUpdatedAt 是“只前进不后退”的活跃刷新,保护在线会话不被陈旧事件拉回过去,因此无法把 +// 导入会话的 updated_at 调回历史。这里提供一个仅供导入路径使用的无条件 setter,把刚建好的导入会话 +// 的 updated_at 设成真实最后活动时间,避免历史会话在列表里被排成“今天刚活跃”。 +export function setImportedSessionActivity( + db: Database, + id: string, + updatedAt: number, + namespace: string +): boolean { + if (!Number.isFinite(updatedAt)) { + return false + } + try { + const result = db.prepare(` + UPDATE sessions + SET updated_at = @updated_at, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + `).run({ + id, + namespace, + updated_at: updatedAt + }) + + return result.changes === 1 + } catch { + return false + } +} + export function getSession(db: Database, id: string): StoredSession | null { const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null @@ -565,6 +601,24 @@ export function getSessionsByNamespace(db: Database, namespace: string): StoredS } export function deleteSession(db: Database, id: string, namespace: string): boolean { + const row = getSessionByNamespace(db, id, namespace) + if (!row) { + return false + } + + const metadata = row.metadata as Metadata | null + tombstoneDeletedSession( + db, + buildOverseerSessionIdentity({ + id: row.id, + flavor: metadata?.flavor ?? 'claude', + tag: row.tag, + metadata + }), + Date.now() + ) + detachSessionEvents(db, id) + detachSessionInboxItems(db, id) const result = db.prepare( 'DELETE FROM sessions WHERE id = ? AND namespace = ?' ).run(id, namespace) diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index ae3f648291..28683c9970 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -64,6 +64,24 @@ export type StoredPushSubscription = { createdAt: number } +export type StoredFcmDevice = { + id: number + namespace: string + token: string + platform: 'phone' | 'wear' + deviceId: string + createdAt: number + updatedAt: number +} + +export type StoredScratchlistEntry = { + sessionId: string + entryId: string + text: string + createdAt: number + updatedAt: number +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/sync/overseerEntity.test.ts b/hub/src/sync/overseerEntity.test.ts new file mode 100644 index 0000000000..0e22610587 --- /dev/null +++ b/hub/src/sync/overseerEntity.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { SyncEngine } from './syncEngine' +import { RpcRegistry } from '../socket/rpcRegistry' +import type { OverseerEntity } from './overseerEntity' + +function makeEngine(): { store: Store; engine: SyncEngine } { + const store = new Store(':memory:') + return { store, engine: buildEngine(store) } +} + +function buildEngine(store: Store): SyncEngine { + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + return new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) +} + +function agentMessage(text: string): unknown { + return { role: 'agent', content: { type: 'codex', data: { type: 'message', message: text } } } +} + +function overseer(engine: SyncEngine): OverseerEntity { + return engine.getOverseer() +} + +describe('OverseerEntity read-only tools', () => { + it('query_events filters by severity, type, source, and project', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('evt', { flavor: 'codex', path: '/tmp/web' }, null, 'default') + const now = Date.now() + store.events.insert({ + ts: now, sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, severity: 4, + summary: 'blocked on CI', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'web' } }) + }) + store.events.insert({ + ts: now, sourceKind: 'worker', eventType: 'progress', attentionCandidate: 0, severity: 1, + summary: 'progress tick', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'web' } }) + }) + store.events.insert({ + ts: now, sourceKind: 'system', eventType: 'stale', attentionCandidate: 0, severity: 3, + summary: 'silent', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'api' } }) + }) + + const engine = buildEngine(store) + const o = overseer(engine) + + expect(o.queryEvents({ severityMin: 4 }).map((e) => e.summary)).toEqual(['blocked on CI']) + expect(o.queryEvents({ eventType: 'progress' }).map((e) => e.summary)).toEqual(['progress tick']) + expect(o.queryEvents({ sourceKind: 'system' }).map((e) => e.summary)).toEqual(['silent']) + expect(o.queryEvents({ project: 'api' }).map((e) => e.summary)).toEqual(['silent']) + expect(o.queryEvents({ attentionCandidate: 1 }).map((e) => e.summary)).toEqual(['blocked on CI']) + expect(o.queryEvents({}).length).toBe(3) + }) + + it('query_inbox groups candidates / surfaced / held', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('inbox', { flavor: 'claude', path: '/tmp/web' }, null, 'default') + const event = store.events.insert({ + ts: Date.now(), sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, severity: 4, + summary: 'CI auth fail', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'web', name: 'peer-15' } }) + }) + expect(event).not.toBeNull() + const item = store.inbox.promoteAttentionEvent(event!) + expect(item).not.toBeNull() + + const engine = buildEngine(store) + const result = overseer(engine).queryInbox({}) + expect(result.items.length).toBe(1) + expect(result.candidates.length).toBe(1) + expect(result.candidates[0]?.status).toBe('new') + expect(result.surfaced.length).toBe(0) + expect(result.held.length).toBe(0) + }) + + it('explain_priority recites the stored reason and the provenance trail', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('prio', { flavor: 'claude', path: '/tmp/web' }, null, 'default') + const event = store.events.insert({ + ts: Date.now(), sourceKind: 'worker', eventType: 'needs_decision', attentionCandidate: 1, severity: 5, + summary: 'pick a deploy target', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'web', name: 'peer-3' } }) + }) + const item = store.inbox.promoteAttentionEvent(event!) + expect(item).not.toBeNull() + + const engine = buildEngine(store) + const explanation = overseer(engine).explainPriority(item!.id) + expect(explanation).not.toBeNull() + expect(explanation!.reasonForPriority).toBe(item!.reasonForPriority) + expect(explanation!.reasonForPriority).toBeTruthy() + expect(explanation!.sourceEventIds).toContain(event!.id) + expect(explanation!.sourceEvents.map((e) => e.summary)).toContain('pick a deploy target') + + expect(overseer(engine).explainPriority(99999)).toBeNull() + }) + + it('get_session_state + get_worker_health combine reported/observed/inferred', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('health', { flavor: 'codex', host: 'local', path: '/tmp/api', name: 'peer-7' }, null, 'default') + store.events.insert({ + ts: Date.now(), sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, severity: 4, + summary: 'blocked on migration', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'api', name: 'peer-7' } }) + }) + + const engine = buildEngine(store) + const state = overseer(engine).getSessionState(session.id) + expect(state).not.toBeNull() + expect(state!.workerReportedState).toBe('blocked') + expect(state!.project).toBe('api') + + const health = overseer(engine).getWorkerHealth(session.id) + expect(health).not.toBeNull() + expect(health!.reportedState).toBe('blocked') + // blocked is a terminal reported state -> inferred follows it with high confidence + expect(health!.inferredState).toBe('blocked') + expect(health!.inferredConfidence).toBeGreaterThan(0.8) + expect(health!.signals.some((s) => s.includes('worker-reported: blocked'))).toBe(true) + + expect(overseer(engine).getSessionState('does-not-exist')).toBeNull() + expect(overseer(engine).getWorkerHealth('does-not-exist')).toBeNull() + }) + + it('get_session_recent_output returns last transcript chunks with roles', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('out', { flavor: 'codex', path: '/tmp/web' }, null, 'default') + store.messages.addMessage(session.id, { role: 'user', content: 'refactor auth' }) + store.messages.addMessage(session.id, agentMessage('Refactored the auth module')) + + const engine = buildEngine(store) + const chunks = overseer(engine).getSessionRecentOutput(session.id, 10) + expect(chunks.length).toBe(2) + expect(chunks.map((c) => c.role)).toEqual(['operator', 'worker']) + expect(chunks[1]?.text).toBe('Refactored the auth module') + }) + + it('list_active_workers builds a roster filterable by project', () => { + const store = new Store(':memory:') + store.sessions.getOrCreateSession('w1', { flavor: 'codex', host: 'local', path: '/tmp/web', name: 'peer-1' }, null, 'default') + store.sessions.getOrCreateSession('w2', { flavor: 'claude', host: 'local', path: '/tmp/api', name: 'peer-2' }, null, 'default') + + const engine = buildEngine(store) + const all = overseer(engine).listActiveWorkers({}) + expect(all.length).toBe(2) + const web = overseer(engine).listActiveWorkers({ project: 'web' }) + expect(web.length).toBe(1) + expect(web[0]?.project).toBe('web') + }) + + it('recordConvoTurn writes a memory-bearing convo_turn event (never an inbox item)', () => { + const { store, engine } = makeEngine() + const inboxBefore = store.inbox.count() + const event = overseer(engine).recordConvoTurn({ + operatorText: 'what needs me next?', + overseerText: 'peer-15 is blocked on CI auth — 12 minutes old.' + }) + expect(event).not.toBeNull() + expect(event!.eventType).toBe('convo_turn') + expect(event!.attentionCandidate).toBe(0) + expect(event!.sourceKind).toBe('overseer') + // memory-bearing: must NOT have created an inbox item + expect(store.inbox.count()).toBe(inboxBefore) + + const convoEvents = engine.getSystemEvents({ eventType: 'convo_turn' }) + expect(convoEvents.length).toBe(1) + }) +}) diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts new file mode 100644 index 0000000000..0d81774e40 --- /dev/null +++ b/hub/src/sync/overseerEntity.ts @@ -0,0 +1,376 @@ +/** + * Overseer entity — read-only hub service (Step 3 / Stage 0). + * + * Implements the seven read-only tools the Overseer uses to reason about the + * fleet, plus `convo_turn` writeback. Everything here is read-only against the + * substrate (events + inbox + sessions + messages); no dispatch, no mutation of + * worker state. The single mutation it performs is appending its own + * memory-bearing `convo_turn` events (contracts §1 three-layer model). + */ + +import { + OVERSEER_STALE_SILENCE_MS, + buildOverseerConvoTurnEventInput, + buildOverseerIdentity, + buildOverseerSystemPrompt, + deriveObservedWorkerState, + inferWorkerState, + mapEventTypeToWorkerState, + type OverseerActiveWorker, + type OverseerConvoTurnInput, + type OverseerExplainPriority, + type OverseerIdentity, + type OverseerRecentOutputChunk, + type OverseerSessionStateView, + type OverseerWorkerHealth, + type OverseerWorkerState, + type QueryEventsArgs, + type QueryInboxArgs, + type ListActiveWorkersArgs +} from '@hapi/protocol' +import { buildOverseerSessionIdentity } from '@hapi/protocol' +import { extractAssistantPlainText, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import type { Session } from '@hapi/protocol/types' +import type { EventStore, StoredSystemEvent } from '../store' +import type { InboxStore, StoredInboxItem } from '../store/inboxStore' +import type { MessageStore } from '../store/messageStore' + +export type OverseerEntityDeps = { + events: EventStore + inbox: InboxStore + messages: MessageStore + getSession: (sessionId: string) => Session | undefined + getSessions: () => Session[] + now?: () => number + staleSilenceMs?: number +} + +function deriveIdentity(session: Session): { name: string | null; project: string | null; flavor: string | null } { + const identity = buildOverseerSessionIdentity({ + id: session.id, + flavor: session.metadata?.flavor ?? 'unknown', + tag: null, + metadata: session.metadata ?? undefined + }) + return { + name: identity.name, + project: identity.project, + flavor: session.metadata?.flavor ?? null + } +} + +function pendingRequestCount(session: Session): number { + const requests = session.agentState?.requests + return requests ? Object.keys(requests).length : 0 +} + +export class OverseerEntity { + private readonly events: EventStore + private readonly inbox: InboxStore + private readonly messages: MessageStore + private readonly getSession: (sessionId: string) => Session | undefined + private readonly getSessions: () => Session[] + private readonly now: () => number + private readonly staleSilenceMs: number + + constructor(deps: OverseerEntityDeps) { + this.events = deps.events + this.inbox = deps.inbox + this.messages = deps.messages + this.getSession = deps.getSession + this.getSessions = deps.getSessions + this.now = deps.now ?? (() => Date.now()) + this.staleSilenceMs = deps.staleSilenceMs ?? OVERSEER_STALE_SILENCE_MS + } + + identity(): OverseerIdentity { + return buildOverseerIdentity() + } + + systemPrompt(): string { + return buildOverseerSystemPrompt() + } + + // --- Tool 1: query_events ------------------------------------------------ + + queryEvents(args: QueryEventsArgs): StoredSystemEvent[] { + return this.events.query({ + sessionId: args.sessionId ?? null, + project: args.project ?? null, + eventType: args.eventType ?? null, + sourceKind: args.sourceKind ?? null, + attentionCandidate: args.attentionCandidate ?? null, + severityMin: args.severityMin ?? null, + sinceTs: args.sinceTs ?? null, + untilTs: args.untilTs ?? null, + beforeId: args.beforeId ?? null, + limit: args.limit ?? 50 + }) + } + + // --- Tool 2: query_inbox ------------------------------------------------- + + /** + * Returns inbox items grouped into candidates (`new`), surfaced, and held. + * Defaults to the operator-relevant set (active + held) when no statuses + * are supplied. + */ + queryInbox(args: QueryInboxArgs): { + items: StoredInboxItem[] + candidates: StoredInboxItem[] + surfaced: StoredInboxItem[] + held: StoredInboxItem[] + } { + const statuses = args.statuses && args.statuses.length > 0 + ? args.statuses + : ['new', 'surfaced', 'deferred', 'snoozed', 'held'] + const items = this.inbox.list({ + statuses, + sessionId: args.sessionId ?? null, + category: args.category ?? null, + limit: args.limit ?? 50 + }) + return { + items, + candidates: items.filter((item) => item.status === 'new'), + surfaced: items.filter((item) => item.status === 'surfaced'), + held: items.filter((item) => item.status === 'held') + } + } + + // --- Tool 3: get_session_state ------------------------------------------ + + getSessionState(sessionId: string): OverseerSessionStateView | null { + const session = this.getSession(sessionId) + if (!session) return null + + const now = this.now() + const { name, project, flavor } = deriveIdentity(session) + const latestEvent = this.events.query({ sessionId, limit: 1 })[0] ?? null + const lastActivityAt = this.computeLastActivityAt(session, latestEvent) + const silenceMs = lastActivityAt !== null ? Math.max(0, now - lastActivityAt) : null + const pending = pendingRequestCount(session) + + const observedState = deriveObservedWorkerState({ + active: session.active, + thinking: session.thinking, + silenceMs, + pendingRequestCount: pending, + staleSilenceMs: this.staleSilenceMs + }) + + const lastToolCall = this.events.query({ sessionId, eventType: 'tool_call', limit: 1 })[0] + ?? this.events.query({ sessionId, eventType: 'tool_result', limit: 1 })[0] + ?? null + + return { + sessionId, + name, + project, + flavor, + active: session.active, + thinking: session.thinking, + observedState, + workerReportedState: this.deriveReportedState(sessionId), + lastActivityAt, + silenceMs, + lastToolCallAgeMs: lastToolCall ? Math.max(0, now - lastToolCall.ts) : null, + pendingRequestCount: pending + } + } + + // --- Tool 4: get_session_recent_output ---------------------------------- + + getSessionRecentOutput(sessionId: string, n = 10): OverseerRecentOutputChunk[] { + const limit = Math.min(Math.max(n, 1), 50) + const messages = this.messages.getMessages(sessionId, limit) + const chunks: OverseerRecentOutputChunk[] = [] + for (const message of messages) { + const text = this.extractMessageText(message.content) + if (!text) continue + chunks.push({ + messageId: message.id, + role: this.classifyRole(message.content), + text, + createdAt: message.createdAt + }) + } + return chunks + } + + // --- Tool 5: get_worker_health ------------------------------------------ + + getWorkerHealth(sessionId: string): OverseerWorkerHealth | null { + const session = this.getSession(sessionId) + if (!session) return null + + const now = this.now() + const { name, project, flavor } = deriveIdentity(session) + const latestEvent = this.events.query({ sessionId, limit: 1 })[0] ?? null + const lastActivityAt = this.computeLastActivityAt(session, latestEvent) + const silenceMs = lastActivityAt !== null ? Math.max(0, now - lastActivityAt) : null + const pending = pendingRequestCount(session) + + const observedState = deriveObservedWorkerState({ + active: session.active, + thinking: session.thinking, + silenceMs, + pendingRequestCount: pending, + staleSilenceMs: this.staleSilenceMs + }) + const reportedState = this.deriveReportedState(sessionId) + const inferred = inferWorkerState({ + reported: reportedState, + observed: observedState, + silenceMs, + staleSilenceMs: this.staleSilenceMs + }) + + const signals: string[] = [] + signals.push(`hub-observed: ${observedState}`) + signals.push(reportedState ? `worker-reported: ${reportedState}` : 'worker-reported: (none)') + if (silenceMs !== null) { + signals.push(`silent for ${Math.round(silenceMs / 60_000)} min`) + } + if (pending > 0) { + signals.push(`${pending} pending permission request(s)`) + } + if (latestEvent) { + signals.push(`last event #${latestEvent.id} (${latestEvent.eventType}): ${latestEvent.summary}`) + } + signals.push(inferred.note) + + return { + sessionId, + name, + project, + flavor, + reportedState: reportedState ?? 'unknown', + observedState, + inferredState: inferred.state, + inferredConfidence: inferred.confidence, + signals, + lastActivityAt, + silenceMs, + pendingRequestCount: pending + } + } + + // --- Tool 6: explain_priority ------------------------------------------- + + explainPriority(itemId: number): OverseerExplainPriority | null { + const item = this.inbox.getById(itemId) + if (!item) return null + + const sourceEvents = item.sourceEventIds + .map((id) => this.events.getById(id)) + .filter((event): event is StoredSystemEvent => event !== null) + .map((event) => ({ + id: event.id, + eventType: event.eventType, + summary: event.summary, + ts: event.ts, + severity: event.severity, + sourceKind: event.sourceKind + })) + + return { + inboxItemId: item.id, + title: item.title, + category: item.category, + status: item.status, + priority: item.priority, + basePriority: item.basePriority, + agingFactor: item.agingFactor, + timeCriticality: item.timeCriticality, + // Recite the stored provenance — do NOT recompute (substrate authored it). + reasonForPriority: item.reasonForPriority, + sourceEventIds: item.sourceEventIds, + relatedSessionId: item.relatedSessionId, + sourceEvents + } + } + + // --- Tool 7: list_active_workers ---------------------------------------- + + listActiveWorkers(args: ListActiveWorkersArgs = {}): OverseerActiveWorker[] { + const now = this.now() + const limit = Math.min(Math.max(args.limit ?? 100, 1), 200) + const roster: OverseerActiveWorker[] = [] + + for (const session of this.getSessions()) { + const { name, project, flavor } = deriveIdentity(session) + const latestEvent = this.events.query({ sessionId: session.id, limit: 1 })[0] ?? null + const lastActivityAt = this.computeLastActivityAt(session, latestEvent) + const silenceMs = lastActivityAt !== null ? Math.max(0, now - lastActivityAt) : null + const observedState = deriveObservedWorkerState({ + active: session.active, + thinking: session.thinking, + silenceMs, + pendingRequestCount: pendingRequestCount(session), + staleSilenceMs: this.staleSilenceMs + }) + const ageMs = lastActivityAt !== null ? Math.max(0, now - lastActivityAt) : null + + if (args.project && project !== args.project) continue + if (args.state && observedState !== args.state) continue + if (args.minAgeMs !== undefined && (ageMs === null || ageMs < args.minAgeMs)) continue + + roster.push({ + sessionId: session.id, + name, + project, + flavor, + observedState, + active: session.active, + lastActivityAt, + ageMs + }) + } + + roster.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) + return roster.slice(0, limit) + } + + // --- convo_turn writeback ----------------------------------------------- + + recordConvoTurn(input: OverseerConvoTurnInput): StoredSystemEvent | null { + const eventInput = buildOverseerConvoTurnEventInput({ ...input, ts: input.ts ?? this.now() }) + return this.events.insert(eventInput) + } + + // --- internals ----------------------------------------------------------- + + private computeLastActivityAt(session: Session, latestEvent: StoredSystemEvent | null): number | null { + const candidates = [session.activeAt, session.updatedAt, latestEvent?.ts] + .filter((value): value is number => typeof value === 'number' && value > 0) + return candidates.length > 0 ? Math.max(...candidates) : null + } + + private deriveReportedState(sessionId: string): OverseerWorkerState | null { + const workerEvent = this.events.query({ sessionId, sourceKind: 'worker', limit: 1 })[0] ?? null + if (!workerEvent) return null + return mapEventTypeToWorkerState(workerEvent.eventType) + } + + private extractMessageText(content: unknown): string | null { + const record = unwrapRoleWrappedRecordEnvelope(content) + if (record?.role === 'agent') { + return extractAssistantPlainText(record.content) + } + const direct = extractAssistantPlainText(content) + if (direct) return direct + // Operator/user messages: best-effort plain text. + if (record?.role === 'user' && typeof record.content === 'string') { + return record.content + } + return null + } + + private classifyRole(content: unknown): 'operator' | 'worker' | 'unknown' { + const record = unwrapRoleWrappedRecordEnvelope(content) + if (record?.role === 'agent') return 'worker' + if (record?.role === 'user') return 'operator' + return 'unknown' + } +} diff --git a/hub/src/sync/overseerEventRecorder.injection.test.ts b/hub/src/sync/overseerEventRecorder.injection.test.ts new file mode 100644 index 0000000000..9a236a7a4f --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.injection.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'bun:test' +import { shouldInjectNotifyContract } from './overseerEventRecorder' +import { AGENT_NOTIFY_CONTRACT_INLINE_PREFIX } from '@hapi/protocol' + +describe('notify contract injection', () => { + it('skips cursor flavor', () => { + expect(shouldInjectNotifyContract('cursor')).toBe(false) + }) + + it('injects for claude and codex', () => { + expect(shouldInjectNotifyContract('claude')).toBe(true) + expect(shouldInjectNotifyContract('codex')).toBe(true) + }) + + it('prefix contains AGENT_NOTIFY_SUMMARY instruction', () => { + expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX).toContain('AGENT_NOTIFY_SUMMARY') + }) +}) diff --git a/hub/src/sync/overseerEventRecorder.test.ts b/hub/src/sync/overseerEventRecorder.test.ts new file mode 100644 index 0000000000..5a4ce9d0d7 --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'bun:test' +import { OVERSEER_STALE_SILENCE_MS } from '@hapi/protocol' +import type { Session } from '@hapi/protocol/types' +import { Store } from '../store' +import { OverseerEventRecorder, toSessionSnapshot } from './overseerEventRecorder' + +function makeSession(id: string, flavor: string, overrides?: Partial): Session { + return { + id, + namespace: 'default', + seq: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + active: true, + activeAt: Date.now(), + metadata: { flavor, path: '/tmp', host: 'local' }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + ...overrides + } +} + +describe('OverseerEventRecorder', () => { + it('records AGENT_NOTIFY_SUMMARY from codex assistant text', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('test', { flavor: 'codex', path: '/tmp', host: 'local' }, null, 'default') + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'Done.\n\nAGENT_NOTIFY_SUMMARY {"version":1,"agent":"peer","project":"demo","status":"done","action":"Review PR","summary":"Shipped fix"}' + } + } + } + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'codex'), session.tag), + 'msg-1', + content, + Date.now() + ) + + expect(event?.eventType).toBe('completed') + expect(event?.attentionCandidate).toBe(1) + expect(event?.summary).toBe('Shipped fix') + expect(store.events.count()).toBe(1) + + const payload = JSON.parse(event!.payloadJson!) as { + session: { id: string; tag: string | null; name: string | null; project: string | null; flavor: string } + notify_summary: { project?: string } + } + expect(payload.session.id).toBe(session.id) + expect(payload.session.tag).toBe('test') + expect(payload.session.project).toBe('demo') + expect(payload.session.flavor).toBe('codex') + + expect(store.inbox.count()).toBe(1) + const item = store.inbox.list({ activeOnly: true })[0] + expect(item?.category).toBe('FINALE') + expect(item?.sourceEventIds).toEqual([event!.id]) + expect(item?.title).toBe('test') + }) + + it('captures done without action as captured-only', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('test2', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"status":"done","summary":"All good","action":""}' + } + } + } + + const event = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'claude'), session.tag), + 'msg-2', + content, + Date.now() + ) + + expect(event?.attentionCandidate).toBe(0) + }) + + it('does not persist hub-inferred stale silence (derive live; no Session Log noise)', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('idle', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') + + const now = Date.now() + const silentSince = now - OVERSEER_STALE_SILENCE_MS - 60_000 + const live = makeSession(session.id, 'claude', { + active: true, + activeAt: silentSince, + updatedAt: silentSince + }) + + const emitted = recorder.checkStaleSessions([live], now) + + expect(emitted).toHaveLength(0) + expect(store.events.count()).toBe(0) + expect(store.inbox.count()).toBe(0) + }) + + it('synthesizes approval_requested from permission prompts', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('perm', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') + + const live = makeSession(session.id, 'claude') + live.agentState = { + requests: { + req1: { tool: 'Bash', arguments: { command: 'git push' } } + } + } + + recorder.onSessionUpdated(live, session.tag) + + const events = store.events.list({ eventType: 'approval_requested' }) + expect(events).toHaveLength(1) + expect(events[0]?.attentionCandidate).toBe(1) + expect(events[0]?.summary).toContain('Bash') + + const payload = JSON.parse(events[0]!.payloadJson!) as { session: { name: string | null } } + expect(payload.session.name).toBe('perm') + expect(store.inbox.count()).toBe(1) + expect(store.inbox.list()[0]?.category).toBe('APPROVAL') + expect(store.inbox.list()[0]?.title).toBe('perm') + }) + + it('denormalizes session display name and project into payload.session', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const stored = store.sessions.getOrCreateSession( + 'meta-triage', + { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, + null, + 'default' + ) + const live = makeSession(stored.id, 'codex', { + metadata: { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' } + }) + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"overseer","project":"hapi","status":"done","action":"","summary":"Triage complete"}' + } + } + } + + const event = recorder.onAgentMessage( + toSessionSnapshot(live, stored.tag), + 'msg-meta', + content, + Date.now() + ) + + const payload = JSON.parse(event!.payloadJson!) as { + session: { id: string; tag: string | null; name: string | null; project: string | null; flavor: string } + } + expect(payload.session.name).toBe('meta HAPI triage') + expect(payload.session.tag).toBe('meta-triage') + expect(payload.session.project).toBe('hapi') + expect(payload.session.flavor).toBe('codex') + expect(payload.session.id).toBe(stored.id) + }) + + it('titles inbox items from payload.session.name after session delete', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const stored = store.sessions.getOrCreateSession( + 'meta-triage', + { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' }, + null, + 'default' + ) + + recorder.onAgentMessage( + toSessionSnapshot(makeSession(stored.id, 'codex', { + metadata: { flavor: 'codex', path: '/coding/hapi', name: 'meta HAPI triage', host: 'local' } + }), stored.tag), + 'msg-attn', + { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'AGENT_NOTIFY_SUMMARY {"version":1,"status":"blocked","action":"Fix CI","summary":"CI failed"}' + } + } + }, + Date.now() + ) + + const itemBefore = store.inbox.list()[0] + expect(itemBefore?.title).toBe('meta HAPI triage') + + expect(store.sessions.deleteSession(stored.id, 'default')).toBe(true) + + const itemAfter = store.inbox.getById(itemBefore!.id) + expect(itemAfter?.title).toBe('meta HAPI triage') + expect(itemAfter?.relatedSessionId).toBeNull() + }) + + it('scoops http(s) URLs into link_seen with artifact_refs kind:url', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('links', { flavor: 'codex', path: '/tmp', host: 'local' }, null, 'default') + + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: [ + 'See https://github.com/tiann/hapi/pull/22 and https://example.com/docs.', + '', + 'AGENT_NOTIFY_SUMMARY {"version":1,"status":"done","action":"","summary":"Linked"}' + ].join('\n') + } + } + } + + const notify = recorder.onAgentMessage( + toSessionSnapshot(makeSession(session.id, 'codex'), session.tag), + 'msg-links', + content, + Date.now() + ) + expect(notify?.eventType).toBe('completed') + + const links = store.events.list({ eventType: 'link_seen', sessionId: session.id }) + expect(links).toHaveLength(2) + expect(links.every((row) => row.attentionCandidate === 0)).toBe(true) + expect(links.every((row) => row.relatedSessionId === session.id)).toBe(true) + + const refs = links.map((row) => JSON.parse(row.artifactRefs!) as Array<{ kind: string; url: string }>) + const urls = refs.flatMap((arr) => arr.map((item) => item.url)).sort() + expect(urls).toEqual([ + 'https://example.com/docs', + 'https://github.com/tiann/hapi/pull/22' + ]) + expect(refs.every((arr) => arr.every((item) => item.kind === 'url'))).toBe(true) + + const payload = JSON.parse(links[0]!.payloadJson!) as { session: { id: string }; url: string } + expect(payload.session.id).toBe(session.id) + }) + + it('idempotently scoops the same URL from the same message once', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('dedupe', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') + const content = { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: 'https://example.com/a https://example.com/a' + } + } + } + const snapshot = toSessionSnapshot(makeSession(session.id, 'claude'), session.tag) + recorder.onAgentMessage(snapshot, 'msg-dup', content, Date.now()) + recorder.onAgentMessage(snapshot, 'msg-dup', content, Date.now()) + expect(store.events.list({ eventType: 'link_seen' })).toHaveLength(1) + }) +}) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts new file mode 100644 index 0000000000..b0e884a447 --- /dev/null +++ b/hub/src/sync/overseerEventRecorder.ts @@ -0,0 +1,425 @@ +import { + buildEventSummaryFromNotify, + buildLinkSeenSummary, + buildOverseerSessionIdentity, + buildUrlArtifactRefs, + deriveAttentionCandidate, + deriveOperatorActionRequired, + deriveSeverity, + detectEmptyHapiEventsSentinel, + detectMalformedNotifySummaryLine, + extractHttpUrls, + mapNotifyStatusToEventType, + mergeEventPayloadWithSession, + normalizeUrlIdempotencyKey, + isObject, + type NotifySummary, + type OverseerSessionIdentity +} from '@hapi/protocol' +import { + extractAssistantPlainText, + extractNotifySummary, + unwrapRoleWrappedRecordEnvelope +} from '@hapi/protocol/messages' +import type { Session } from '@hapi/protocol/types' +import type { EventStore, InsertSystemEventInput, StoredSystemEvent } from '../store' +import type { InboxStore } from '../store/inboxStore' + +export type SessionSnapshot = OverseerSessionIdentity + +function asRecord(value: unknown): Record | null { + return isObject(value) ? value as Record : null +} + +function extractTextBlocks(value: unknown): string | null { + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + if (Array.isArray(value)) { + const parts: string[] = [] + for (const block of value) { + const record = asRecord(block) + if (record?.type === 'text' && typeof record.text === 'string') { + parts.push(record.text) + } else if (typeof block === 'string') { + parts.push(block) + } + } + const joined = parts.join('\n').trim() + return joined.length > 0 ? joined : null + } + const record = asRecord(value) + if (record?.type === 'text' && typeof record.text === 'string') { + return record.text + } + return null +} + +/** Best-effort plain text for URL scoop (agent + user message shapes). */ +export function extractTextForLinkScoop(content: unknown): string | null { + const envelope = unwrapRoleWrappedRecordEnvelope(content) + const body = envelope?.content ?? content + + const assistant = extractAssistantPlainText(body) + if (assistant) return assistant + + const direct = extractTextBlocks(body) + if (direct) return direct + + const record = asRecord(body) + if (record?.type === 'output') { + const data = asRecord(record.data) + const message = asRecord(data?.message) + if (message) { + const fromMessage = extractTextBlocks(message.content) + if (fromMessage) return fromMessage + } + } + if (record?.type === 'codex') { + const data = asRecord(record.data) + if (typeof data?.message === 'string' && data.message.length > 0) { + return data.message + } + } + + return null +} + +function buildPayload( + session: SessionSnapshot, + fields: Record, + notifyProject?: string | null +): string { + const identity = notifyProject + ? buildOverseerSessionIdentity({ + id: session.id, + flavor: session.flavor, + tag: session.tag, + metadata: { name: session.name ?? undefined }, + notifyProject + }) + : session + return mergeEventPayloadWithSession(fields, identity) +} + +function isAgentMessageContent(content: unknown): boolean { + const record = unwrapRoleWrappedRecordEnvelope(content) + return record?.role === 'agent' +} + +function extractToolFailureSummary(content: unknown): string | null { + const record = unwrapRoleWrappedRecordEnvelope(content) + if (record?.role !== 'agent') return null + const body = record.content + if (!isObject(body)) return null + + if (body.type === 'codex') { + const data = asRecord(body.data) + if (data?.type !== 'tool-call-result') return null + const output = asRecord(data.output) + const exitCode = typeof output?.exit_code === 'number' + ? output.exit_code + : typeof output?.exitCode === 'number' + ? output.exitCode + : null + if (exitCode === null || exitCode === 0) return null + const stderr = typeof output?.stderr === 'string' ? output.stderr.trim() : '' + return stderr.length > 0 ? `Tool failed (exit ${exitCode}): ${stderr.slice(0, 160)}` : `Tool failed with exit code ${exitCode}` + } + + return null +} + +function buildTags(notify: NotifySummary | null, flavor: string): string | null { + const parts: string[] = [] + if (notify?.agent) parts.push(`agent:${notify.agent}`) + if (notify?.project) parts.push(`project:${notify.project}`) + parts.push(`flavor:${flavor}`) + return parts.length > 0 ? parts.join(' ') : null +} + +export class OverseerEventRecorder { + private readonly lastAgentMessageAt = new Map() + private readonly knownPermissionRequestIds = new Map>() + + constructor( + private readonly events: EventStore, + private readonly inbox?: InboxStore + ) {} + + list(options: Parameters[0] = {}): StoredSystemEvent[] { + return this.events.list(options) + } + + count(): number { + return this.events.count() + } + + onAgentMessage(session: SessionSnapshot, messageId: string, content: unknown, ts: number): StoredSystemEvent | null { + let primary: StoredSystemEvent | null = null + + if (isAgentMessageContent(content)) { + this.lastAgentMessageAt.set(session.id, ts) + + const agentBody = unwrapRoleWrappedRecordEnvelope(content) + const agentContent = agentBody?.role === 'agent' ? agentBody.content : content + + const plainText = extractAssistantPlainText(agentContent) + if (plainText) { + if (detectEmptyHapiEventsSentinel(plainText)) { + primary = this.insertSystemEvent(session, { + ts, + sourceKind: 'system', + eventType: 'validation_error', + attentionCandidate: 0, + summary: 'Malformed HAPI_EVENTS sentinel block (empty body)', + relatedSessionId: session.id, + provenance: 'hub-inferred from empty HAPI_EVENTS sentinel pair', + idempotencyKey: `session:${session.id}:message:${messageId}:validation_error:empty_hapi_events`, + payloadFields: { messageId, plainTextPreview: plainText.slice(0, 500) }, + severity: 1 + }) + } else if (detectMalformedNotifySummaryLine(plainText)) { + primary = this.insertSystemEvent(session, { + ts, + sourceKind: 'system', + eventType: 'validation_error', + attentionCandidate: 0, + summary: 'Malformed AGENT_NOTIFY_SUMMARY line on last turn', + relatedSessionId: session.id, + provenance: 'hub-inferred from malformed AGENT_NOTIFY_SUMMARY JSON', + idempotencyKey: `session:${session.id}:message:${messageId}:validation_error:malformed_notify`, + payloadFields: { messageId }, + severity: 1 + }) + } else { + const notify = extractNotifySummary(plainText) + if (notify) { + primary = this.recordNotifySummary(session, messageId, notify, ts) + } + } + } + + if (!primary) { + const toolFailure = extractToolFailureSummary(agentContent) + if (toolFailure) { + primary = this.insertSystemEvent(session, { + ts, + sourceKind: 'system', + sourceRef: session.id, + eventType: 'failed', + attentionCandidate: 1, + operatorActionRequired: 1, + summary: toolFailure, + relatedSessionId: session.id, + provenance: 'hub-inferred from tool-call-result exit code', + idempotencyKey: `session:${session.id}:message:${messageId}:tool_failed`, + payloadFields: { messageId }, + severity: deriveSeverity('failed'), + tags: buildTags(null, session.flavor) + }) + } + } + } + + // Always scoop URLs from any ingestible message text (agent or user). + this.scoopLinksFromContent(session, messageId, content, ts) + return primary + } + + onSessionUpdated(session: Session, tag?: string | null): void { + this.syncPermissionRequests(session, tag ?? null) + } + + onSessionEnd( + session: Session, + tag: string | null, + ts: number, + reason: string | undefined, + getLastAgentPlainText: () => string | null + ): StoredSystemEvent | null { + this.knownPermissionRequestIds.delete(session.id) + + if (reason !== 'completed') { + return null + } + + const lastText = getLastAgentPlainText() + if (lastText && extractNotifySummary(lastText)) { + return null + } + + const snapshot = toSessionSnapshot(session, tag) + return this.insertSystemEvent(snapshot, { + ts, + sourceKind: 'system', + sourceRef: session.id, + eventType: 'completed', + attentionCandidate: 0, + summary: 'Session ended without AGENT_NOTIFY_SUMMARY; hub inferred completion', + relatedSessionId: session.id, + provenance: 'hub-inferred from session-end completed signal', + idempotencyKey: `session:${session.id}:session_end:${ts}:completed_fallback`, + payloadFields: { reason }, + severity: deriveSeverity('completed'), + tags: buildTags(null, snapshot.flavor) + }) + } + + /** + * Hub silence sweep — deliberately does **not** persist `stale` rows. + * + * Idle silence is ambient state derivable from last-activity timestamps + * (`get_worker_health` / list-active). Writing one durable event per idle + * session filled Session Logs with "No agent output for 30 minutes" noise + * and wasted storage. Worker self-reported `stalled` (via AGENT_NOTIFY_SUMMARY) + * still becomes a normal attention-qualified event through the notify path. + * + * Kept as a SyncEngine tick hook for API stability; returns []. + */ + checkStaleSessions(_activeSessions: Session[], _now: number = Date.now()): StoredSystemEvent[] { + return [] + } + + seedLastAgentMessageAt(sessionId: string, ts: number): void { + this.lastAgentMessageAt.set(sessionId, ts) + } + + private scoopLinksFromContent( + session: SessionSnapshot, + messageId: string, + content: unknown, + ts: number + ): StoredSystemEvent[] { + const text = extractTextForLinkScoop(content) + if (!text) return [] + + const urls = extractHttpUrls(text) + if (urls.length === 0) return [] + + const emitted: StoredSystemEvent[] = [] + for (const url of urls) { + const urlKey = normalizeUrlIdempotencyKey(url) + const event = this.insertSystemEvent(session, { + ts, + sourceKind: 'system', + sourceRef: session.id, + eventType: 'link_seen', + attentionCandidate: 0, + operatorActionRequired: 0, + summary: buildLinkSeenSummary(url), + relatedSessionId: session.id, + provenance: 'hub-inferred from message URL scoop', + idempotencyKey: `session:${session.id}:message:${messageId}:link:${urlKey}`, + payloadFields: { messageId, url }, + artifactRefs: JSON.stringify(buildUrlArtifactRefs([url], 'inferred', ts)), + severity: deriveSeverity('link_seen'), + tags: buildTags(null, session.flavor) + }) + if (event) emitted.push(event) + } + return emitted + } + + private recordNotifySummary( + session: SessionSnapshot, + messageId: string, + notify: NotifySummary, + ts: number + ): StoredSystemEvent | null { + const eventType = mapNotifyStatusToEventType(notify.status) + const attentionCandidate = deriveAttentionCandidate(notify.status, notify.action) + const operatorActionRequired = deriveOperatorActionRequired(notify.status, notify.action) + const sourceRef = notify.agent ?? notify.project ?? session.tag ?? session.id + + return this.insertSystemEvent(session, { + ts, + sourceKind: 'worker', + sourceRef, + eventType, + attentionCandidate, + operatorActionRequired, + summary: buildEventSummaryFromNotify(notify), + relatedSessionId: session.id, + provenance: 'AGENT_NOTIFY_SUMMARY', + idempotencyKey: `session:${session.id}:message:${messageId}:notify`, + payloadFields: { + messageId, + notify_summary: notify, + suggested_action: notify.action ?? null + }, + notifyProject: notify.project ?? null, + severity: deriveSeverity(eventType), + tags: buildTags(notify, session.flavor) + }) + } + + private syncPermissionRequests(session: Session, tag: string | null): void { + const requests = session.agentState?.requests ?? null + if (!requests) { + this.knownPermissionRequestIds.delete(session.id) + return + } + + const snapshot = toSessionSnapshot(session, tag) + const currentIds = new Set(Object.keys(requests)) + const known = this.knownPermissionRequestIds.get(session.id) ?? new Set() + + for (const requestId of currentIds) { + if (known.has(requestId)) continue + const request = asRecord(requests[requestId]) + const toolName = typeof request?.tool === 'string' ? request.tool : 'tool' + const summary = `Permission requested: ${toolName}` + this.insertSystemEvent(snapshot, { + ts: Date.now(), + sourceKind: 'system', + sourceRef: session.id, + eventType: 'approval_requested', + attentionCandidate: 1, + operatorActionRequired: 1, + summary, + relatedSessionId: session.id, + provenance: 'hub-inferred from permission prompt', + idempotencyKey: `session:${session.id}:permission:${requestId}`, + payloadFields: { requestId, request }, + severity: deriveSeverity('approval_requested'), + tags: buildTags(null, snapshot.flavor) + }) + } + + this.knownPermissionRequestIds.set(session.id, currentIds) + } + + private insertSystemEvent( + session: SessionSnapshot, + input: Omit & { + riskDetected?: 0 | 1 + payloadFields?: Record + notifyProject?: string | null + } + ): StoredSystemEvent | null { + const { payloadFields = {}, notifyProject, ...rest } = input + const stored = this.events.insert({ + riskDetected: 0, + ...rest, + payloadJson: buildPayload(session, payloadFields, notifyProject) + }) + if (stored && stored.attentionCandidate === 1 && this.inbox) { + this.inbox.promoteAttentionEvent(stored) + } + return stored + } +} + +export function toSessionSnapshot(session: Session, tag?: string | null): SessionSnapshot { + return buildOverseerSessionIdentity({ + id: session.id, + flavor: session.metadata?.flavor ?? 'claude', + tag: tag ?? null, + metadata: session.metadata + }) +} + +export function shouldInjectNotifyContract(flavor: string | undefined | null): boolean { + return flavor !== 'cursor' +} diff --git a/hub/src/sync/permissionModePersistence.test.ts b/hub/src/sync/permissionModePersistence.test.ts index 396cda4b8b..afa38cfcb8 100644 --- a/hub/src/sync/permissionModePersistence.test.ts +++ b/hub/src/sync/permissionModePersistence.test.ts @@ -134,6 +134,7 @@ describe('permission mode persistence', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, _effort?: string, permissionMode?: string ) => { diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 5a63f4547c..be143b7ffc 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -61,6 +61,23 @@ export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEffortResponse + +export type RpcCodexSession = { + id: string + title: string + updatedAt: number + path: string | null + model: string | null + isOld: boolean +} + +export type RpcListCodexSessionsResponse = { + success: boolean + sessions?: RpcCodexSession[] + nextCursor?: string | null + error?: string +} + export class RpcGateway { constructor( private readonly io: Server, @@ -137,6 +154,7 @@ export class RpcGateway { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + importHistory?: boolean, effort?: string, permissionMode?: PermissionMode, serviceTier?: string @@ -145,7 +163,7 @@ export class RpcGateway { const result = await this.machineRpc( machineId, RPC_METHODS.SpawnHappySession, - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, importHistory, effort, permissionMode, serviceTier } ) if (result && typeof result === 'object') { const obj = result as Record @@ -270,6 +288,13 @@ export class RpcGateway { return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse } + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListCodexSessions, options ?? {}) as RpcListCodexSessionsResponse + } + async listCursorModelsForMachine(machineId: string): Promise { return await this.machineRpc(machineId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse } diff --git a/hub/src/sync/sessionCache.applySessionPatch.test.ts b/hub/src/sync/sessionCache.applySessionPatch.test.ts new file mode 100644 index 0000000000..d773caa5d8 --- /dev/null +++ b/hub/src/sync/sessionCache.applySessionPatch.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'bun:test' +import type { SyncEvent } from '@hapi/protocol/types' +import { Store } from '../store' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' + +function createPublisher(events: SyncEvent[]): EventPublisher { + return { + emit: (event: SyncEvent) => { + events.push(event) + } + } as unknown as EventPublisher +} + +// Companion guard for syncEngine.handleRealtimeEvent's new "forward structured +// patches without DB refresh" branch (closes the second half of #884). The +// hub-side fast-path is only safe if applySessionPatch keeps the in-memory +// cache consistent with what just landed in the DB — otherwise subsequent +// callers like NotificationHub.getSession would see stale data and the +// cache-vs-DB divergence would manifest as ghost notifications, stale +// pendingRequestsCount, or wrong todos progress in the session list. +describe('SessionCache.applySessionPatch', () => { + it('applies a todos patch in place when the session is cached', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'todos-patch-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + + const todos = [ + { content: 'one', status: 'pending' as const, priority: 'medium' as const, id: '1' } + ] + const applied = cache.applySessionPatch(created.id, { todos }) + + expect(applied).toBe(true) + expect(cache.getSession(created.id)?.todos).toEqual(todos) + }) + + it('applies a versioned metadata patch by unwrapping value + version', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'meta-patch-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + + const nextVersion = created.metadataVersion + 1 + const applied = cache.applySessionPatch(created.id, { + metadata: { + version: nextVersion, + value: { path: '/tmp', host: 'h', lifecycleState: 'archived' } + } + }) + + expect(applied).toBe(true) + const after = cache.getSession(created.id) + expect(after?.metadata?.lifecycleState).toBe('archived') + expect(after?.metadataVersion).toBe(nextVersion) + }) + + it('applies a versioned agentState patch with null value', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'agent-patch-session', + { path: '/tmp', host: 'h' }, + { controlledByUser: true }, + 'default' + ) + expect(created.agentState).not.toBeNull() + + const nextVersion = created.agentStateVersion + 1 + const applied = cache.applySessionPatch(created.id, { + agentState: { version: nextVersion, value: null } + }) + + expect(applied).toBe(true) + const after = cache.getSession(created.id) + expect(after?.agentState).toBeNull() + expect(after?.agentStateVersion).toBe(nextVersion) + }) + + it('returns false (caller falls back to refresh) when the session is not cached', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const applied = cache.applySessionPatch('does-not-exist', { todos: [] }) + expect(applied).toBe(false) + }) + + it('returns false when patch data fails SessionPatchSchema', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'bad-patch-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + + // Bogus shape: { metadata: { value: ... } } is missing the required version. + const applied = cache.applySessionPatch(created.id, { + metadata: { value: { path: '/x', host: 'y' } } + }) + expect(applied).toBe(false) + }) + + it('refuses an empty patch ({}) so the caller falls back to refreshSession', () => { + // Web-side getSessionPatch rejects empty payloads (Object.keys length 0) + // and would fall through to REST invalidation — exactly the storm we + // are closing. The empty-patch guard keeps the syncEngine on the safe + // legacy refresh path for these no-op events. + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'empty-patch-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + + expect(cache.applySessionPatch(created.id, {})).toBe(false) + }) + + it('clears cached teamState when a null teamState patch lands (TeamDelete)', () => { + // PR #897 review (HAPI Bot, 2026-06-13 Major): TeamDelete events + // drive applyTeamStateDelta to return null; the emit-site sends + // { teamState: null } as the explicit clear signal. Without + // hasOwnProperty-discrimination, `if (patch.teamState !== undefined)` + // skipped the clear path and left the hub cache holding stale + // pre-delete TeamState — sidebar / NotificationHub / dedup all + // would serve stale data until the next full refresh. + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'teamstate-clear-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + // Seed cached teamState (the pre-delete state). + const seedApplied = cache.applySessionPatch(created.id, { + teamState: { teamName: 'crew', members: [{ name: 'a' }] } + }) + expect(seedApplied).toBe(true) + expect(cache.getSession(created.id)?.teamState?.teamName).toBe('crew') + + // TeamDelete: null teamState patch must clear the cache. + const cleared = cache.applySessionPatch(created.id, { teamState: null }) + expect(cleared).toBe(true) + expect(cache.getSession(created.id)?.teamState).toBeUndefined() + }) + + it('leaves teamState untouched when the patch does not carry the key', () => { + // Guard the hasOwnProperty discriminator against a refactor back to + // `if (patch.teamState !== undefined)` — a todos-only patch must + // NOT clear teamState, which a naive `?? undefined` assignment on + // the unconditional branch would do. + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'teamstate-untouched-session', + { path: '/tmp', host: 'h' }, + null, + 'default' + ) + cache.applySessionPatch(created.id, { + teamState: { teamName: 'crew', members: [{ name: 'a' }] } + }) + expect(cache.getSession(created.id)?.teamState?.teamName).toBe('crew') + + const todosOnly = cache.applySessionPatch(created.id, { + todos: [{ content: 'one', status: 'pending' as const, priority: 'medium' as const, id: '1' }] + }) + expect(todosOnly).toBe(true) + expect(cache.getSession(created.id)?.teamState?.teamName).toBe('crew') + }) + + it('refuses cross-namespace patches even if the session exists', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const created = cache.getOrCreateSession( + 'ns-guard-session', + { path: '/tmp', host: 'h' }, + null, + 'tenant-a' + ) + + const applied = cache.applySessionPatch(created.id, { todos: [] }, 'tenant-b') + expect(applied).toBe(false) + }) +}) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 304b01ccae..50c9db15b3 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,4 +1,4 @@ -import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' +import { AgentStateSchema, MetadataSchema, SessionPatchSchema, TeamStateSchema } from '@hapi/protocol/schemas' import type { CodexCollaborationMode, PermissionMode, Session, SessionPatch } from '@hapi/protocol/types' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' @@ -170,6 +170,89 @@ export class SessionCache { } } + /** + * Apply a structured patch to the cached Session in place. + * + * Returns `true` if the patch parsed, carried at least one field, and a + * Session was present to update. Returns `false` when: + * - the patch data fails SessionPatchSchema (caller falls back to + * refreshSession), + * - the patch is the empty object `{}` — the web client's + * `getSessionPatch` rejects empty payloads and would fall through to + * REST invalidation, so we route empty events through the legacy + * refresh path instead (caller falls back to refreshSession), + * - the session is not in the cache (caller falls back to refreshSession + * so the DB read can hydrate it), + * - the patch's namespace hint disagrees with the cached session + * namespace (cross-namespace event, caller skips). + * + * Companion to syncEngine.handleRealtimeEvent. Closes the second half of + * #884 by giving the four no-data emit-sites in cli/sessionHandlers.ts a + * way to propagate their delta straight through to SSE without a DB + * re-read or full-Session broadcast. + */ + applySessionPatch(sessionId: string, data: unknown, namespace?: string): boolean { + const parsed = SessionPatchSchema.safeParse(data) + if (!parsed.success) { + return false + } + + // Empty patch ({}): forward would hit the web-side fallback that + // triggers a REST refetch. Let the caller fall back to refreshSession + // so the existing full-Session broadcast path keeps the cache + // coherent. + if (Object.keys(parsed.data).length === 0) { + return false + } + + const session = this.sessions.get(sessionId) + if (!session) { + return false + } + + if (namespace && session.namespace !== namespace) { + return false + } + + const patch = parsed.data + + if (patch.active !== undefined) session.active = patch.active + if (patch.thinking !== undefined) session.thinking = patch.thinking + if (patch.activeAt !== undefined) session.activeAt = patch.activeAt + if (patch.updatedAt !== undefined) session.updatedAt = Math.max(session.updatedAt, patch.updatedAt) + if (patch.model !== undefined) session.model = patch.model + if (patch.modelReasoningEffort !== undefined) session.modelReasoningEffort = patch.modelReasoningEffort + if (patch.effort !== undefined) session.effort = patch.effort + if (Object.prototype.hasOwnProperty.call(patch, 'serviceTier')) { + session.serviceTier = patch.serviceTier ?? null + } + if (patch.permissionMode !== undefined) session.permissionMode = patch.permissionMode + if (patch.collaborationMode !== undefined) session.collaborationMode = patch.collaborationMode + if (patch.backgroundTaskCount !== undefined) session.backgroundTaskCount = patch.backgroundTaskCount + if (patch.todos !== undefined) session.todos = patch.todos + // teamState uses `null` on the wire as the explicit clear signal + // (TeamDelete events); `undefined` means "field absent, don't + // touch". Use hasOwnProperty to discriminate, then map null → + // undefined to match the cached Session.teamState type + // (TeamState | undefined). Without this, a TeamDelete leaves the + // hub cache holding the stale pre-delete TeamState even though + // the DB row was cleared — sidebar / NotificationHub / dedup all + // serve stale data until the next full refresh. + if (Object.prototype.hasOwnProperty.call(patch, 'teamState')) { + session.teamState = patch.teamState ?? undefined + } + if (patch.metadata !== undefined) { + session.metadata = patch.metadata.value + session.metadataVersion = patch.metadata.version + } + if (patch.agentState !== undefined) { + session.agentState = patch.agentState.value + session.agentStateVersion = patch.agentState.version + } + + return true + } + handleSessionAlive(payload: { sid: string time: number @@ -374,6 +457,35 @@ export class SessionCache { }) } + /** + * tiann/hapi#893 (scratchlist v2): emit a `session-updated` SSE patch + * carrying `scratchlistUpdatedAt` so other clients viewing the same + * session refetch the entries query. Called by `SyncEngine` after + * any successful scratchlist mutation. The timestamp is the trigger, + * not the payload - clients use it as a change-detection token and + * pull entries via the dedicated REST query. + * + * Per operator decision (see brief): piggyback on `session-updated` + * rather than introduce a new event type, because scratchlist + * mutations are exceedingly rare relative to keep-alive patches. + * + * Resolves the namespace from the in-memory session map (or the DB + * row as a fallback) so the SSE manager can scope the broadcast + * correctly even if the cache is cold. + */ + emitScratchlistChanged(sessionId: string, updatedAt: number = Date.now()): void { + const cached = this.sessions.get(sessionId) + const namespace = cached?.namespace + ?? this.store.sessions.getSession(sessionId)?.namespace + if (!namespace) return + this.publisher.emit({ + type: 'session-updated', + sessionId, + namespace, + data: { scratchlistUpdatedAt: updatedAt } satisfies SessionPatch + }) + } + handleSessionEnd(payload: { sid: string; time: number }): void { const t = clampAliveTime(payload.time) ?? Date.now() @@ -932,6 +1044,8 @@ export class SessionCache { } if (options.deleteOldSession) { + this.store.events.repointSession(oldSessionId, newSessionId) + this.store.inbox.repointSession(oldSessionId, newSessionId) const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) if (!deleted) { throw new Error('Failed to delete old session during merge') diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 8fa18c074a..f48a4aae55 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -640,6 +640,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, effort?: string ) => { capturedModel = model @@ -1014,6 +1015,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, _effort?: string, permissionMode?: string ) => { diff --git a/hub/src/sync/syncEngine-scratchlist.test.ts b/hub/src/sync/syncEngine-scratchlist.test.ts new file mode 100644 index 0000000000..6559cc34e2 --- /dev/null +++ b/hub/src/sync/syncEngine-scratchlist.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'bun:test' +import type { SyncEvent } from '@hapi/protocol/types' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' +import { SyncEngine } from './syncEngine' + +/** + * Tests for scratchlist v2 (tiann/hapi#893) wiring at the SyncEngine / + * SessionCache layer: + * - every successful mutation emits a `session-updated` SyncEvent + * carrying `scratchlistUpdatedAt` + * - failed mutations (entry not found, duplicate) emit nothing + * - the patch is namespace-scoped to the session's own namespace so + * the SSE manager doesn't broadcast across operators + * + * The web client uses the patch as a refetch trigger; the timestamp + * itself is the only signal, the entries arrive via the dedicated + * `/api/sessions/:id/scratchlist` GET endpoint. + */ + +function createCapturingPublisher(events: SyncEvent[]): EventPublisher { + return { + emit: (event: SyncEvent) => { + events.push(event) + } + } as unknown as EventPublisher +} + +describe('SessionCache.emitScratchlistChanged', () => { + it('emits a session-updated patch carrying scratchlistUpdatedAt', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + const session = cache.getOrCreateSession( + 'tag', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + // Drain spawn events so we can assert on the scratchlist + // emission alone. + events.length = 0 + + cache.emitScratchlistChanged(session.id, 9999) + + expect(events).toHaveLength(1) + const event = events[0]! + expect(event.type).toBe('session-updated') + if (event.type !== 'session-updated') throw new Error('unreachable') + expect(event.sessionId).toBe(session.id) + expect(event.namespace).toBe('default') + expect(event.data).toEqual({ scratchlistUpdatedAt: 9999 }) + }) + + it('does not emit when the session is unknown (no namespace to scope to)', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + cache.emitScratchlistChanged('does-not-exist', 9999) + expect(events).toHaveLength(0) + }) +}) + +describe('SyncEngine scratchlist mutations emit session-updated patches', () => { + function setup() { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + // We attach the EventPublisher to SyncEngine via a private field + // path so the route-layer surface (createScratchlistEntry, etc.) + // exercises the same code path used in production. We only need + // the cache for `getOrCreateSession`; the engine reuses the + // store internally. + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + // SyncEngine constructs its own SessionCache internally - shimming + // the inner one would be brittle. Use the engine's events stream + // directly via subscription. + const engineEvents: SyncEvent[] = [] + engine.subscribe((e) => { engineEvents.push(e) }) + return { engine, store, events, cache, engineEvents } + } + + it('createScratchlistEntry emits a session-updated patch on success', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-create', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + // Drain events from the spawn so we can assert on the mutation + // emission alone. + engineEvents.length = 0 + + const result = engine.createScratchlistEntry(session.id, 'note', { entryId: 'e1' }) + expect(result.outcome).toBe('created') + + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(1) + const patch = matching[0] + if (!patch || patch.type !== 'session-updated') throw new Error('unreachable') + expect(patch.sessionId).toBe(session.id) + expect(patch.namespace).toBe('default') + + engine.stop() + }) + + it('updateScratchlistEntry emits a session-updated patch on success', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-update', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engine.createScratchlistEntry(session.id, 'before', { entryId: 'e1' }) + engineEvents.length = 0 + + const updated = engine.updateScratchlistEntry(session.id, 'e1', 'after') + expect(updated).not.toBeNull() + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(1) + + engine.stop() + }) + + it('updateScratchlistEntry on a missing entry emits nothing', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-update-missing', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engineEvents.length = 0 + const updated = engine.updateScratchlistEntry(session.id, 'never-existed', 'whatever') + expect(updated).toBeNull() + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(0) + engine.stop() + }) + + it('deleteScratchlistEntry emits a session-updated patch on success', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-delete', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engine.createScratchlistEntry(session.id, 'doomed', { entryId: 'e1' }) + engineEvents.length = 0 + const removed = engine.deleteScratchlistEntry(session.id, 'e1') + expect(removed).toBe(true) + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(1) + engine.stop() + }) + + it('deleteScratchlistEntry on a missing entry emits nothing', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-delete-missing', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engineEvents.length = 0 + const removed = engine.deleteScratchlistEntry(session.id, 'no-such-entry') + expect(removed).toBe(false) + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(0) + engine.stop() + }) + + it('createScratchlistEntry on duplicate does not emit an extra patch', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-dup', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engine.createScratchlistEntry(session.id, 'first', { entryId: 'dup' }) + engineEvents.length = 0 + const result = engine.createScratchlistEntry(session.id, 'second', { entryId: 'dup' }) + if (result.outcome === 'session-not-found') throw new Error('unexpected') + expect(result.outcome).toBe('duplicate') + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(0) + engine.stop() + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d59f58fd47..7a1235086b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -10,7 +10,8 @@ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' import type { CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' -import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import { unwrapRoleWrappedRecordEnvelope, extractAssistantPlainText } from '@hapi/protocol/messages' +import { AGENT_NOTIFY_CONTRACT_INLINE_PREFIX } from '@hapi/protocol' import type { Server } from 'socket.io' import type { Store, CancelQueuedMessageResult } from '../store' import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport' @@ -30,6 +31,7 @@ import { type RpcGeneratedImageResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcListCodexSessionsResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, type RpcListOpencodeReasoningEffortOptionsResponse, @@ -40,6 +42,11 @@ import { type RpcUploadFileResponse } from './rpcGateway' import { SessionCache } from './sessionCache' +import { OverseerEventRecorder, shouldInjectNotifyContract, toSessionSnapshot } from './overseerEventRecorder' +import { OverseerEntity } from './overseerEntity' +import type { ListSystemEventsOptions, StoredSystemEvent } from '../store' +import type { InboxOperatorAction } from '@hapi/protocol' +import type { ListInboxItemsOptions, StoredInboxItem } from '../store/inboxItems' export type { Session, SyncEvent } from '@hapi/protocol/types' export type { Machine } from './machineCache' @@ -51,6 +58,7 @@ export type { RpcGeneratedImageResponse, RpcListDirectoryResponse, RpcListCodexModelsResponse, + RpcListCodexSessionsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, RpcListOpencodeReasoningEffortOptionsResponse, @@ -133,6 +141,8 @@ export class SyncEngine { private readonly machineCache: MachineCache private readonly messageService: MessageService private readonly rpcGateway: RpcGateway + private readonly overseerEvents: OverseerEventRecorder + private readonly overseer: OverseerEntity private inactivityTimer: NodeJS.Timeout | null = null /** Sessions that emitted `session-ready` (Cursor ACP load/newSession complete). */ private readonly sessionReadyIds = new Set() @@ -153,6 +163,14 @@ export class SyncEngine { (sessionId, updatedAt) => this.recordSessionActivity(sessionId, updatedAt) ) this.rpcGateway = new RpcGateway(io, rpcRegistry) + this.overseerEvents = new OverseerEventRecorder(store.events, store.inbox) + this.overseer = new OverseerEntity({ + events: store.events, + inbox: store.inbox, + messages: store.messages, + getSession: (sessionId) => this.getSession(sessionId), + getSessions: () => this.getSessions() + }) this.reloadAll() this.inactivityTimer = setInterval(() => this.expireInactive(), 5_000) } @@ -270,19 +288,66 @@ export class SyncEngine { handleRealtimeEvent(event: SyncEvent): void { if (event.type === 'session-updated' && event.sessionId) { - // Snapshot agent session IDs before refresh — safe because JS is single-threaded - // and refreshSession replaces the Map entry with a new object. + // Closes the second half of #884: when a CLI handler emits a + // structured patch (todos / teamState / metadata / agentState), + // apply it in place and forward the patch as-is. This skips both + // the DB re-read AND the full-Session SSE broadcast that the + // legacy no-data path went through. Web clients hit + // getSessionPatch's truthy path and patch the cache instead of + // falling through to the per-session REST invalidation that drove + // the refetch storm. + // + // `applySessionPatch` MUTATES the cached Session in place (it + // reassigns `session.metadata = patch.metadata.value`), so we + // MUST snapshot the metadata reference BEFORE calling it. + // Reading `before?.metadata` after the mutation would see the + // new value and `hasSameAgentSessionIds` would always return + // true — breaking the dedup-on-metadata-id-change trigger that + // the legacy `refreshSession` path got for free (refresh + // REPLACES the cache entry, leaving the old object reference + // intact for the caller). Use the snapshot for BOTH branches + // so the comparison contract is identical. const before = this.sessionCache.getSession(event.sessionId) + const beforeMetadata = before?.metadata ?? null + const patchApplied = event.data + ? this.sessionCache.applySessionPatch(event.sessionId, event.data, event.namespace) + : false + + if (patchApplied) { + this.eventPublisher.emit(event) + const after = this.sessionCache.getSession(event.sessionId) + if (after?.metadata && !this.hasSameAgentSessionIds(beforeMetadata, after.metadata)) { + if (!this.canRunCursorDedup(after)) { + return + } + void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { + // best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates + }) + } + return + } + + // No-data event (or data we can't apply directly, e.g. full + // Session payload from a different emitter): fall back to the + // legacy refresh-from-DB-and-broadcast path. this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) - if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) { + if (after) { + this.overseerEvents.onSessionUpdated( + after, + this.store.sessions.getSession(after.id)?.tag ?? null + ) + } + if (after?.metadata && !this.hasSameAgentSessionIds(beforeMetadata, after.metadata)) { if (!this.canRunCursorDedup(after)) { + this.eventPublisher.emit(event) return } void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { // best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates }) } + this.eventPublisher.emit(event) return } @@ -295,11 +360,59 @@ export class SyncEngine { if (!this.getSession(event.sessionId)) { this.sessionCache.refreshSession(event.sessionId) } + const session = this.getSession(event.sessionId) + if (session && event.message) { + const storedSession = this.store.sessions.getSession(event.sessionId) + this.overseerEvents.onAgentMessage( + toSessionSnapshot(session, storedSession?.tag ?? null), + event.message.id, + event.message.content, + event.message.createdAt + ) + } } this.eventPublisher.emit(event) } + getOverseer(): OverseerEntity { + return this.overseer + } + + getSystemEvents(options: ListSystemEventsOptions = {}): StoredSystemEvent[] { + return this.overseerEvents.list(options) + } + + getSystemEventCount(): number { + return this.overseerEvents.count() + } + + getInboxItems(options: ListInboxItemsOptions = {}): StoredInboxItem[] { + return this.store.inbox.list(options) + } + + getInboxItemCount(): number { + return this.store.inbox.count() + } + + recordInboxOperatorAction( + inboxItemId: number, + action: InboxOperatorAction, + feedback: string | null = null, + snoozedUntil: number | null = null + ): StoredInboxItem | null { + return this.store.inbox.recordOperatorAction(inboxItemId, action, feedback, snoozedUntil) + } + + private getLastAgentPlainText(sessionId: string): string | null { + const messages = this.store.messages.getMessages(sessionId, 80) + for (let i = messages.length - 1; i >= 0; i -= 1) { + const text = extractAssistantPlainText(messages[i].content) + if (text) return text + } + return null + } + handleSessionAlive(payload: { sid: string time: number @@ -332,6 +445,16 @@ export class SyncEngine { const shouldRetryDedup = !isCursorAcp || this.sessionReadyIds.has(payload.sid) this.sessionCache.handleSessionEnd(payload) + const session = this.getSession(payload.sid) + if (session) { + this.overseerEvents.onSessionEnd( + session, + this.store.sessions.getSession(session.id)?.tag ?? null, + payload.time, + payload.reason, + () => this.getLastAgentPlainText(session.id) + ) + } this.eventPublisher.emit({ type: 'session-ended', sessionId: payload.sid, @@ -354,6 +477,113 @@ export class SyncEngine { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } + /** + * tiann/hapi#893 (scratchlist v2). Read-side: list entries for a + * session. Auth / namespace check is the route layer's job (via + * `requireSessionFromParam`); by the time we get here the caller + * already proved access. + */ + listScratchlistEntries(sessionId: string): Array<{ + entryId: string + text: string + createdAt: number + updatedAt: number + }> { + return this.store.scratchlist.list(sessionId).map((row) => ({ + entryId: row.entryId, + text: row.text, + createdAt: row.createdAt, + updatedAt: row.updatedAt + })) + } + + countScratchlistEntries(sessionId: string): number { + return this.store.scratchlist.count(sessionId) + } + + /** + * Read a single entry by id. The route layer uses this to short- + * circuit duplicate POSTs (migration retry) BEFORE running the + * server-side cap check; otherwise an idempotent retry against a + * session that has hit `SCRATCHLIST_MAX_ENTRIES` would 409 when it + * should 200 with the existing row. + */ + getScratchlistEntry( + sessionId: string, + entryId: string + ): { entryId: string; text: string; createdAt: number; updatedAt: number } | null { + const row = this.store.scratchlist.get(sessionId, entryId) + if (!row) return null + return { + entryId: row.entryId, + text: row.text, + createdAt: row.createdAt, + updatedAt: row.updatedAt + } + } + + /** + * Insert a scratchlist entry. Returns the canonical row on success + * (so the route layer can serialise it without a follow-up read). + * Emits a `session-updated` SSE patch carrying `scratchlistUpdatedAt` + * so other clients viewing the same session refetch. + * + * `outcome: 'duplicate'` covers the migration path's idempotency: + * the web client may retry pushing a localStorage entry after a + * partial failure; the second attempt should be a no-op rather than + * a hard error. Route layer maps duplicate → 200/conflict per its + * own contract; this layer just reports it. + */ + createScratchlistEntry( + sessionId: string, + text: string, + options?: { entryId?: string; createdAt?: number } + ): { + outcome: 'created' | 'duplicate' + entry: { entryId: string; text: string; createdAt: number; updatedAt: number } + } | { outcome: 'session-not-found' } { + const result = this.store.scratchlist.create(sessionId, text, options) + if (result.outcome === 'session-not-found') { + return result + } + if (result.outcome === 'created') { + this.sessionCache.emitScratchlistChanged(sessionId, result.entry.updatedAt) + } + return { + outcome: result.outcome, + entry: { + entryId: result.entry.entryId, + text: result.entry.text, + createdAt: result.entry.createdAt, + updatedAt: result.entry.updatedAt + } + } + } + + updateScratchlistEntry( + sessionId: string, + entryId: string, + text: string + ): { entryId: string; text: string; createdAt: number; updatedAt: number } | null { + const updated = this.store.scratchlist.update(sessionId, entryId, text) + if (!updated) return null + this.sessionCache.emitScratchlistChanged(sessionId, updated.updatedAt) + return { + entryId: updated.entryId, + text: updated.text, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt + } + } + + deleteScratchlistEntry(sessionId: string, entryId: string): boolean { + const removed = this.store.scratchlist.delete(sessionId, entryId) + if (removed) { + this.sessionCache.emitScratchlistChanged(sessionId, Date.now()) + } + return removed + } + handleMachineAlive(payload: { machineId: string; time: number }): void { this.machineCache.handleMachineAlive(payload) } @@ -370,6 +600,7 @@ export class SyncEngine { this.triggerDedupIfNeeded(session.id) } this.machineCache.expireInactive() + this.overseerEvents.checkStaleSessions(this.sessionCache.getSessions()) // Piggybacked on the inactivity tick; not a logical part of expireInactive // but shares its 5s cadence (avoids a second timer). this.messageService.releaseMatureScheduledMessages(Date.now()) @@ -413,7 +644,16 @@ export class SyncEngine { scheduledAt?: number | null } ): Promise { - await this.messageService.sendMessage(sessionId, payload) + const session = this.getSession(sessionId) + const flavor = session?.metadata?.flavor ?? 'claude' + const text = payload.text && shouldInjectNotifyContract(flavor) + ? `${AGENT_NOTIFY_CONTRACT_INLINE_PREFIX}${payload.text}` + : payload.text + + await this.messageService.sendMessage(sessionId, { + ...payload, + text + }) this.sessionCache.markMessageQueued(sessionId) this.sessionCache.recordSessionActivity(sessionId, Date.now()) } @@ -718,6 +958,7 @@ export class SyncEngine { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + importHistory?: boolean, effort?: string, permissionMode?: PermissionMode, serviceTier?: string @@ -732,6 +973,7 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + importHistory, effort, permissionMode, serviceTier @@ -1187,6 +1429,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + false, session.effort ?? undefined, preferredPermissionMode, session.serviceTier ?? undefined @@ -1589,6 +1832,13 @@ export class SyncEngine { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.rpcGateway.listCodexSessionsForMachine(machineId, options) + } + async listCursorModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCursorModelsForSession(sessionId) } diff --git a/hub/src/sync/syncEngineHandleRealtimeEvent.test.ts b/hub/src/sync/syncEngineHandleRealtimeEvent.test.ts new file mode 100644 index 0000000000..4290c08fd9 --- /dev/null +++ b/hub/src/sync/syncEngineHandleRealtimeEvent.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'bun:test' +import type { SessionPatch, SyncEvent } from '@hapi/protocol/types' +import { RpcRegistry } from '../socket/rpcRegistry' +import { Store } from '../store' +import { SessionCache } from './sessionCache' +import { SyncEngine } from './syncEngine' + +/** + * Regression guard for the in-place-mutation interaction between + * `SessionCache.applySessionPatch` and `SyncEngine.handleRealtimeEvent`'s + * dedup trigger. + * + * `applySessionPatch` MUTATES the cached Session in place (it reassigns + * `session.metadata = patch.metadata.value`). The dedup-on-metadata-change + * branch in `handleRealtimeEvent` needs to compare BEFORE and AFTER agent + * session IDs to decide whether to trigger `deduplicateByAgentSessionId`. + * Without snapshotting the metadata reference before the mutation, `before` + * and `after` resolve to the SAME object reference, `hasSameAgentSessionIds` + * is always true, and dedup silently never fires on the fast path. The + * legacy `refreshSession` path got dedup for free because it REPLACED the + * cache entry with a new Session object, leaving the pre-refresh reference + * intact for the comparator. + * + * This was a real regression introduced by the refetch-storm fix + * (#884 second half) — the fast-path replaced the refresh-then-broadcast + * path for the four CLI emit-sites including the `update-metadata` RPC + * handler, which is exactly where a Cursor session id change would land + * (CLI resume re-stamps `metadata.cursorSessionId`). + */ +describe('SyncEngine.handleRealtimeEvent dedup-on-metadata-change', () => { + function makeEngine(): { engine: SyncEngine; cache: SessionCache; dedupCalls: string[] } { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + const cache = (engine as unknown as { sessionCache: SessionCache }).sessionCache + const dedupCalls: string[] = [] + const originalDedup = cache.deduplicateByAgentSessionId.bind(cache) + cache.deduplicateByAgentSessionId = async (sessionId: string) => { + dedupCalls.push(sessionId) + // Do not actually run dedup; we only need to assert the trigger + // fired. Running the real merge logic would require additional + // store fixtures and is covered by sessionCache tests. + void originalDedup + } + return { engine, cache, dedupCalls } + } + + it('triggers dedup when a structured metadata patch changes the agent session id', () => { + const { engine, cache, dedupCalls } = makeEngine() + + const created = cache.getOrCreateSession( + 'cursor-session-fast-path', + { path: '/tmp', host: 'h', flavor: 'cursor', cursorSessionId: 'cursor-old' }, + null, + 'default' + ) + + const patch: SessionPatch = { + metadata: { + version: created.metadataVersion + 1, + value: { + path: '/tmp', + host: 'h', + flavor: 'cursor', + cursorSessionId: 'cursor-new' + } + } + } + + const event: SyncEvent = { + type: 'session-updated', + sessionId: created.id, + data: patch + } + engine.handleRealtimeEvent(event) + + expect(dedupCalls).toEqual([created.id]) + expect(cache.getSession(created.id)?.metadata?.cursorSessionId).toBe('cursor-new') + }) + + it('does not trigger dedup when the patch leaves agent session ids unchanged', () => { + const { engine, cache, dedupCalls } = makeEngine() + + const created = cache.getOrCreateSession( + 'cursor-session-fast-path-noop', + { path: '/tmp', host: 'h', flavor: 'cursor', cursorSessionId: 'cursor-stable' }, + null, + 'default' + ) + + // A todos patch carries no metadata and must NOT trigger dedup. + const event: SyncEvent = { + type: 'session-updated', + sessionId: created.id, + data: { todos: [] } satisfies SessionPatch + } + engine.handleRealtimeEvent(event) + + expect(dedupCalls).toEqual([]) + }) + + it('triggers dedup on the legacy refresh fallback path (no patch data)', () => { + // Tighten the contract for the legacy refresh-from-DB branch: + // a no-`data` session-updated event still needs to fire dedup + // when the DB read surfaces a new agent session id. The shared + // `beforeMetadata` snapshot covers both branches, this guards + // against a refactor breaking the legacy path. + const { engine, cache, dedupCalls } = makeEngine() + + const created = cache.getOrCreateSession( + 'cursor-session-legacy-path', + { path: '/tmp', host: 'h', flavor: 'cursor', cursorSessionId: 'cursor-legacy-old' }, + null, + 'default' + ) + + // Persist a metadata change to the DB without going through the + // cache mutation path, so refreshSession's DB read picks up the + // new value when handleRealtimeEvent fires. + const updateResult = (engine as unknown as { store: Store }).store.sessions.updateSessionMetadata( + created.id, + { path: '/tmp', host: 'h', flavor: 'cursor', cursorSessionId: 'cursor-legacy-new' }, + created.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(updateResult.result).toBe('success') + + const event: SyncEvent = { + type: 'session-updated', + sessionId: created.id + } + engine.handleRealtimeEvent(event) + + expect(dedupCalls).toEqual([created.id]) + }) +}) diff --git a/hub/src/telegram/sessionView.ts b/hub/src/telegram/sessionView.ts index f61d5b6df9..0f64303cf0 100644 --- a/hub/src/telegram/sessionView.ts +++ b/hub/src/telegram/sessionView.ts @@ -8,10 +8,9 @@ import { InlineKeyboard } from 'grammy' import type { Machine, Session } from '../sync/syncEngine' import { ACTIONS } from './callbacks' -import { createCallbackData, truncate, getSessionName } from './renderer' +import { createCallbackData, getSessionName } from './renderer' import { getAgentName } from '../notifications/sessionInfo' - -const MAX_TOOL_ARGS_LENGTH = 150 +import { formatToolArgumentsDetailed } from '../notifications/toolArgs' type NotificationContext = { hasContext: boolean @@ -157,79 +156,6 @@ export function createNotificationKeyboard(session: Session, publicUrl: string): return keyboard } -/** - * Format detailed tool arguments for notification display - */ -function formatToolArgumentsDetailed(tool: string, args: any): string { - if (!args) return '' - - try { - switch (tool) { - case 'Edit': { - const file = args.file_path || args.path || 'unknown' - const oldStr = args.old_string ? truncate(args.old_string, 50) : '' - const newStr = args.new_string ? truncate(args.new_string, 50) : '' - let result = `File: ${truncate(file, MAX_TOOL_ARGS_LENGTH)}` - if (oldStr) result += `\nOld: "${oldStr}"` - if (newStr) result += `\nNew: "${newStr}"` - return result - } - - case 'Write': { - const file = args.file_path || args.path || 'unknown' - const content = args.content ? `${args.content.length} chars` : '' - return `File: ${truncate(file, MAX_TOOL_ARGS_LENGTH)}${content ? ` (${content})` : ''}` - } - - case 'Read': { - const file = args.file_path || args.path || 'unknown' - return `File: ${truncate(file, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'Bash': { - const cmd = args.command || '' - return `Command: ${truncate(cmd, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'Agent': - case 'Task': { - const desc = args.description || args.prompt || '' - return `Task: ${truncate(desc, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'Grep': - case 'Glob': { - const pattern = args.pattern || '' - const path = args.path || '' - let result = `Pattern: ${pattern}` - if (path) result += `\nPath: ${truncate(path, 80)}` - return result - } - - case 'WebFetch': { - const url = args.url || '' - return `URL: ${truncate(url, MAX_TOOL_ARGS_LENGTH)}` - } - - case 'TodoWrite': { - const count = args.todos?.length || 0 - return `Updating ${count} todo items` - } - - default: { - // Generic args display for unknown tools - const argStr = JSON.stringify(args) - if (argStr.length > 10) { - return `Args: ${truncate(argStr, MAX_TOOL_ARGS_LENGTH)}` - } - return '' - } - } - } catch { - return '' - } -} - function buildMiniAppDeepLink(baseUrl: string, startParam: string): string { try { const url = new URL(baseUrl) diff --git a/hub/src/tunnel/tlsGate.ts b/hub/src/tunnel/tlsGate.ts index 8dcb46fe0d..bc7fa8a9b0 100644 --- a/hub/src/tunnel/tlsGate.ts +++ b/hub/src/tunnel/tlsGate.ts @@ -59,7 +59,7 @@ function hostMatchesCertificate(host: string, cert: PeerCertificate): boolean { } const commonName = cert.subject?.CN - if (!commonName) { + if (!commonName || typeof commonName !== 'string') { return false } diff --git a/hub/src/web/routes/_agentImport/types.ts b/hub/src/web/routes/_agentImport/types.ts new file mode 100644 index 0000000000..bf2deb7ff6 --- /dev/null +++ b/hub/src/web/routes/_agentImport/types.ts @@ -0,0 +1,118 @@ +/** + * Shared types for the multi-flavor agent-session import surface. + * + * The codex flavor (web/src/components/CodexSessionSyncDialog.tsx + + * hub/src/web/routes/codexDesktop.ts) already shipped upstream in + * `tiann/hapi#796`. This module factors out the small surface the dialog + * needs to render a per-flavor row generically so the cursor flavor (and + * future claude / gemini / opencode flavors) can reuse the same UI shape + * without one-off type drift. + * + * Hub-side parallel routes (`/codex/*` and `/cursor/*`) remain alongside + * each other rather than being collapsed into a generic + * `/api/agent-sessions/...` so each flavor keeps its own refusal vocabulary + * and row shape. + */ + +/** Agent flavors supported by the import dialog. */ +export type AgentImportFlavor = 'codex' | 'cursor' + +/** + * Source format of an importable cursor session as discovered on disk. + * + * The strict refusal contract (see `cursorImporter`) only ships ACP-mode + * HAPI rows. `legacy` sessions are transplanted via the `agent acp` + * verify-probe before being given a HAPI row; if the probe refuses, the + * row is never created and the legacy `store.db` is not touched. + * + * `acp` sessions are imported by reading the existing + * `~/.cursor/acp-sessions//` directory directly (no transplant). + */ +export type CursorImportSourceFormat = 'legacy' | 'acp' + +/** + * Mirrors `tiann/hapi#824`'s `CursorMigrateRefusalReason` (defined in + * shared/src/apiTypes.ts) plus the few import-only cases that the + * migrator does not produce because it always operates on a pre-existing + * HAPI session. + */ +export type CursorImportRefusalReason = + | 'verify_load_failed' + | 'missing_on_disk_store' + | 'target_already_exists' + | 'already_imported' + | 'agent_binary_not_found' + | 'verify_timeout' + | 'corrupted_store' + | 'ambiguous_legacy_store' + | 'internal_error' + +/** Per-row metadata returned by `GET /api/cursor/importable-sessions`. */ +export interface CursorImportableSessionSummary { + /** The cursor sessionId (UUID-ish basename of the on-disk dir). */ + id: string + /** Best-effort display title — chat name from meta record, or "Untitled". */ + title: string + /** First user message from the store, when readable. */ + firstUserMessage?: string | null + /** Absolute workspace path the chat was opened against. */ + workspacePath?: string | null + /** Absolute path of the on-disk `store.db` we read this row from. */ + storeDbPath: string + /** Source format: legacy needs verify+transplant; acp imports as-is. */ + sourceFormat: CursorImportSourceFormat + /** mtime of the on-disk `store.db`. */ + modifiedAt: number + /** Size of the on-disk `store.db` in bytes. */ + sizeBytes: number + /** + * Set to the HAPI sessionId when a HAPI session row in this namespace + * already references this cursor uuid. Dialog renders such rows as + * read-only chips ("already imported") and refuses to re-import. + */ + alreadyImportedHapiSessionId?: string | null +} + +export interface CursorImportableSessionsResponse { + success: true + sessions: CursorImportableSessionSummary[] +} + +/** Per-row import result. The `import` endpoint returns one of these per uuid. */ +export type CursorImportRowOutcome = + | { + ok: true + uuid: string + hapiSessionId: string + sourceFormat: CursorImportSourceFormat + durationMs: number + } + | { + ok: false + uuid: string + reason: CursorImportRefusalReason + message: string + durationMs: number + } + +export interface CursorImportRequest { + /** + * One or more cursor uuids to import. Multi-select mirrors codex but + * each row's outcome is independent (one failing does not abort the + * batch). + */ + uuids: string[] + /** + * Optional explicit workspace path. Used by the ambiguity check + * (`workspaceHashFromPath` in `cursorLegacyMigrator`) when the same + * uuid exists in multiple `` drawers. If omitted, the row's own + * `workspacePath` (from discovery) is used. + */ + workspacePath?: string | null +} + +export interface CursorImportResponse { + success: true + results: CursorImportRowOutcome[] + importedCount: number +} diff --git a/hub/src/web/routes/claudeDesktop.test.ts b/hub/src/web/routes/claudeDesktop.test.ts new file mode 100644 index 0000000000..6f86b166e9 --- /dev/null +++ b/hub/src/web/routes/claudeDesktop.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { Store } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createClaudeDesktopRoutes, importSelectedClaudeSessions, listLocalClaudeSessions } from './claudeDesktop' + +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + +// 中文注释:写一条“内容丰富”的 Claude transcript,覆盖 user 字符串 / assistant text+thinking+tool_use / user tool_result,并夹杂若干 sidecar。 +function createRichTranscript(claudeHome: string, sessionId: string, encodedCwd = '-home-user-project', cwd: string | null = '/home/user/project'): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + // sidecar lines: must be skipped + { type: 'last-prompt', prompt: 'ignored' }, + { type: 'mode', mode: 'default' }, + { type: 'attachment', sessionId, content: 'ignored' }, + // isMeta user line: skipped + { type: 'user', isMeta: true, sessionId, cwd, message: { role: 'user', content: 'ignored' } }, + // real user message (string content) + { type: 'user', sessionId, cwd, message: { role: 'user', content: 'hello claude' } }, + // assistant with thinking + text + tool_use + { + type: 'assistant', + sessionId, + cwd, + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'let me think' }, + { type: 'text', text: 'working on it' }, + { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/tmp/x' } } + ] + } + }, + // user line carrying a tool_result block + { + type: 'user', + sessionId, + cwd, + message: { + role: 'user', + content: [ + { tool_use_id: 'toolu_1', type: 'tool_result', content: [{ type: 'text', text: 'file contents' }] } + ] + } + }, + // assistant final text + { type: 'assistant', sessionId, cwd, message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } }, + // more sidecar + { type: 'ai-title', title: 'some title' } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +// 中文注释:写一条带逐行 `timestamp` 的最小 Claude transcript,用于断言导入后保留原始时间戳而不是被盖成 now。 +function createTimestampedTranscript( + claudeHome: string, + sessionId: string, + timestamps: { user: string; assistant: string }, + encodedCwd = '-home-user-ts', + cwd: string | null = '/home/user/ts' +): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + { type: 'user', sessionId, cwd, timestamp: timestamps.user, message: { role: 'user', content: 'hello from the past' } }, + { type: 'assistant', sessionId, cwd, timestamp: timestamps.assistant, message: { role: 'assistant', content: [{ type: 'text', text: 'replying from the past' }] } } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createSidecarOnlyTranscript(claudeHome: string, sessionId: string, encodedCwd = '-home-user-empty'): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + { type: 'last-prompt', prompt: 'ignored' }, + { type: 'mode', mode: 'default' }, + { type: 'ai-title', title: 'no real conversation' }, + { type: 'user', isMeta: true, sessionId, message: { role: 'user', content: 'ignored' } } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createRoutesApp(namespace: string, store: Store): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', namespace) + await next() + }) + app.route('/api', createClaudeDesktopRoutes({ + store, + getSyncEngine: () => null + })) + return app +} + +describe('Claude Desktop import routes', () => { + afterEach(() => { + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + }) + + it('maps user/assistant text, thinking, tool_use and tool_result, skipping sidecar lines', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-test-')) + const store = new Store(':memory:') + const sessionId = '11111111-1111-4111-8111-111111111111' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + expect(session.metadata).toMatchObject({ + flavor: 'claude', + claudeSessionId: sessionId, + path: '/home/user/project', + lifecycleState: 'imported' + }) + + const messages = store.messages.getAllMessages(session.id) + // user(string), thinking, text, tool_use, tool_result, final text => 6 + expect(messages).toHaveLength(6) + + expect(messages[0].content).toEqual({ + role: 'user', + content: { type: 'text', text: 'hello claude' }, + meta: { sentFrom: 'cli' } + }) + expect((messages[1].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'reasoning', + message: 'let me think' + }) + expect((messages[2].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'message', + message: 'working on it' + }) + expect((messages[3].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'tool-call', + name: 'Read', + callId: 'toolu_1', + input: { file_path: '/tmp/x' } + }) + expect((messages[4].content as { content: { data: { type: string; callId: string } } }).content.data).toMatchObject({ + type: 'tool-call-result', + callId: 'toolu_1' + }) + expect((messages[5].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'message', + message: 'done' + }) + expect(messages[2].content).toMatchObject({ + content: { type: AGENT_MESSAGE_PAYLOAD_TYPE } + }) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('is idempotent: re-importing the same session adds no duplicate session or messages', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-idem-test-')) + const store = new Store(':memory:') + const sessionId = '22222222-2222-4222-8222-222222222222' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const first = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(first.success).toBe(true) + + const sessionsAfterFirst = store.sessions.getSessionsByNamespace('default') + expect(sessionsAfterFirst).toHaveLength(1) + const messagesAfterFirst = store.messages.getAllMessages(sessionsAfterFirst[0].id).length + + const second = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(second.success).toBe(true) + + const sessionsAfterSecond = store.sessions.getSessionsByNamespace('default') + expect(sessionsAfterSecond).toHaveLength(1) + expect(sessionsAfterSecond[0].id).toBe(sessionsAfterFirst[0].id) + const messagesAfterSecond = store.messages.getAllMessages(sessionsAfterSecond[0].id).length + expect(messagesAfterSecond).toBe(messagesAfterFirst) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('preserves the original record timestamps as message createdAt/invokedAt and session updatedAt', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-ts-test-')) + const store = new Store(':memory:') + const sessionId = '55555555-5555-4555-8555-555555555555' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + const userTs = '2026-01-02T03:04:05.000Z' + const assistantTs = '2026-01-02T03:05:06.000Z' + const userMs = Date.parse(userTs) + const assistantMs = Date.parse(assistantTs) + + try { + createTimestampedTranscript(claudeHome, sessionId, { user: userTs, assistant: assistantTs }) + + const before = Date.now() + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + + // 中文注释:核心断言——落库时间是 transcript 原始时间戳,而不是导入瞬间的 Date.now()。 + expect(messages[0].createdAt).toBe(userMs) + expect(messages[0].invokedAt).toBe(userMs) + expect(messages[1].createdAt).toBe(assistantMs) + expect(messages[1].invokedAt).toBe(assistantMs) + expect(messages[0].createdAt).toBeLessThan(before) + + // 中文注释:会话最后活跃时间应反映最后一条消息的原始时间,而不是“今天刚活跃”。 + expect(session.updatedAt).toBe(assistantMs) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('falls back to the transcript file mtime when records carry no per-line timestamp', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-nots-test-')) + const store = new Store(':memory:') + const sessionId = '66666666-6666-4666-8666-666666666666' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + // createRichTranscript 的记录没有逐行 timestamp,应回退到文件 mtime。 + createRichTranscript(claudeHome, sessionId) + const summaries = listLocalClaudeSessions() + const summary = summaries.find((s) => s.id === sessionId) + expect(summary).toBeDefined() + const fileModifiedAt = summary!.modifiedAt + + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + for (const message of messages) { + expect(message.createdAt).toBe(fileModifiedAt) + } + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('filters out empty / sidecar-only sessions from listing', () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-empty-test-')) + const sessionId = '33333333-3333-4333-8333-333333333333' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createSidecarOnlyTranscript(claudeHome, sessionId) + const sessions = listLocalClaudeSessions() + expect(sessions).toHaveLength(0) + } finally { + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('lists real sessions and rejects non-default namespace', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-route-test-')) + const sessionId = '44444444-4444-4444-8444-444444444444' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const defaultStore = new Store(':memory:') + try { + const defaultApp = createRoutesApp('default', defaultStore) + const response = await defaultApp.request('/api/claude/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { success: boolean; sessions: { id: string }[] } + expect(body.success).toBe(true) + expect(body.sessions.map((s) => s.id)).toContain(sessionId) + } finally { + defaultStore.close() + } + + const teamStore = new Store(':memory:') + try { + const teamApp = createRoutesApp('team-a', teamStore) + const denied = await teamApp.request('/api/claude/sessions') + expect(denied.status).toBe(403) + } finally { + teamStore.close() + } + } finally { + rmSync(claudeHome, { recursive: true, force: true }) + } + }) +}) diff --git a/hub/src/web/routes/claudeDesktop.ts b/hub/src/web/routes/claudeDesktop.ts new file mode 100644 index 0000000000..c4cfbe02f6 --- /dev/null +++ b/hub/src/web/routes/claudeDesktop.ts @@ -0,0 +1,434 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { isAbsolute, join, resolve } from 'node:path' +import { homedir } from 'node:os' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import { + type ImportedMessage, + type ImportedMessageContent, + type ImporterAdapter, + type LocalSessionSummary, + type ScriptLaunchResponse, + type TranscriptImportData, + DEFAULT_SESSION_SCAN_LIMIT, + appendScriptLog, + asRecord, + asString, + buildImportedAgentMessage, + buildImportedUserMessage, + expandHomePath, + getDirectImportRouteContext, + importSelectedSessions, + parseImportedTimestamp, + parseSyncSessionRequest, + truncateText +} from './transcriptImport' + +type ClaudeStatusResponse = { + success: true + claudeProjectsAvailable: boolean +} + +type ClaudeLocalSessionsResponse = { + success: true + sessions: LocalSessionSummary[] +} + +const CLAUDE_SESSION_ID_KEY = 'claudeSessionId' +const CLAUDE_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Claude transcript import is not available outside the default namespace' + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +function getClaudeHome(): string { + // 中文注释:与 codex 的 getCodexHome 对称;优先 CLAUDE_CONFIG_DIR,否则回退 ~/.claude。 + const configured = process.env.CLAUDE_CONFIG_DIR?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : join(homedir(), '.claude') +} + +function getClaudeProjectRoots(): string[] { + return [join(getClaudeHome(), 'projects')] +} + +function decodeProjectDirName(dirName: string): string | null { + // 中文注释:Claude 把 cwd 里的路径分隔符编码成 '-'(如 /home/ubuntu → -home-ubuntu)。 + // 由于含 '-' 的真实路径无法可靠还原,cwd 仍以 transcript 行内的 cwd 字段为准,这里只作回退用途。 + if (!dirName) return null + const decoded = dirName.replace(/-/g, '/') + return decoded.startsWith('/') ? decoded : `/${decoded}` +} + +function extractClaudeBlockText(value: unknown): string { + if (typeof value === 'string') { + return value.trim() + } + if (Array.isArray(value)) { + return value + .map((item) => { + const record = asRecord(item) + if (record?.type === 'text' && typeof record.text === 'string') return record.text + return null + }) + .filter((part): part is string => Boolean(part)) + .join(' ') + .trim() + } + return '' +} + +function isMetaUserRecord(record: Record): boolean { + // 中文注释:Claude 会写入本地命令/环境提示等 isMeta 用户行,这些不是真实对话内容,跳过。 + return record.isMeta === true +} + +function getClaudeFirstUserMessage(lines: string[]): string | null { + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record || record.type !== 'user' || isMetaUserRecord(record)) continue + const message = asRecord(record.message) + const text = extractClaudeBlockText(message?.content) + if (text) { + return text + } + } + return null +} + +function readClaudeFields(lines: string[]): { sessionId: string | null; cwd: string | null; cliVersion: string | null } { + let sessionId: string | null = null + let cwd: string | null = null + let cliVersion: string | null = null + for (const line of lines) { + if (sessionId && cwd && cliVersion) break + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record) continue + if (!sessionId && typeof record.sessionId === 'string') sessionId = record.sessionId + if (!cwd && typeof record.cwd === 'string') cwd = record.cwd + if (!cliVersion && typeof record.version === 'string') cliVersion = record.version + } + return { sessionId, cwd, cliVersion } +} + +function getClaudeSessionTitle(cwd: string | null, sessionId: string, firstUserMessage: string | null): string { + if (firstUserMessage) { + return truncateText(firstUserMessage, 80) + } + if (cwd) { + const parts = cwd.split(/[\\/]+/).filter(Boolean) + if (parts.length > 0) { + return parts[parts.length - 1] + } + } + return sessionId.slice(0, 8) +} + +function parseClaudeLocalSession(filePath: string, dirName: string): LocalSessionSummary | null { + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + if (lines.length === 0) { + return null + } + + const { sessionId: inlineSessionId, cwd: inlineCwd, cliVersion } = readClaudeFields(lines) + const fileSessionId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.jsonl$/i, '') ?? null + const sessionId = fileSessionId || inlineSessionId + if (!sessionId) { + return null + } + + const cwd = inlineCwd ?? decodeProjectDirName(dirName) + const firstUserMessage = getClaudeFirstUserMessage(lines) + + let modifiedAt = Date.now() + try { + modifiedAt = statSync(filePath).mtimeMs + } catch { + // Fall back to current time if stat fails during a concurrent file change. + } + + return { + id: sessionId, + title: getClaudeSessionTitle(cwd, sessionId, firstUserMessage), + lastUserMessage: firstUserMessage ? truncateText(firstUserMessage, 140) : null, + cwd, + file: filePath, + modifiedAt, + originator: 'claude_code', + cliVersion + } +} + +function listLocalClaudeSessions(limit = DEFAULT_SESSION_SCAN_LIMIT): LocalSessionSummary[] { + const deduped = new Map() + + for (const root of getClaudeProjectRoots()) { + if (!existsSync(root)) continue + let projectDirs + try { + projectDirs = readdirSync(root, { withFileTypes: true }) + } catch { + continue + } + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue + const projectPath = join(root, projectDir.name) + let entries + try { + entries = readdirSync(projectPath, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.jsonl')) continue + const filePath = join(projectPath, entry.name) + const session = parseClaudeLocalSession(filePath, projectDir.name) + if (!session) continue + // 中文注释:仅含 sidecar、没有任何可导入对话的会话不进入列表,避免给用户展示空壳会话。 + if (!parseClaudeTranscriptImportData(session)) continue + const previous = deduped.get(session.id) + if (!previous || previous.modifiedAt < session.modifiedAt) { + deduped.set(session.id, session) + } + } + } + } + + const sorted = Array.from(deduped.values()).sort((a, b) => b.modifiedAt - a.modifiedAt) + if (sorted.length > limit) { + // 中文注释:不静默截断;超过扫描上限时记录被截断的数量,方便排查“为什么少了会话”。 + console.warn(`[claude-import] listLocalClaudeSessions truncated ${sorted.length - limit} session(s) beyond limit=${limit}`) + } + return sorted.slice(0, limit) +} + +function convertClaudeRecordToImportedMessage(record: Record): ImportedMessageContent[] { + // 中文注释:一行 Claude 记录可能含多块(text/thinking/tool_use/tool_result),故返回数组而非单条。 + const type = asString(record.type) + const message = asRecord(record.message) + if (!type || !message) { + return [] + } + + const content = message.content + const results: ImportedMessageContent[] = [] + + if (type === 'user') { + if (isMetaUserRecord(record)) { + return [] + } + if (typeof content === 'string') { + const text = content.trim() + return text ? [buildImportedUserMessage(text)] : [] + } + if (Array.isArray(content)) { + const userTextParts: string[] = [] + for (const item of content) { + const block = asRecord(item) + if (!block) continue + const blockType = asString(block.type) + if (blockType === 'text' && typeof block.text === 'string') { + const text = block.text.trim() + if (text) userTextParts.push(text) + } else if (blockType === 'tool_result') { + const callId = asString(block.tool_use_id) + if (callId) { + results.push(buildImportedAgentMessage({ + type: 'tool-call-result', + callId, + output: block.content + })) + } + } + } + if (userTextParts.length > 0) { + // 中文注释:把用户文本拼成一条 user message,置于 tool_result 之前以保留视觉顺序。 + results.unshift(buildImportedUserMessage(userTextParts.join('\n'))) + } + return results + } + return [] + } + + if (type === 'assistant') { + if (!Array.isArray(content)) { + return [] + } + for (const item of content) { + const block = asRecord(item) + if (!block) continue + const blockType = asString(block.type) + if (blockType === 'text' && typeof block.text === 'string') { + const text = block.text.trim() + if (text) { + results.push(buildImportedAgentMessage({ type: 'message', message: text })) + } + } else if (blockType === 'thinking' && typeof block.thinking === 'string') { + const thinking = block.thinking.trim() + if (thinking) { + results.push(buildImportedAgentMessage({ type: 'reasoning', message: thinking })) + } + } else if (blockType === 'tool_use') { + const name = asString(block.name) + const callId = asString(block.id) + if (name && callId) { + results.push(buildImportedAgentMessage({ + type: 'tool-call', + name, + callId, + input: block.input + })) + } + } + } + return results + } + + // 中文注释:其余 sidecar 类型(last-prompt/mode/agent-setting/permission-mode/attachment/system/ + // file-history-snapshot/ai-title/agent-name/queue-operation 等)一律安全跳过。 + return [] +} + +function parseClaudeTranscriptImportData(summary: LocalSessionSummary): TranscriptImportData | null { + let content: string + try { + content = readFileSync(summary.file, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + const messages: ImportedMessage[] = [] + + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record) continue + // 中文注释:Claude 记录在顶层 `timestamp` 带 ISO 时间串,解析出来随消息一起落库; + // 一行可拆出多块消息(text/tool_use/...),它们共用该行的时间戳。 + const createdAt = parseImportedTimestamp(record.timestamp) + for (const content of convertClaudeRecordToImportedMessage(record)) { + messages.push({ content, createdAt }) + } + } + + if (messages.length === 0) { + return null + } + + return { + ...summary, + messages + } +} + +// 中文注释:claudeAdapter 把 Claude 专属扫描/解析封装成通用 ImporterAdapter,落库/同步/去重复用 transcriptImport。 +const claudeAdapter: ImporterAdapter = { + flavor: 'claude', + sessionIdKey: CLAUDE_SESSION_ID_KEY, + listLocalSessions: (limit) => listLocalClaudeSessions(limit), + parseTranscript: (summary) => parseClaudeTranscriptImportData(summary) +} + +export async function importSelectedClaudeSessions(options: { + claudeSessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + return importSelectedSessions({ + adapter: claudeAdapter, + sessionIds: options.claudeSessionIds, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) +} + +export { + listLocalClaudeSessions, + parseClaudeTranscriptImportData, + convertClaudeRecordToImportedMessage +} + +export function createClaudeDesktopRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.use('/claude/*', async (c, next) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CLAUDE_TRANSCRIPT_IMPORT_NAMESPACE_ERROR + }, 403) + } + return next() + }) + + app.get('/claude/status', (c) => { + const available = getClaudeProjectRoots().some((root) => existsSync(root)) + return c.json({ + success: true, + claudeProjectsAvailable: available + } satisfies ClaudeStatusResponse) + }) + + app.get('/claude/sessions', (c) => { + return c.json({ + success: true, + sessions: listLocalClaudeSessions() + } satisfies ClaudeLocalSessionsResponse) + }) + + app.post('/claude/sync-session', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseSyncSessionRequest(body) + if (parsed.error) { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${parsed.error}`) + return c.json({ + success: false, + error: parsed.error, + cwd: workspace + }) + } + + // 中文注释:直接读取本地 Claude transcript 写入 Hapi store,复用与 Codex 相同的落库/同步/去重引擎。 + const result = await importSelectedClaudeSessions({ + claudeSessionIds: parsed.sessionIds, + store: options.store, + namespace: c.get('namespace'), + getSyncEngine: options.getSyncEngine + }) + return c.json(result) + }) + + return app +} diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index 9b4324ea3d..885572c0d2 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'bun:test' import { randomUUID } from 'node:crypto' -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Hono } from 'hono' @@ -46,6 +46,36 @@ function createTranscript(codexHome: string, sessionId: string, cwd = 'C:\\work\ writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') } +// 中文注释:写一条带顶层 `timestamp` 的最小 Codex rollout,用于断言导入后保留原始时间戳而不是被盖成 now。 +function createTimestampedTranscript( + codexHome: string, + sessionId: string, + timestamps: { user: string; assistant: string }, + cwd = '/home/user/ts' +): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`) + const lines = [ + { + timestamp: timestamps.user, + type: 'session_meta', + payload: { id: sessionId, cwd, originator: 'codex_cli_rs', cli_version: '0.0.0-test' } + }, + { + timestamp: timestamps.user, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello from the past' }] } + }, + { + timestamp: timestamps.assistant, + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'replying from the past' }] } + } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + function createMachine(id: string, workspaceRoots: string[], namespace = 'default'): Machine { return { id, @@ -163,6 +193,80 @@ describe('Codex Desktop import routes', () => { } }) + it('preserves the original record timestamps as message createdAt/invokedAt and session updatedAt', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-ts-test-')) + const store = new Store(':memory:') + const codexSessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.env.CODEX_HOME = codexHome + + const userTs = '2026-01-02T03:04:05.000Z' + const assistantTs = '2026-01-02T03:05:06.000Z' + const userMs = Date.parse(userTs) + const assistantMs = Date.parse(assistantTs) + + try { + createTimestampedTranscript(codexHome, codexSessionId, { user: userTs, assistant: assistantTs }) + + const before = Date.now() + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + + // 中文注释:核心断言——落库时间是 rollout 记录的原始时间戳,而不是导入瞬间的 Date.now()。 + expect(messages[0].createdAt).toBe(userMs) + expect(messages[0].invokedAt).toBe(userMs) + expect(messages[1].createdAt).toBe(assistantMs) + expect(messages[1].invokedAt).toBe(assistantMs) + expect(messages[0].createdAt).toBeLessThan(before) + + // 中文注释:会话最后活跃时间应反映最后一条消息的原始时间,而不是“今天刚活跃”。 + expect(session.updatedAt).toBe(assistantMs) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('falls back to the transcript file mtime when records carry no per-line timestamp', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-nots-test-')) + const store = new Store(':memory:') + const codexSessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + process.env.CODEX_HOME = codexHome + + try { + // createTranscript 的记录没有顶层 timestamp,应回退到文件 mtime。 + createTranscript(codexHome, codexSessionId) + const transcriptPath = join(codexHome, 'sessions', '2026', '06', '04', `rollout-${codexSessionId}.jsonl`) + const fileModifiedAt = statSync(transcriptPath).mtimeMs + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(result.success).toBe(true) + + const session = store.sessions.getSessionsByNamespace('default')[0] + const messages = store.messages.getAllMessages(session.id) + expect(messages.length).toBeGreaterThan(0) + for (const message of messages) { + expect(message.createdAt).toBe(fileModifiedAt) + } + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + it('binds imported transcripts to the unique online machine that owns the cwd', async () => { const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-')) const store = new Store(':memory:') diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index d5723f4025..d6afc5ebe0 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -1,46 +1,42 @@ -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { homedir, hostname, platform } from 'node:os' -import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { homedir } from 'node:os' import { Hono } from 'hono' -import type { Machine, SyncEngine } from '../../sync/syncEngine' -import type { Store, StoredMessage } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store } from '../../store' import type { WebAppEnv } from '../middleware/auth' +import { + type ImportedMessage, + type ImportedMessageContent, + type ImporterAdapter, + type LocalSessionSummary, + type ScriptLaunchResponse, + type TranscriptImportData, + DEFAULT_SESSION_SCAN_LIMIT, + NO_SYNC_SESSION_SELECTED_ERROR, + appendScriptLog, + asRecord, + asString, + buildImportedAgentMessage, + buildImportedUserMessage, + getDirectImportRouteContext, + importSelectedSessions, + listDuplicateSessionGroups, + mergeDuplicateSessionGroups, + parseImportedTimestamp, + parseSyncSessionRequest, + truncateText +} from './transcriptImport' type ScriptLogKind = 'sync' | 'restart' -const DIRECT_IMPORT_COMMAND = 'direct-import' const RESTART_SCRIPT_ENV_NAME = 'HAPI_CODEX_RESTART_SCRIPT' const RESTART_SCRIPT_DEFAULT_FILE = 'Restart-CodexDesktop.ps1' const RESTART_SCRIPT_ARGS = ['-Apply'] const RESTART_SCRIPT_MESSAGE = 'Codex Desktop restart script started' -type ScriptLaunchResponse = { - success: true - message: string - pid: number - command: string - script?: string - cwd: string - output?: string - codexDesktopRunning?: boolean - codexClientAvailable?: boolean - syncedCount?: number - sessionIds?: string[] -} | { - success: false - error: string - script?: string - cwd: string - output?: string - codexDesktopRunning?: boolean - codexClientAvailable?: boolean - syncedCount?: number - sessionIds?: string[] -} - type CodexDesktopStatus = { running: boolean clientAvailable: boolean @@ -52,68 +48,14 @@ type CodexDesktopStatusResponse = { codexClientAvailable: boolean } -type CodexLocalSessionSummary = { - id: string - title: string - lastUserMessage?: string | null - cwd?: string | null - file: string - modifiedAt: number - originator?: string | null - cliVersion?: string | null -} - type CodexLocalSessionsResponse = { success: true - sessions: CodexLocalSessionSummary[] -} - -type CodexImportedMessageContent = { - role: 'user' - content: { - type: 'text' - text: string - } - meta: { - sentFrom: 'cli' - } -} | { - role: 'agent' - content: { - type: typeof AGENT_MESSAGE_PAYLOAD_TYPE - data: unknown - } - meta: { - sentFrom: 'cli' - } -} - -type CodexTranscriptImportData = CodexLocalSessionSummary & { - messages: CodexImportedMessageContent[] -} - -type ImportCandidate = { - sessionId: string - active: boolean - updatedAt: number - metadata: Record | null -} - -type ImportTargetSelection = { - sessionId: string | null - comparablePrefixCount: number -} - -type SyncSessionRequestParseResult = { - sessionIds: string[] - error?: string + sessions: LocalSessionSummary[] } type CodexDuplicateSessionGroup = { codexSessionId: string hapiSessionIds: string[] - canonicalSessionId?: string - removedSessionIds?: string[] } type CodexDuplicateSessionsResponse = { @@ -126,24 +68,18 @@ type CodexDuplicateSessionsResponse = { type CodexMergeDuplicateSessionsResponse = { success: true - merged: CodexDuplicateSessionGroup[] + merged: { codexSessionId: string; hapiSessionIds: string[]; canonicalSessionId?: string; removedSessionIds?: string[] }[] mergedCount: number } | { success: false error: string } -type DuplicateSessionGroupCandidate = { - codexSessionId: string - sessions: ImportCandidate[] -} - +const CODEX_SESSION_ID_KEY = 'codexSessionId' const CODEX_DESKTOP_NOT_FOUND_ERROR = '尝试重启codex客户端失败,未安装/找不到codex客户端' const SCRIPT_TIMEOUT_ERROR = '执行超时' -const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的 Codex 会话' const CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Codex transcript import is not available outside the default namespace' const DEFAULT_SCRIPT_TIMEOUT_MS = 60_000 -const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 500 function resolveLocalPath(pathValue: string): string { return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) @@ -187,11 +123,6 @@ function getWorkspace(scriptPath: string): string { return configured ? resolveLocalPath(configured) : dirname(scriptPath) } -function getDirectImportWorkspace(): string { - const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() - return configured ? resolveLocalPath(configured) : process.cwd() -} - function expandHomePath(pathValue: string): string { return pathValue.replace(/^~(?=$|[\\/])/, homedir()) } @@ -228,16 +159,6 @@ function collectJsonlFiles(root: string, files: string[]): void { } } -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null -} - -function asString(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null -} - function extractCodexText(value: unknown): string { if (typeof value === 'string') { return value.trim() @@ -268,10 +189,6 @@ function extractCodexText(value: unknown): string { return '' } -function truncateText(value: string, maxLength: number): string { - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value -} - function shouldIgnoreSyntheticUserMessage(text: string): boolean { const normalized = text.trim() return normalized.startsWith('# AGENTS.md instructions') @@ -410,7 +327,7 @@ function isSubagentSource(value: unknown): boolean { return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false } -function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null { +function parseCodexLocalSession(filePath: string): LocalSessionSummary | null { let content: string try { content = readFileSync(filePath, 'utf-8') @@ -493,13 +410,13 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu } } -function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): CodexLocalSessionSummary[] { +function listLocalCodexSessions(limit = DEFAULT_SESSION_SCAN_LIMIT): LocalSessionSummary[] { const files: string[] = [] for (const root of getCodexSessionRoots()) { collectJsonlFiles(root, files) } - const deduped = new Map() + const deduped = new Map() for (const filePath of files) { const session = parseCodexLocalSession(filePath) if (!session) continue @@ -514,33 +431,7 @@ function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Codex .slice(0, limit) } -function buildImportedUserMessage(text: string): CodexImportedMessageContent { - return { - role: 'user', - content: { - type: 'text', - text - }, - meta: { - sentFrom: 'cli' - } - } -} - -function buildImportedAgentMessage(data: unknown): CodexImportedMessageContent { - return { - role: 'agent', - content: { - type: AGENT_MESSAGE_PAYLOAD_TYPE, - data - }, - meta: { - sentFrom: 'cli' - } - } -} - -function convertCodexRecordToImportedMessage(record: Record): CodexImportedMessageContent | null { +function convertCodexRecordToImportedMessage(record: Record): ImportedMessageContent | null { const type = asString(record.type) const payload = asRecord(record.payload) if (!type || !payload) { @@ -639,7 +530,7 @@ function convertCodexRecordToImportedMessage(record: Record): C return null } -function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null { +function parseCodexTranscriptImportData(summary: LocalSessionSummary): TranscriptImportData | null { let content: string try { content = readFileSync(summary.file, 'utf-8') @@ -648,7 +539,7 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } const lines = content.split(/\r?\n/).filter(Boolean) - const messages: CodexImportedMessageContent[] = [] + const messages: ImportedMessage[] = [] for (const line of lines) { let parsed: unknown @@ -660,9 +551,11 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code const record = asRecord(parsed) if (!record) continue - const message = convertCodexRecordToImportedMessage(record) - if (message) { - messages.push(message) + const content = convertCodexRecordToImportedMessage(record) + if (content) { + // 中文注释:Codex rollout 记录在顶层 `timestamp` 带 ISO 时间串(payload 内还有一份,取顶层即可), + // 随消息一起落库以保留原始时间线。 + messages.push({ content, createdAt: parseImportedTimestamp(record.timestamp) }) } } @@ -672,431 +565,27 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } } -function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string { - let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/') - if (normalized.length > 1) { - normalized = normalized.replace(/\/+$/, '') - } - return options?.caseInsensitive ? normalized.toLowerCase() : normalized +// 中文注释:codexAdapter 把 Codex 专属的扫描/解析封装成通用 ImporterAdapter,落库/同步/去重交给 transcriptImport 共享引擎。 +const codexAdapter: ImporterAdapter = { + flavor: 'codex', + sessionIdKey: CODEX_SESSION_ID_KEY, + listLocalSessions: (limit) => listLocalCodexSessions(limit), + parseTranscript: (summary) => parseCodexTranscriptImportData(summary) } -function shouldCompareCaseInsensitive(...pathValues: string[]): boolean { - return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\')) -} - -function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean { - if (!pathValue.trim() || !rootValue.trim()) { - return false - } - - const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue) - const path = normalizeComparablePath(pathValue, { caseInsensitive }) - const root = normalizeComparablePath(rootValue, { caseInsensitive }) - if (!path || !root) { - return false - } - if (path === root) { - return true - } - if (root === '/') { - return path.startsWith('/') - } - return path.startsWith(`${root}/`) -} - -function machineOwnsCodexCwd(machine: Machine, cwd: string): boolean { - const workspaceRoots = machine.metadata?.workspaceRoots ?? [] - return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot)) -} - -function resolveImportMachineId( - cwd: string | null | undefined, - namespace: string, - engine: SyncEngine | null -): string | undefined { - if (!cwd || !engine) { - return undefined - } - - const matches = engine.getOnlineMachinesByNamespace(namespace) - .filter((machine) => machineOwnsCodexCwd(machine, cwd)) - const machineIds = Array.from(new Set(matches.map((machine) => machine.id))) - return machineIds.length === 1 ? machineIds[0] : undefined -} - -function buildImportedSessionMetadata( - data: CodexTranscriptImportData, - existingMetadata?: Record | null, - resolvedMachineId?: string -): Record { - const now = Date.now() - const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) - const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) - const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() - const summaryText = data.lastUserMessage ?? data.title - const machineId = typeof existingMetadata?.machineId === 'string' - ? existingMetadata.machineId - : resolvedMachineId - - return { - ...(existingMetadata ?? {}), - path, - host, - os: osValue, - name: data.title, - summary: summaryText - ? { - text: summaryText, - updatedAt: now - } - : existingMetadata?.summary, - flavor: 'codex', - codexSessionId: data.id, - ...(machineId ? { machineId } : {}), - lifecycleState: typeof existingMetadata?.lifecycleState === 'string' - ? existingMetadata.lifecycleState - : 'imported', - lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' - ? existingMetadata.lifecycleStateSince - : now - } -} - -function stableSerialize(value: unknown): string { - if (value === null || value === undefined) { - return String(value) - } - if (typeof value === 'string') { - return JSON.stringify(value) - } - if (typeof value === 'number' || typeof value === 'boolean') { - return JSON.stringify(value) - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableSerialize(item)).join(',')}]` - } - if (typeof value === 'object') { - const record = value as Record - const keys = Object.keys(record).sort() - return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` - } - return JSON.stringify(value) -} - -function normalizeComparableAgentData(value: unknown): unknown { - const record = asRecord(value) - if (!record) { - return value - } - - const normalized = { ...record } - if ('id' in normalized) { - delete normalized.id - } - return normalized -} - -function normalizeComparableContent(content: unknown): string | null { - const record = asRecord(content) - if (!record) { - return null - } - - if (record.role === 'user') { - const body = asRecord(record.content) - if (body?.type !== 'text' || typeof body.text !== 'string') { - return null - } - return stableSerialize({ - role: 'user', - text: body.text - }) - } - - if (record.role === 'agent') { - const body = asRecord(record.content) - if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { - return null - } - return stableSerialize({ - role: 'agent', - data: normalizeComparableAgentData(body.data) - }) - } - - return null -} - -function getComparableStoredMessageKey(message: StoredMessage): string { - // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 - return normalizeComparableContent(message.content) ?? stableSerialize(message.content) -} - -function collectImportCandidates( - store: Store, - namespace: string, - getSyncEngine?: () => SyncEngine | null -): ImportCandidate[] { - const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] - if (engineSessions.length > 0) { - return engineSessions.map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) - } - - return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) -} - -function selectImportTargetSession( - store: Store, - candidates: ImportCandidate[], - codexSessionId: string, - importedComparableMessages: string[] -): ImportTargetSelection { - const relatedCandidates = candidates - .filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId) - .sort((a, b) => b.updatedAt - a.updatedAt) - - if (relatedCandidates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - - let bestSessionId: string | null = null - let bestPrefixCount = -1 - - for (const candidate of relatedCandidates) { - const comparableMessages = store.messages.getAllMessages(candidate.sessionId) - .map((message) => normalizeComparableContent(message.content)) - .filter((value): value is string => value !== null) - - if (comparableMessages.length > importedComparableMessages.length) { - continue - } - - let prefixMatches = true - for (let index = 0; index < comparableMessages.length; index += 1) { - if (comparableMessages[index] !== importedComparableMessages[index]) { - prefixMatches = false - break - } - } - - if (!prefixMatches) { - continue - } - - if (comparableMessages.length > bestPrefixCount) { - bestPrefixCount = comparableMessages.length - bestSessionId = candidate.sessionId - } - } - - return { - sessionId: bestSessionId, - comparablePrefixCount: Math.max(0, bestPrefixCount) - } -} - -function listDuplicateCodexSessionGroups( - store: Store, - namespace: string, - codexSessionIds: string[], - getSyncEngine?: () => SyncEngine | null -): DuplicateSessionGroupCandidate[] { - const requestedSessionIds = new Set(codexSessionIds) - if (requestedSessionIds.size === 0) { - return [] - } - - const groups = new Map() - for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { - const codexSessionId = typeof candidate.metadata?.codexSessionId === 'string' - ? candidate.metadata.codexSessionId - : null - if (!codexSessionId || !requestedSessionIds.has(codexSessionId)) { - continue - } - - const existing = groups.get(codexSessionId) - if (existing) { - existing.push(candidate) - } else { - groups.set(codexSessionId, [candidate]) - } - } - - return Array.from(groups.entries()) - .map(([codexSessionId, sessions]) => ({ - codexSessionId, - sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) - })) - .filter((group) => group.sessions.length > 1) -} - -async function mergeDuplicateCodexSessionGroups(options: { - store: Store - namespace: string +export async function importSelectedCodexSessions(options: { codexSessionIds: string[] - getSyncEngine?: () => SyncEngine | null -}): Promise { - const groups = listDuplicateCodexSessionGroups( - options.store, - options.namespace, - options.codexSessionIds, - options.getSyncEngine - ) - if (groups.length === 0) { - return { - success: true, - merged: [], - mergedCount: 0 - } - } - - const merged: CodexDuplicateSessionGroup[] = [] - for (const group of groups) { - const result = await mergeSingleDuplicateCodexSessionGroup({ - group, - store: options.store, - namespace: options.namespace, - getSyncEngine: options.getSyncEngine - }) - merged.push(result) - } - - return { - success: true, - merged, - mergedCount: merged.length - } -} - -async function mergeSingleDuplicateCodexSessionGroup(options: { - group: DuplicateSessionGroupCandidate store: Store namespace: string getSyncEngine?: () => SyncEngine | null -}): Promise { - const engine = options.getSyncEngine?.() ?? null - const sessionStates = options.group.sessions - .map((candidate) => ({ - ...candidate, - storedMessages: options.store.messages.getAllMessages(candidate.sessionId), - })) - .map((candidate) => ({ - ...candidate, - comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) - })) - .sort((a, b) => { - if (b.comparableKeys.length !== a.comparableKeys.length) { - return b.comparableKeys.length - a.comparableKeys.length - } - if (b.updatedAt !== a.updatedAt) { - return b.updatedAt - a.updatedAt - } - return a.sessionId.localeCompare(b.sessionId) - }) - - if (sessionStates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - - const canonical = sessionStates[0] - if (!canonical) { - throw new Error(`No duplicate Hapi session found for Codex thread: ${options.group.codexSessionId}`) - } - - const knownKeys = new Set(canonical.comparableKeys) - const removedSessionIds: string[] = [] - const appendedMessages: StoredMessage[] = [] - let latestActivity = canonical.updatedAt - - for (const source of sessionStates.slice(1)) { - latestActivity = Math.max(latestActivity, source.updatedAt) - for (const message of source.storedMessages) { - const comparableKey = getComparableStoredMessageKey(message) - if (knownKeys.has(comparableKey)) { - continue - } - - const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { - content: message.content, - createdAt: message.createdAt, - localId: message.localId, - invokedAt: message.invokedAt, - scheduledAt: message.scheduledAt - }) - knownKeys.add(comparableKey) - appendedMessages.push(copied) - latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) - } - - if (engine) { - await engine.deleteSession(source.sessionId) - } else { - const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) - if (!deleted) { - throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) - } - } - removedSessionIds.push(source.sessionId) - } - - if (appendedMessages.length > 0) { - emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) - } - - if (engine) { - engine.recordSessionActivity(canonical.sessionId, latestActivity) - // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 - engine.handleRealtimeEvent({ - type: 'session-updated', - sessionId: canonical.sessionId - }) - } else { - options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) - } - - return { - codexSessionId: options.group.codexSessionId, - hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), - canonicalSessionId: canonical.sessionId, - removedSessionIds - } -} - -function emitImportedMessageEvents( - engine: SyncEngine | null, - sessionId: string, - appendedMessages: StoredMessage[] -): void { - if (!engine) { - return - } - - // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 - for (const message of appendedMessages) { - engine.handleRealtimeEvent({ - type: 'message-received', - sessionId, - message: { - id: message.id, - seq: message.seq, - localId: message.localId ?? null, - content: message.content, - createdAt: message.createdAt, - invokedAt: message.invokedAt - } - }) - } +}): Promise { + return importSelectedSessions({ + adapter: codexAdapter, + sessionIds: options.codexSessionIds, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) } function getPathExts(): string[] { @@ -1262,15 +751,8 @@ function createLaunchArgs(scriptPath: string, workspace: string, scriptArgs: str ] } -function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { - try { - const logDir = join(workspace, 'logs') - mkdirSync(logDir, { recursive: true }) - const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` - appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') - } catch { - // Best-effort logging only; API response still carries the error. - } +function appendRestartScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + appendScriptLog(workspace, kind, message) } async function runPowerShellScript(scriptPath: string, workspace: string, scriptArgs: string[]): Promise<{ pid: number; command: string; output: string }> { @@ -1366,7 +848,7 @@ async function launchRestartScript(): Promise { const workspace = getWorkspace(scriptPath) if (!existsSync(scriptPath)) { - appendScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) return { success: false, error: `Script not found: ${scriptPath}`, @@ -1376,7 +858,7 @@ async function launchRestartScript(): Promise { } if (!existsSync(workspace)) { - appendScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) return { success: false, error: `Workspace not found: ${workspace}`, @@ -1388,7 +870,7 @@ async function launchRestartScript(): Promise { try { const launched = await runPowerShellScript(scriptPath, workspace, RESTART_SCRIPT_ARGS) const output = launched.output - appendScriptLog( + appendRestartScriptLog( workspace, 'restart', `SUCCESS: ${RESTART_SCRIPT_MESSAGE}; pid=${launched.pid}; command=${launched.command}; script=${scriptPath}${output ? `; output=${output}` : ''}` @@ -1404,7 +886,7 @@ async function launchRestartScript(): Promise { } } catch (error) { const message = error instanceof Error ? error.message : String(error) - appendScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) return { success: false, error: message, @@ -1414,251 +896,6 @@ async function launchRestartScript(): Promise { } } -function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { - // 中文注释:导入弹窗现在直接提交 Codex thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 - if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { - return { sessionIds: [] } - } - - const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds - if (!Array.isArray(rawSessionIds)) { - return { sessionIds: [], error: 'Invalid sessionIds' } - } - - const sessionIds: string[] = [] - for (const value of rawSessionIds) { - if (typeof value !== 'string') { - return { sessionIds: [], error: 'Invalid sessionIds' } - } - const trimmed = value.trim() - if (trimmed) { - sessionIds.push(trimmed) - } - } - - // 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。 - return { sessionIds: Array.from(new Set(sessionIds)) } -} - -function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { - const output = results - .map((result, index) => { - // 中文注释:direct import 不再依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 - const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) - return detail ? `[${index + 1}] ${detail}` : '' - }) - .filter(Boolean) - .join('\n\n') - .trim() - return output || undefined -} - -function getDirectImportRouteContext(): { workspace: string } { - return { - workspace: getDirectImportWorkspace() - } -} - -function createImportErrorResponse( - codexSessionIds: string[], - error: string, - syncedCount = 0 -): ScriptLaunchResponse { - const { workspace } = getDirectImportRouteContext() - appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${codexSessionIds.join(',') || '(none)'}`) - return { - success: false, - error, - cwd: workspace, - sessionIds: codexSessionIds, - syncedCount - } -} - -function createImportSuccessResponse( - codexSessionIds: string[], - results: ScriptLaunchResponse[] -): ScriptLaunchResponse { - const { workspace } = getDirectImportRouteContext() - appendScriptLog( - workspace, - 'sync', - `SUCCESS: imported ${results.length} Codex session(s); sessionIds=${codexSessionIds.join(',')}` - ) - return { - success: true, - message: `Imported ${results.length} Codex session(s) into Hapi`, - pid: 0, - command: DIRECT_IMPORT_COMMAND, - cwd: workspace, - output: combineSyncOutputs(results), - sessionIds: codexSessionIds, - syncedCount: results.length - } -} - -function importSingleCodexSession(options: { - codexSessionId: string - localSessionsById: Map - store: Store - namespace: string - getSyncEngine?: () => SyncEngine | null -}): ScriptLaunchResponse { - const summary = options.localSessionsById.get(options.codexSessionId) - if (!summary) { - return { - ...createImportErrorResponse([options.codexSessionId], `Transcript not found for Codex session: ${options.codexSessionId}`), - output: `未找到对应的本地 transcript:${options.codexSessionId}` - } - } - - const transcript = parseCodexTranscriptImportData(summary) - if (!transcript) { - return { - ...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`), - output: `解析 transcript 失败:${summary.file}` - } - } - - if (transcript.messages.length === 0) { - return { - ...createImportErrorResponse([options.codexSessionId], `No importable conversation content found in transcript: ${summary.file}`), - output: `transcript 中没有可导入的会话内容:${summary.file}` - } - } - - const importedComparableMessages = transcript.messages - .map((message) => normalizeComparableContent(message)) - .filter((value): value is string => value !== null) - - try { - const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) - const target = selectImportTargetSession( - options.store, - candidates, - options.codexSessionId, - importedComparableMessages - ) - const engine = options.getSyncEngine?.() ?? null - const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null - const metadata = buildImportedSessionMetadata( - transcript, - asRecord(existingStored?.metadata), - resolveImportMachineId(transcript.cwd, options.namespace, engine) - ) - - let sessionId = existingStored?.id ?? null - let created = false - if (!sessionId) { - // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 - const createdSession = engine?.getOrCreateSession( - randomUUID(), - metadata, - {}, - options.namespace - ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) - sessionId = createdSession.id - created = true - } else if (existingStored) { - const updatedMetadata = options.store.sessions.updateSessionMetadata( - existingStored.id, - metadata, - existingStored.metadataVersion, - options.namespace - ) - if (updatedMetadata.result !== 'success') { - throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) - } - engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) - } - - if (!sessionId) { - throw new Error(`Failed to determine target Hapi session for Codex thread: ${options.codexSessionId}`) - } - - const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 - const messagesToAppend = transcript.messages.slice(comparablePrefixCount) - const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) - - // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 - const latestMessageCreatedAt = appendedMessages[appendedMessages.length - 1]?.createdAt ?? Date.now() - if (engine) { - engine.recordSessionActivity(sessionId, latestMessageCreatedAt) - } else { - options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) - } - if (!created) { - emitImportedMessageEvents(engine, sessionId, appendedMessages) - } - - const output = [ - `Codex thread: ${options.codexSessionId}`, - `Hapi session: ${sessionId}`, - `Action: ${created ? 'created' : 'updated'}`, - `Appended messages: ${appendedMessages.length}` - ].join('\n') - - appendScriptLog( - getDirectImportRouteContext().workspace, - 'sync', - `SUCCESS: codexSessionId=${options.codexSessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` - ) - - return { - success: true, - message: created ? 'Codex session imported into a new Hapi session' : 'Codex session appended to existing Hapi session', - pid: 0, - command: DIRECT_IMPORT_COMMAND, - cwd: getDirectImportRouteContext().workspace, - output, - sessionIds: [options.codexSessionId], - syncedCount: 1 - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - ...createImportErrorResponse([options.codexSessionId], message), - output: `Codex thread: ${options.codexSessionId}\n${message}` - } - } -} - -export async function importSelectedCodexSessions(options: { - codexSessionIds: string[] - store: Store - namespace: string - getSyncEngine?: () => SyncEngine | null -}): Promise { - const codexSessionIds = options.codexSessionIds - if (codexSessionIds.length === 0) { - return createImportErrorResponse(codexSessionIds, NO_SYNC_SESSION_SELECTED_ERROR) - } - - const localSessionsById = new Map(listLocalCodexSessions().map((session) => [session.id, session])) - const results: ScriptLaunchResponse[] = [] - for (const codexSessionId of codexSessionIds) { - const result = importSingleCodexSession({ - codexSessionId, - localSessionsById, - store: options.store, - namespace: options.namespace, - getSyncEngine: options.getSyncEngine - }) - results.push(result) - - if (!result.success) { - return { - ...result, - sessionIds: codexSessionIds, - syncedCount: Math.max(0, results.length - 1), - output: combineSyncOutputs(results) ?? result.output - } - } - } - - return createImportSuccessResponse(codexSessionIds, results) -} - export function createCodexDesktopRoutes(options: { store: Store getSyncEngine: () => SyncEngine | null @@ -1739,13 +976,14 @@ export function createCodexDesktopRoutes(options: { } // 中文注释:这里只检查本次导入弹窗里勾选过的 codexSessionId;未选中的会话即使也有重复,也不参与本轮提示。 - const duplicates = listDuplicateCodexSessionGroups( + const duplicates = listDuplicateSessionGroups( options.store, c.get('namespace'), + CODEX_SESSION_ID_KEY, parsed.sessionIds, options.getSyncEngine ).map((group) => ({ - codexSessionId: group.codexSessionId, + codexSessionId: group.flavorSessionId, hapiSessionIds: group.sessions.map((session) => session.sessionId) })) @@ -1775,10 +1013,11 @@ export function createCodexDesktopRoutes(options: { const { workspace } = getDirectImportRouteContext() try { // 中文注释:真正执行合并时仍然只按这次选中的 codexSessionId 收口,防止顺手把别的会话历史也改掉。 - const result = await mergeDuplicateCodexSessionGroups({ + const result = await mergeDuplicateSessionGroups({ store: options.store, namespace: c.get('namespace'), - codexSessionIds: parsed.sessionIds, + sessionIdKey: CODEX_SESSION_ID_KEY, + flavorSessionIds: parsed.sessionIds, getSyncEngine: options.getSyncEngine }) appendScriptLog( @@ -1786,7 +1025,20 @@ export function createCodexDesktopRoutes(options: { 'sync', `SUCCESS: merged duplicate Hapi sessions for selected codexSessionIds=${parsed.sessionIds.join(',')}` ) - return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + if (!result.success) { + return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + } + // 中文注释:对外字段保持 codexSessionId 命名不变,避免破坏既有前端契约。 + return c.json({ + success: true, + merged: result.merged.map((group) => ({ + codexSessionId: group.sessionId, + hapiSessionIds: group.hapiSessionIds, + canonicalSessionId: group.canonicalSessionId, + removedSessionIds: group.removedSessionIds + })), + mergedCount: result.mergedCount + } satisfies CodexMergeDuplicateSessionsResponse) } catch (error) { const message = error instanceof Error ? error.message : String(error) appendScriptLog( @@ -1807,7 +1059,7 @@ export function createCodexDesktopRoutes(options: { const scriptPath = getRestartScriptPath() const workspace = getWorkspace(scriptPath) const error = CODEX_DESKTOP_NOT_FOUND_ERROR - appendScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) return c.json({ success: false, error, diff --git a/hub/src/web/routes/cursorImport.test.ts b/hub/src/web/routes/cursorImport.test.ts new file mode 100644 index 0000000000..49259d5ccf --- /dev/null +++ b/hub/src/web/routes/cursorImport.test.ts @@ -0,0 +1,628 @@ +/** + * Unit tests for the cursor flavor of the multi-agent import surface. + * + * Mirrors the codex import route test shape + * (`hub/src/web/routes/codexDesktop.test.ts`) so reviewers can read the + * two test files side-by-side. + * + * Strategy: + * - Real filesystem in a per-test tmpdir (mirrors the migrator unit tests). + * - Real bun:sqlite synthetic legacy store fixture + * (`hub/src/cursor/fixtures/buildSyntheticLegacyStore.ts`). + * - MOCK `agent acp` via the `createProbe` dependency injection point + * on `CursorImporterDeps`. The verify-probe is not spawned; tests + * script the initialize / loadSession responses to exercise every + * refusal branch without depending on a real `cursor-agent` install. + * - MOCK `findAgentBinary` by placing a stub `agent` shim under + * `/.local/bin/agent` so the pre-flight binary check passes + * even in CI where cursor-agent is absent. + * + * Covers (one row per refusal reason + the happy path): + * - listImportableCursorSessions: legacy + acp discovery + dedup + + * alreadyImported flagging + * - importCursorSession happy path: legacy → transplant + Hapi row created + * - refusal: missing_on_disk_store + * - refusal: already_imported + * - refusal: ambiguous_legacy_store (multi-drawer) + * - refusal: corrupted_store + * - refusal: verify_load_failed (initialize) + * - refusal: verify_load_failed (session/load) + * - refusal: verify_timeout + * - refusal: target_already_exists + * - route shape: GET /api/cursor/importable-sessions + * - route shape: POST /api/cursor/import (multi-row batch, mixed outcomes) + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import type { AcpRpcResponse } from '../../cursor/acpVerifyProbe' +import { buildSyntheticLegacyStore } from '../../cursor/fixtures/buildSyntheticLegacyStore' +import { + importCursorSession, + importSelectedCursorSessions, + listImportableCursorSessions +} from '../../cursor/cursorImporter' +import { createCursorImportRoutes } from './cursorImport' + +/* ---------- mock probe ---------- */ + +interface ScriptedProbe { + initializeResponse: AcpRpcResponse + loadResponse: AcpRpcResponse + started: boolean + stopped: boolean +} + +function ok(result: Record = {}): AcpRpcResponse { + return { ok: true, result } +} + +function err(message: string, code: number = -32602): AcpRpcResponse { + return { ok: false, error: { code, message } } +} + +interface MockProbeHandle { + state: ScriptedProbe + start: () => void + stop: () => Promise + initialize: () => Promise + loadSession: () => Promise<{ response: AcpRpcResponse; notificationCount: number; notificationKinds: Record; durationMs: number }> +} + +function makeMockProbe(overrides: Partial = {}): MockProbeHandle { + const state: ScriptedProbe = { + initializeResponse: ok({ protocolVersion: 1 }), + loadResponse: ok({ models: { availableModels: [], currentModelId: 'default[]' }, modes: { availableModes: [], currentModeId: 'agent' } }), + started: false, + stopped: false, + ...overrides + } + return { + state, + start() { state.started = true }, + async stop() { state.stopped = true }, + async initialize() { + return state.initializeResponse + }, + async loadSession() { + return { + response: state.loadResponse, + notificationCount: state.loadResponse.ok ? 5 : 0, + notificationKinds: {}, + durationMs: 25 + } + } + } +} + +/* ---------- test harness ---------- */ + +interface Harness { + home: string + chatsDir: string + acpSessionsDir: string + store: Store + placeLegacyStore: (uuid: string, opts?: { workspaceHash?: string; lastUsedModel?: string; name?: string }) => string + placeAcpStore: (uuid: string, opts?: { name?: string; cwd?: string; title?: string }) => string + /** Plant a fake `agent` binary so findAgentBinary succeeds. */ + placeFakeAgentBinary: () => void +} + +function makeHarness(): Harness { + const home = mkdtempSync(join(tmpdir(), 'hapi-cursor-import-test-home-')) + const chatsDir = join(home, '.cursor', 'chats') + const acpSessionsDir = join(home, '.cursor', 'acp-sessions') + mkdirSync(chatsDir, { recursive: true }) + mkdirSync(acpSessionsDir, { recursive: true }) + const store = new Store(':memory:') + + return { + home, + chatsDir, + acpSessionsDir, + store, + placeLegacyStore(uuid, opts = {}) { + const wsh = opts.workspaceHash ?? `wsh-${Math.random().toString(36).slice(2, 10)}` + const dir = join(chatsDir, wsh, uuid) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'store.db') + buildSyntheticLegacyStore({ + path, + name: opts.name, + lastUsedModel: opts.lastUsedModel + }) + return path + }, + placeAcpStore(uuid, opts = {}) { + const dir = join(acpSessionsDir, uuid) + mkdirSync(dir, { recursive: true }) + const storePath = join(dir, 'store.db') + buildSyntheticLegacyStore({ + path: storePath, + name: opts.name + }) + writeFileSync( + join(dir, 'meta.json'), + JSON.stringify({ + schemaVersion: 1, + cwd: opts.cwd ?? '/workspace/example', + title: opts.title ?? opts.name ?? 'acp chat' + }) + ) + return storePath + }, + placeFakeAgentBinary() { + const binDir = join(home, '.local', 'bin') + mkdirSync(binDir, { recursive: true }) + const path = join(binDir, 'agent') + writeFileSync(path, '#!/bin/sh\necho "fake-agent"\n', { mode: 0o755 }) + try { chmodSync(path, 0o755) } catch {} + } + } +} + +function cleanupHarness(h: Harness): void { + try { h.store.close() } catch {} + try { rmSync(h.home, { recursive: true, force: true }) } catch {} +} + +function makeDeps(h: Harness, probe?: MockProbeHandle) { + const handle = probe ?? makeMockProbe() + return { + homeDir: () => h.home, + hostName: () => 'test-host', + tmpDir: () => h.home, + now: () => Date.now(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createProbe: (() => handle) as any, + verifyTimeoutMs: 5_000, + logger: { debug() {}, info() {}, warn() {}, error() {} } + } +} + +/* ---------- listImportableCursorSessions ---------- */ + +describe('listImportableCursorSessions', () => { + let h: Harness + beforeEach(() => { h = makeHarness() }) + afterEach(() => cleanupHarness(h)) + + it('returns an empty list when no chats exist on disk', () => { + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + expect(out).toEqual([]) + }) + + it('discovers legacy + acp stores and dedups by uuid (acp wins)', () => { + // Legacy-only chat. + h.placeLegacyStore('11111111-1111-1111-1111-111111111111', { name: 'legacy chat' }) + // ACP-only chat. + h.placeAcpStore('22222222-2222-2222-2222-222222222222', { name: 'acp chat', title: 'acp display title' }) + // Same uuid in both — acp wins. + h.placeLegacyStore('33333333-3333-3333-3333-333333333333', { name: 'legacy version' }) + h.placeAcpStore('33333333-3333-3333-3333-333333333333', { name: 'acp version', title: 'acp version' }) + + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + expect(out).toHaveLength(3) + const byId = new Map(out.map((r) => [r.id, r])) + expect(byId.get('11111111-1111-1111-1111-111111111111')?.sourceFormat).toBe('legacy') + expect(byId.get('22222222-2222-2222-2222-222222222222')?.sourceFormat).toBe('acp') + expect(byId.get('33333333-3333-3333-3333-333333333333')?.sourceFormat).toBe('acp') + // Title fallthrough from legacy meta record. + expect(byId.get('11111111-1111-1111-1111-111111111111')?.title).toBe('legacy chat') + // ACP meta.json title preferred. + expect(byId.get('22222222-2222-2222-2222-222222222222')?.title).toBe('acp display title') + }) + + it('flags already-imported uuids with the existing Hapi session id', () => { + h.placeLegacyStore('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', { name: 'already imported' }) + // Plant a HAPI session that references this cursor uuid. + const created = h.store.sessions.getOrCreateSession('hapi-existing-tag', { + path: '/workspace/x', + host: 'test-host', + flavor: 'cursor', + cursorSessionId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + } as Record, {}, 'default') + expect(created.id).toBeTruthy() + + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + const row = out.find((r) => r.id === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + expect(row).toBeDefined() + expect(row?.alreadyImportedHapiSessionId).toBe(created.id) + }) + + it('ignores non-uuid-ish basenames (path-traversal guard)', () => { + // Manually create a directory with a bogus name; should not appear. + const evil = join(h.acpSessionsDir, '..') + // Don't actually create traversal entries — just verify the regex + // rejection by planting an entry named '.evil/' which has '/' (not + // legal on disk but readdirSync would expose '.evil'). + const allowed = '11111111-1111-1111-1111-111111111111' + h.placeAcpStore(allowed, { name: 'allowed' }) + // Plant a non-matching entry. + const bogusDir = join(h.acpSessionsDir, 'bogus name with spaces') + mkdirSync(bogusDir, { recursive: true }) + writeFileSync(join(bogusDir, 'store.db'), '') + + const out = listImportableCursorSessions({ store: h.store, namespace: 'default', home: h.home }) + const ids = out.map((r) => r.id) + expect(ids).toContain(allowed) + expect(ids).not.toContain('bogus name with spaces') + // Defensive evil-path check: never returns '..'. + expect(ids).not.toContain('..') + // unused var lint pacifier + expect(evil.length).toBeGreaterThan(0) + }) +}) + +/* ---------- importCursorSession ---------- */ + +describe('importCursorSession refusals', () => { + let h: Harness + beforeEach(() => { + h = makeHarness() + h.placeFakeAgentBinary() + }) + afterEach(() => cleanupHarness(h)) + + it('refuses missing_on_disk_store when neither legacy nor acp store exists', async () => { + const out = await importCursorSession({ + uuid: '11111111-2222-3333-4444-555555555555', + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('missing_on_disk_store') + }) + + it('refuses already_imported when a Hapi row already references the uuid', async () => { + const uuid = '11111111-2222-3333-4444-666666666666' + h.placeAcpStore(uuid, { name: 'already-imported chat' }) + const planted = h.store.sessions.getOrCreateSession('hapi-prev-tag', { + path: '/workspace/y', + host: 'test-host', + flavor: 'cursor', + cursorSessionId: uuid + } as Record, {}, 'default') + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('already_imported') + expect(out.message).toContain(planted.id) + }) + + it('refuses ambiguous_legacy_store when the same uuid exists in 2+ drawers without workspacePath', async () => { + const uuid = '11111111-2222-3333-4444-777777777777' + h.placeLegacyStore(uuid, { workspaceHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', name: 'd1' }) + h.placeLegacyStore(uuid, { workspaceHash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', name: 'd2' }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('ambiguous_legacy_store') + }) + + it('refuses corrupted_store when store.db is not valid sqlite', async () => { + const uuid = '11111111-2222-3333-4444-888888888888' + const dir = join(h.acpSessionsDir, uuid) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'store.db'), 'not a sqlite database at all') + writeFileSync(join(dir, 'meta.json'), JSON.stringify({ schemaVersion: 1, cwd: '/x' })) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('corrupted_store') + }) + + it('refuses verify_load_failed when agent acp initialize fails', async () => { + const uuid = '11111111-2222-3333-4444-999999999999' + h.placeAcpStore(uuid, { name: 'will fail init' }) + const probe = makeMockProbe({ initializeResponse: err('initialize bombed') }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h, probe) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + expect(out.message).toContain('initialize') + // STRICT contract: refusal must NOT have created a Hapi session row. + expect(h.store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('refuses verify_load_failed when agent acp session/load fails', async () => { + const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + h.placeAcpStore(uuid, { name: 'will fail load' }) + const probe = makeMockProbe({ loadResponse: err('session/load failed: bad blob graph') }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h, probe) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_load_failed') + expect(out.message).toContain('session/load') + expect(h.store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('refuses verify_timeout when probe reports a timeout', async () => { + const uuid = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff' + h.placeAcpStore(uuid, { name: 'will time out' }) + const probe = makeMockProbe({ loadResponse: err('timeout after 30000ms', -32001) }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h, probe) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('verify_timeout') + expect(h.store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('refuses target_already_exists when the ACP target dir is already populated for a legacy import', async () => { + const uuid = 'cccccccc-dddd-eeee-ffff-000000000000' + h.placeLegacyStore(uuid, { workspaceHash: 'wsh-only', name: 'legacy with stale acp twin' }) + // Plant an unrelated file in the ACP target so the existence check fires. + const acpDir = join(h.acpSessionsDir, uuid) + mkdirSync(acpDir, { recursive: true }) + writeFileSync(join(acpDir, 'meta.json'), JSON.stringify({ schemaVersion: 1 })) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('target_already_exists') + }) + + it('refuses agent_binary_not_found when no `agent` binary is reachable', async () => { + // Drop the fake binary placed by beforeEach. + rmSync(join(h.home, '.local', 'bin', 'agent'), { force: true }) + const originalPath = process.env.PATH + // Sanitize PATH so the real `agent` (if present on developer + // machines) cannot be found either. + process.env.PATH = '/__hapi_test_dummy__/bin' + try { + const uuid = 'dddddddd-eeee-ffff-0000-111111111111' + h.placeAcpStore(uuid, { name: 'no agent binary' }) + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.reason).toBe('agent_binary_not_found') + } finally { + process.env.PATH = originalPath + } + }) +}) + +describe('importCursorSession happy paths', () => { + let h: Harness + beforeEach(() => { + h = makeHarness() + h.placeFakeAgentBinary() + }) + afterEach(() => cleanupHarness(h)) + + it('imports an ACP-format chat without touching disk and creates a Hapi row', async () => { + const uuid = 'eeeeeeee-ffff-0000-1111-222222222222' + h.placeAcpStore(uuid, { name: 'acp happy', cwd: '/workspace/happy-acp' }) + + const out = await importCursorSession({ + uuid, + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.sourceFormat).toBe('acp') + expect(out.hapiSessionId).toBeTruthy() + + const session = h.store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const metadata = session.metadata as Record + expect(metadata.flavor).toBe('cursor') + expect(metadata.cursorSessionId).toBe(uuid) + // STRICT contract: any HAPI row produced by this import path is + // ACP from birth. + expect(metadata.cursorSessionProtocol).toBe('acp') + expect(metadata.lifecycleState).toBe('imported') + }) + + it('imports a legacy chat by transplanting to the ACP location', async () => { + const uuid = 'ffffffff-0000-1111-2222-333333333333' + const sourceStorePath = h.placeLegacyStore(uuid, { + workspaceHash: 'wsh-only-source', + name: 'legacy happy' + }) + // Sanity: the legacy store exists where we expect. + expect(sourceStorePath).toContain('chats') + + const out = await importCursorSession({ + uuid, + workspacePath: '/workspace/legacy-happy', + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.sourceFormat).toBe('legacy') + + // After import the ACP target dir should exist with store.db + meta.json. + const acpDir = join(h.acpSessionsDir, uuid) + const acpStore = join(acpDir, 'store.db') + const acpMeta = join(acpDir, 'meta.json') + const { existsSync, readFileSync } = await import('node:fs') + expect(existsSync(acpStore)).toBe(true) + expect(existsSync(acpMeta)).toBe(true) + const meta = JSON.parse(readFileSync(acpMeta, 'utf-8')) as Record + expect(meta.schemaVersion).toBe(1) + expect(meta.cwd).toBe('/workspace/legacy-happy') + + const session = h.store.sessions.getSessionsByNamespace('default')[0] + const metadata = session.metadata as Record + expect(metadata.cursorSessionProtocol).toBe('acp') + expect(metadata.path).toBe('/workspace/legacy-happy') + }) + + it('importSelectedCursorSessions returns per-row outcomes (mixed batch)', async () => { + const goodUuid = '00000000-1111-2222-3333-444444444444' + const badUuid = '00000000-1111-2222-3333-555555555555' + h.placeAcpStore(goodUuid, { name: 'will succeed' }) + // badUuid intentionally has no on-disk store. + + const out = await importSelectedCursorSessions({ + uuids: [goodUuid, badUuid], + store: h.store, + namespace: 'default', + home: h.home, + deps: makeDeps(h) + }) + expect(out.results).toHaveLength(2) + expect(out.importedCount).toBe(1) + const byUuid = new Map(out.results.map((r) => [r.uuid, r])) + expect(byUuid.get(goodUuid)?.ok).toBe(true) + const badRow = byUuid.get(badUuid) + expect(badRow?.ok).toBe(false) + if (badRow && !badRow.ok) { + expect(badRow.reason).toBe('missing_on_disk_store') + } + }) +}) + +/* ---------- route shape ---------- */ + +function createRoutesApp(opts: { namespace: string; store: Store }): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', opts.namespace) + await next() + }) + app.route('/api', createCursorImportRoutes({ + store: opts.store, + getSyncEngine: () => null + })) + return app +} + +describe('Cursor import HTTP routes', () => { + let h: Harness + const originalHomeOverride = process.env.HAPI_CURSOR_HOME_OVERRIDE + const originalLogRoot = process.env.HAPI_CURSOR_LOG_ROOT + beforeEach(() => { + h = makeHarness() + h.placeFakeAgentBinary() + process.env.HAPI_CURSOR_HOME_OVERRIDE = h.home + process.env.HAPI_CURSOR_LOG_ROOT = h.home + }) + afterEach(() => { + if (originalHomeOverride === undefined) { + delete process.env.HAPI_CURSOR_HOME_OVERRIDE + } else { + process.env.HAPI_CURSOR_HOME_OVERRIDE = originalHomeOverride + } + if (originalLogRoot === undefined) { + delete process.env.HAPI_CURSOR_LOG_ROOT + } else { + process.env.HAPI_CURSOR_LOG_ROOT = originalLogRoot + } + cleanupHarness(h) + }) + + it('rejects non-default namespaces', async () => { + const app = createRoutesApp({ namespace: 'tenant-a', store: h.store }) + const res = await app.request('/api/cursor/importable-sessions') + expect(res.status).toBe(403) + const body = await res.json() as Record + expect(body.success).toBe(false) + }) + + it('GET /api/cursor/importable-sessions returns the discovery list', async () => { + const app = createRoutesApp({ namespace: 'default', store: h.store }) + h.placeAcpStore('77777777-7777-7777-7777-777777777777', { name: 'route test' }) + const res = await app.request('/api/cursor/importable-sessions') + expect(res.status).toBe(200) + const body = await res.json() as { success: true; sessions: Array<{ id: string }> } + expect(body.success).toBe(true) + expect(body.sessions.some((s) => s.id === '77777777-7777-7777-7777-777777777777')).toBe(true) + }) + + it('POST /api/cursor/import rejects empty uuid arrays', async () => { + const app = createRoutesApp({ namespace: 'default', store: h.store }) + const res = await app.request('/api/cursor/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uuids: [] }) + }) + expect(res.status).toBe(400) + }) + + it('POST /api/cursor/import returns a per-row outcome for each requested uuid', async () => { + const app = createRoutesApp({ namespace: 'default', store: h.store }) + const res = await app.request('/api/cursor/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uuids: ['not-on-disk-uuid'] }) + }) + // Pre-flight refuses on missing_on_disk_store before any probe spawn. + expect(res.status).toBe(200) + const body = await res.json() as { success: true; results: Array<{ ok: boolean; reason?: string }>; importedCount: number } + expect(body.success).toBe(true) + expect(body.results).toHaveLength(1) + expect(body.results[0].ok).toBe(false) + expect(body.importedCount).toBe(0) + }) +}) diff --git a/hub/src/web/routes/cursorImport.ts b/hub/src/web/routes/cursorImport.ts new file mode 100644 index 0000000000..55422f0a96 --- /dev/null +++ b/hub/src/web/routes/cursorImport.ts @@ -0,0 +1,179 @@ +/** + * Cursor flavor of the multi-agent session import surface. + * + * Mirrors the codex import route shape (`hub/src/web/routes/codexDesktop.ts`, + * shipped upstream in `tiann/hapi#796`) so the diff parallel between the + * two routes minimizes review friction. The cursor endpoints live + * alongside the codex endpoints rather than under a generalized + * `/api/agent-sessions/...` umbrella; only the shared types live in + * `_agentImport/types.ts`. + * + * Endpoints: + * GET /api/cursor/importable-sessions → list local cursor chats + * POST /api/cursor/import { uuids[], workspacePath? } → import N rows + * + * The strict ACP-only refusal contract lives in `cursorImporter.ts`. + * This module is namespace-gated to `default` (matching codex), proxies + * the importer's structured outcomes back to the dialog, and writes a + * lightweight audit log line per import. + */ + +import { Hono } from 'hono' +import { appendFileSync, existsSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +import type { Store } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { + importSelectedCursorSessions, + listImportableCursorSessions +} from '../../cursor/cursorImporter' +import type { + CursorImportResponse, + CursorImportRowOutcome, + CursorImportableSessionsResponse +} from './_agentImport/types' + +const CURSOR_IMPORT_NAMESPACE_ERROR = 'Cursor session import is not available outside the default namespace' +const NO_CURSOR_SESSION_SELECTED_ERROR = 'No cursor sessions selected for import' + +function getHome(): string { + return process.env.HAPI_CURSOR_HOME_OVERRIDE?.trim() || homedir() +} + +function getLogRoot(): string { + const configured = process.env.HAPI_CURSOR_LOG_ROOT?.trim() + return configured || process.cwd() +} + +function appendImportLog(message: string): void { + try { + const logDir = join(getLogRoot(), 'logs') + mkdirSync(logDir, { recursive: true }) + const line = `[${new Date().toISOString()}] [cursor-import] ${message}\n` + appendFileSync(join(logDir, 'CursorImport.log'), line, 'utf-8') + } catch { + // best-effort + } +} + +interface CursorImportRequestParseResult { + uuids: string[] + workspacePath: string | null + error?: string +} + +function parseImportRequest(body: unknown): CursorImportRequestParseResult { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return { uuids: [], workspacePath: null } + } + const record = body as Record + const rawUuids = record.uuids + let uuids: string[] = [] + if (Array.isArray(rawUuids)) { + for (const value of rawUuids) { + if (typeof value !== 'string') { + return { uuids: [], workspacePath: null, error: 'Invalid uuids' } + } + const trimmed = value.trim() + if (trimmed) uuids.push(trimmed) + } + } else if (rawUuids !== undefined) { + return { uuids: [], workspacePath: null, error: 'Invalid uuids' } + } + uuids = Array.from(new Set(uuids)) + + let workspacePath: string | null = null + if (typeof record.workspacePath === 'string') { + const trimmed = record.workspacePath.trim() + workspacePath = trimmed.length > 0 ? trimmed : null + } else if (record.workspacePath != null && record.workspacePath !== undefined) { + return { uuids: [], workspacePath: null, error: 'Invalid workspacePath' } + } + + return { uuids, workspacePath } +} + +export function createCursorImportRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.use('/cursor/*', async (c, next) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CURSOR_IMPORT_NAMESPACE_ERROR + }, 403) + } + return next() + }) + + app.get('/cursor/importable-sessions', (c) => { + const home = getHome() + const sessions = listImportableCursorSessions({ + store: options.store, + namespace: c.get('namespace'), + home + }) + return c.json({ + success: true, + sessions + } satisfies CursorImportableSessionsResponse) + }) + + app.post('/cursor/import', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseImportRequest(body) + if (parsed.error) { + appendImportLog(`FAILED: ${parsed.error}`) + return c.json({ + success: false, + error: parsed.error + }, 400) + } + if (parsed.uuids.length === 0) { + appendImportLog(`FAILED: ${NO_CURSOR_SESSION_SELECTED_ERROR}`) + return c.json({ + success: false, + error: NO_CURSOR_SESSION_SELECTED_ERROR + }, 400) + } + + const home = getHome() + const result = await importSelectedCursorSessions({ + uuids: parsed.uuids, + workspacePath: parsed.workspacePath, + store: options.store, + namespace: c.get('namespace'), + home, + getSyncEngine: options.getSyncEngine + }) + + appendImportLog( + `imported=${result.importedCount}/${parsed.uuids.length}; uuids=${parsed.uuids.join(',')}; outcomes=${result.results.map(rowToLog).join('|')}` + ) + + const response: CursorImportResponse = { + success: true, + results: result.results, + importedCount: result.importedCount + } + return c.json(response) + }) + + return app +} + +function rowToLog(row: CursorImportRowOutcome): string { + if (row.ok) { + return `ok(${row.uuid}->${row.hapiSessionId} ${row.sourceFormat} ${row.durationMs}ms)` + } + return `fail(${row.uuid} ${row.reason} ${row.durationMs}ms)` +} + +// Re-export for direct programmatic use from tests / future CLI subcommand. +export { listImportableCursorSessions, importSelectedCursorSessions } from '../../cursor/cursorImporter' diff --git a/hub/src/web/routes/devices.test.ts b/hub/src/web/routes/devices.test.ts new file mode 100644 index 0000000000..07819e2dcc --- /dev/null +++ b/hub/src/web/routes/devices.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { SignJWT } from 'jose' +import type { WebAppEnv } from '../middleware/auth' +import { createAuthMiddleware } from '../middleware/auth' +import { Store } from '../../store' +import { createDevicesRoutes } from './devices' + +const JWT_SECRET = new TextEncoder().encode('test-secret') + +async function authHeaders() { + const token = await new SignJWT({ uid: 1, ns: 'default' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(JWT_SECRET) + return { authorization: `Bearer ${token}` } +} + +function createApp(store: Store) { + const app = new Hono() + app.use('*', createAuthMiddleware(JWT_SECRET)) + app.route('/api', createDevicesRoutes(store)) + return app +} + +describe('devices routes', () => { + it('registers and unregisters FCM devices for namespace', async () => { + const store = new Store(':memory:') + const app = createApp(store) + const headers = await authHeaders() + + const register = await app.request('/api/devices/register', { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ + token: 'fcm-tok-1', + platform: 'wear', + deviceId: 'watch-1' + }) + }) + expect(register.status).toBe(200) + + const devices = store.fcm.getDevicesByNamespace('default') + expect(devices).toHaveLength(1) + expect(devices[0].platform).toBe('wear') + + const unregister = await app.request('/api/devices/register', { + method: 'DELETE', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'fcm-tok-1' }) + }) + expect(unregister.status).toBe(200) + expect(store.fcm.getDevicesByNamespace('default')).toHaveLength(0) + }) +}) diff --git a/hub/src/web/routes/devices.ts b/hub/src/web/routes/devices.ts new file mode 100644 index 0000000000..e42fb6ca42 --- /dev/null +++ b/hub/src/web/routes/devices.ts @@ -0,0 +1,44 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' + +const registerSchema = z.object({ + token: z.string().min(1), + platform: z.enum(['phone', 'wear']), + deviceId: z.string().min(1).max(128) +}) + +const unregisterSchema = z.object({ + token: z.string().min(1) +}) + +export function createDevicesRoutes(store: Store): Hono { + const app = new Hono() + + app.post('/devices/register', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = registerSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const namespace = c.get('namespace') + store.fcm.upsertDevice(namespace, parsed.data) + return c.json({ ok: true }) + }) + + app.delete('/devices/register', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = unregisterSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const namespace = c.get('namespace') + store.fcm.removeDeviceByToken(namespace, parsed.data.token) + return c.json({ ok: true }) + }) + + return app +} diff --git a/hub/src/web/routes/guards.ts b/hub/src/web/routes/guards.ts index 9056111d6e..8e51fb51d2 100644 --- a/hub/src/web/routes/guards.ts +++ b/hub/src/web/routes/guards.ts @@ -43,6 +43,9 @@ export function requireSessionFromParam( ): { sessionId: string; session: Session } | Response { const paramName = options?.paramName ?? 'id' const sessionId = c.req.param(paramName) + if (!sessionId) { + return c.json({ error: `Missing required path parameter: ${paramName}` }, 400) + } const result = requireSession(c, engine, sessionId, { requireActive: options?.requireActive }) if (result instanceof Response) { return result diff --git a/hub/src/web/routes/inboxItems.test.ts b/hub/src/web/routes/inboxItems.test.ts new file mode 100644 index 0000000000..2d484149c4 --- /dev/null +++ b/hub/src/web/routes/inboxItems.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hapi/protocol' +import { Store } from '../../store' +import { SyncEngine } from '../../sync/syncEngine' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { createInboxItemsRoutes } from './inboxItems' +import type { WebAppEnv } from '../middleware/auth' + +describe('inboxItems routes', () => { + it('lists promoted inbox items in coarse-rank order', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('route-inbox', { name: 'route-session' }, null, 'default') + const payloadJson = mergeEventPayloadWithSession({}, buildOverseerSessionIdentity({ + id: session.id, + flavor: 'codex', + tag: session.tag, + metadata: session.metadata as { name?: string } | null + })) + const event = store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'blocked', + attentionCandidate: 1, + summary: 'Route smoke inbox item', + relatedSessionId: session.id, + payloadJson, + provenance: 'test' + }) + store.inbox.promoteAttentionEvent(event!) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createInboxItemsRoutes(() => engine)) + + const listRes = await app.request('/api/inbox-items?activeOnly=1') + expect(listRes.status).toBe(200) + const body = await listRes.json() as { total: number; items: Array<{ summary: string; title: string }> } + expect(body.total).toBe(1) + expect(body.items[0]?.summary).toBe('Route smoke inbox item') + expect(body.items[0]?.title).toBe('route-session') + }) + + it('records operator actions', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('route-action', { name: 'x' }, null, 'default') + const payloadJson = mergeEventPayloadWithSession({}, buildOverseerSessionIdentity({ + id: session.id, + flavor: 'codex', + tag: session.tag, + metadata: session.metadata as { name?: string } | null + })) + const event = store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + eventType: 'needs_decision', + attentionCandidate: 1, + summary: 'decide', + relatedSessionId: session.id, + payloadJson, + provenance: 'test' + }) + const item = store.inbox.promoteAttentionEvent(event!) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createInboxItemsRoutes(() => engine)) + + const res = await app.request(`/api/inbox-items/${item!.id}/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'dismiss', feedback: 'noise' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { item: { status: string; operatorFeedback: string | null } } + expect(body.item.status).toBe('obsoleted') + expect(body.item.operatorFeedback).toBe('noise') + }) +}) diff --git a/hub/src/web/routes/inboxItems.ts b/hub/src/web/routes/inboxItems.ts new file mode 100644 index 0000000000..a6b33c2d3e --- /dev/null +++ b/hub/src/web/routes/inboxItems.ts @@ -0,0 +1,84 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { INBOX_OPERATOR_ACTIONS } from '@hapi/protocol' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const listQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + activeOnly: z.enum(['0', '1']).optional(), + sessionId: z.string().min(1).optional() +}) + +const actionBodySchema = z.object({ + action: z.enum(INBOX_OPERATOR_ACTIONS), + feedback: z.string().max(500).optional(), + snoozedUntil: z.number().int().positive().optional() +}) + +export function createInboxItemsRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/inbox-items', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const parsed = listQuerySchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid query', issues: parsed.error.flatten() }, 400) + } + + const activeOnly = parsed.data.activeOnly === '1' + const items = engine.getInboxItems({ + limit: parsed.data.limit ?? 50, + activeOnly, + sessionId: parsed.data.sessionId ?? null + }) + + return c.json({ + total: engine.getInboxItemCount(), + items + }) + }) + + app.post('/inbox-items/:id/actions', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const inboxItemId = Number(c.req.param('id')) + if (!Number.isInteger(inboxItemId) || inboxItemId <= 0) { + return c.json({ error: 'Invalid inbox item id' }, 400) + } + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + + const parsed = actionBodySchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const item = engine.recordInboxOperatorAction( + inboxItemId, + parsed.data.action, + parsed.data.feedback ?? null, + parsed.data.snoozedUntil ?? null + ) + if (!item) { + return c.json({ error: 'Inbox item not found' }, 404) + } + + return c.json({ item }) + }) + + return app +} diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 3c4e6457ba..c9b33e5c95 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -57,6 +57,53 @@ describe('machines routes', () => { }) }) + it('returns Codex sessions for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCodexSessionsForMachine: async () => ({ + success: true, + sessions: [ + { + id: 'thread-1', + title: 'Fix auth bug', + updatedAt: 100, + path: '/repo', + model: 'gpt-5', + isOld: false + } + ], + nextCursor: null + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/codex-sessions?includeOld=1&limit=20') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + sessions: [ + { + id: 'thread-1', + title: 'Fix auth bug', + updatedAt: 100, + path: '/repo', + model: 'gpt-5', + isOld: false + } + ], + nextCursor: null + }) + }) + it('returns 400 when /opencode-models is called without cwd', async () => { const machine = createMachine() const engine = { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 7288a42de0..9245fc623c 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -49,7 +49,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, - undefined, + parsed.data.resumeSessionId, + parsed.data.importHistory, parsed.data.effort ) return c.json(result) @@ -112,6 +113,47 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + + app.get('/machines/:id/codex-sessions', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const query = c.req.query() + const includeOld = query.includeOld === '1' || query.includeOld === 'true' + const olderThanDays = Number.isFinite(Number(query.olderThanDays)) + ? Number(query.olderThanDays) + : undefined + const limit = Number.isFinite(Number(query.limit)) + ? Number(query.limit) + : undefined + const cursor = typeof query.cursor === 'string' && query.cursor.length > 0 + ? query.cursor + : undefined + + try { + const result = await engine.listCodexSessionsForMachine(machineId, { + includeOld, + olderThanDays, + limit, + cursor + }) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Codex sessions' + }, 500) + } + }) + app.get('/machines/:id/codex-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/overseer.test.ts b/hub/src/web/routes/overseer.test.ts new file mode 100644 index 0000000000..9972a896d6 --- /dev/null +++ b/hub/src/web/routes/overseer.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { Store } from '../../store' +import { SyncEngine } from '../../sync/syncEngine' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { createOverseerRoutes } from './overseer' +import type { WebAppEnv } from '../middleware/auth' + +function buildApp(store: Store): Hono { + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createOverseerRoutes(() => engine)) + return app +} + +describe('overseer routes', () => { + it('GET /overseer/identity returns read-only identity + system prompt', async () => { + const app = buildApp(new Store(':memory:')) + const res = await app.request('/api/overseer/identity') + expect(res.status).toBe(200) + const body = await res.json() as { + identity: { canDispatch: boolean; tools: unknown[] } + systemPrompt: string + } + expect(body.identity.canDispatch).toBe(false) + expect(body.identity.tools.length).toBe(7) + expect(body.systemPrompt).toContain('Overseer') + }) + + it('GET /overseer/voice returns prompt + backend descriptor', async () => { + const app = buildApp(new Store(':memory:')) + const res = await app.request('/api/overseer/voice') + expect(res.status).toBe(200) + const body = await res.json() as { systemPrompt: string; backends: unknown } + expect(body.systemPrompt).toContain('Overseer') + expect(Array.isArray(body.backends)).toBe(true) + }) + + it('POST /overseer/tools/query_events dispatches a read-only tool', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('r', { flavor: 'codex', path: '/tmp/web' }, null, 'default') + store.events.insert({ + ts: Date.now(), sourceKind: 'worker', eventType: 'blocked', attentionCandidate: 1, severity: 4, + summary: 'blocked event', relatedSessionId: session.id, + payloadJson: JSON.stringify({ session: { project: 'web' } }) + }) + const app = buildApp(store) + + const res = await app.request('/api/overseer/tools/query_events', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ severityMin: 4 }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { tool: string; result: { events: Array<{ summary: string }> } } + expect(body.tool).toBe('query_events') + expect(body.result.events[0]?.summary).toBe('blocked event') + }) + + it('POST /overseer/tools rejects unknown tool (404) and bad args (400)', async () => { + const app = buildApp(new Store(':memory:')) + + const unknown = await app.request('/api/overseer/tools/dispatch_now', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' + }) + expect(unknown.status).toBe(404) + + const bad = await app.request('/api/overseer/tools/explain_priority', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ itemId: -1 }) + }) + expect(bad.status).toBe(400) + }) + + it('POST /overseer/convo-turns persists a convo_turn event', async () => { + const store = new Store(':memory:') + const app = buildApp(store) + const res = await app.request('/api/overseer/convo-turns', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ operatorText: 'who is blocked?', overseerText: 'peer-15 on CI auth' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { event: { eventType: string; attentionCandidate: number } } + expect(body.event.eventType).toBe('convo_turn') + expect(body.event.attentionCandidate).toBe(0) + }) + + it('POST /overseer/convo-turns rejects an empty turn', async () => { + const app = buildApp(new Store(':memory:')) + const res = await app.request('/api/overseer/convo-turns', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ operatorText: ' ', overseerText: '' }) + }) + expect(res.status).toBe(400) + }) +}) diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts new file mode 100644 index 0000000000..4b42bf3b3d --- /dev/null +++ b/hub/src/web/routes/overseer.ts @@ -0,0 +1,155 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { + OVERSEER_TOOL_NAMES, + buildOverseerIdentity, + buildOverseerSystemPrompt, + overseerToolArgsSchemas, + type OverseerToolName +} from '@hapi/protocol' +import { listConfiguredVoiceBackends, resolveHubVoiceBackend } from '@hapi/protocol/voice' +import type { SyncEngine } from '../../sync/syncEngine' +import type { OverseerEntity } from '../../sync/overseerEntity' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const convoTurnBodySchema = z.object({ + operatorText: z.string().max(8000).default(''), + overseerText: z.string().max(20000).default(''), + relatedSessionId: z.string().min(1).optional(), + relatedEventId: z.number().int().positive().optional(), + toolCalls: z.array(z.object({ + tool: z.enum(OVERSEER_TOOL_NAMES), + argsSummary: z.string().max(500).optional() + })).max(50).optional(), + ts: z.number().int().positive().optional() +}) + +function runTool(overseer: OverseerEntity, tool: OverseerToolName, args: unknown): unknown { + switch (tool) { + case 'query_events': + return { events: overseer.queryEvents(overseerToolArgsSchemas.query_events.parse(args)) } + case 'query_inbox': + return overseer.queryInbox(overseerToolArgsSchemas.query_inbox.parse(args)) + case 'get_session_state': { + const parsed = overseerToolArgsSchemas.get_session_state.parse(args) + return { state: overseer.getSessionState(parsed.sessionId) } + } + case 'get_session_recent_output': { + const parsed = overseerToolArgsSchemas.get_session_recent_output.parse(args) + return { chunks: overseer.getSessionRecentOutput(parsed.sessionId, parsed.n ?? 10) } + } + case 'get_worker_health': { + const parsed = overseerToolArgsSchemas.get_worker_health.parse(args) + return { health: overseer.getWorkerHealth(parsed.sessionId) } + } + case 'explain_priority': { + const parsed = overseerToolArgsSchemas.explain_priority.parse(args) + return { explanation: overseer.explainPriority(parsed.itemId) } + } + case 'list_active_workers': + return { workers: overseer.listActiveWorkers(overseerToolArgsSchemas.list_active_workers.parse(args)) } + default: { + const exhaustive: never = tool + throw new Error(`Unknown overseer tool: ${String(exhaustive)}`) + } + } +} + +function isToolName(value: string): value is OverseerToolName { + return (OVERSEER_TOOL_NAMES as readonly string[]).includes(value) +} + +export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/overseer/identity', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + return c.json({ + identity: buildOverseerIdentity(), + systemPrompt: buildOverseerSystemPrompt() + }) + }) + + // Voice surface descriptor — everything a client needs to open a voice + // conversation with the Overseer (distinct from per-session voice). The + // chrome-level relocation of the voice button is Step 5, not here. + app.get('/overseer/voice', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + return c.json({ + identity: buildOverseerIdentity(), + systemPrompt: buildOverseerSystemPrompt(), + backend: resolveHubVoiceBackend(process.env), + backends: listConfiguredVoiceBackends(process.env) + }) + }) + + // Read-only tool dispatch. All tools are read-only; this endpoint never + // mutates worker or inbox state. + app.post('/overseer/tools/:tool', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + const tool = c.req.param('tool') + if (!isToolName(tool)) { + return c.json({ error: `Unknown overseer tool: ${tool}` }, 404) + } + + let body: unknown + try { + body = await c.req.json() + } catch { + body = {} + } + + try { + const result = runTool(engine.getOverseer(), tool, body ?? {}) + return c.json({ tool, result }) + } catch (error) { + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid tool arguments', issues: error.flatten() }, 400) + } + throw error + } + }) + + // convo_turn writeback — persists an operator<->Overseer exchange as a + // memory-bearing event (attention_candidate=0, never an inbox item). + app.post('/overseer/convo-turns', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + + const parsed = convoTurnBodySchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + if (!parsed.data.operatorText.trim() && !parsed.data.overseerText.trim()) { + return c.json({ error: 'operatorText or overseerText is required' }, 400) + } + + const event = engine.getOverseer().recordConvoTurn({ + operatorText: parsed.data.operatorText, + overseerText: parsed.data.overseerText, + relatedSessionId: parsed.data.relatedSessionId ?? null, + relatedEventId: parsed.data.relatedEventId ?? null, + toolCalls: parsed.data.toolCalls, + ts: parsed.data.ts + }) + + if (!event) { + return c.json({ error: 'Failed to record convo turn' }, 500) + } + return c.json({ event }) + }) + + return app +} diff --git a/hub/src/web/routes/sessions-scratchlist.test.ts b/hub/src/web/routes/sessions-scratchlist.test.ts new file mode 100644 index 0000000000..11582e26e2 --- /dev/null +++ b/hub/src/web/routes/sessions-scratchlist.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createSessionsRoutes } from './sessions' + +/** + * Tests for the scratchlist v2 (tiann/hapi#893) REST routes: + * GET /api/sessions/:id/scratchlist + * POST /api/sessions/:id/scratchlist + * PUT /api/sessions/:id/scratchlist/:entryId + * DELETE /api/sessions/:id/scratchlist/:entryId + * + * The routes call into a small surface on `SyncEngine` (list/create/ + * update/delete + count). We mock that surface here so the assertions + * focus on: + * - happy-path response shapes + * - auth + namespace gating via `requireSessionFromParam` + * - validation (text required, max length) + * - cap enforcement at SCRATCHLIST_MAX_ENTRIES + * - 404 paths (missing session, missing entry) + * - 200 vs 201 split (created vs duplicate during migration retries) + * + * SSE emission is exercised at the SyncEngine + SessionCache layer in a + * separate test (`syncEngine-scratchlist.test.ts`). + */ + +function createSession(overrides?: Partial): Session { + const baseMetadata = { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex' as const + } + const base: Session = { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: baseMetadata, + metadataVersion: 1, + agentState: { + controlledByUser: false, + requests: {}, + completedRequests: {} + }, + agentStateVersion: 1, + thinking: false, + thinkingAt: 1, + model: 'gpt-5.4', + modelReasoningEffort: null, + effort: null, + permissionMode: 'default', + collaborationMode: 'default' + } + return { ...base, ...overrides } +} + +type EngineOverrides = Partial<{ + listScratchlistEntries: SyncEngine['listScratchlistEntries'] + countScratchlistEntries: SyncEngine['countScratchlistEntries'] + getScratchlistEntry: SyncEngine['getScratchlistEntry'] + createScratchlistEntry: SyncEngine['createScratchlistEntry'] + updateScratchlistEntry: SyncEngine['updateScratchlistEntry'] + deleteScratchlistEntry: SyncEngine['deleteScratchlistEntry'] + sessionAccess: 'ok' | 'not-found' | 'wrong-namespace' + callerNamespace: string +}> + +function createApp(session: Session, overrides: EngineOverrides = {}) { + const engine = { + resolveSessionAccess: () => { + if (overrides.sessionAccess === 'not-found') { + return { ok: false, reason: 'not-found' as const } + } + if (overrides.sessionAccess === 'wrong-namespace') { + return { ok: false, reason: 'access-denied' as const } + } + return { ok: true, sessionId: session.id, session } + }, + listScratchlistEntries: overrides.listScratchlistEntries ?? (() => []), + countScratchlistEntries: overrides.countScratchlistEntries ?? (() => 0), + getScratchlistEntry: overrides.getScratchlistEntry ?? (() => null), + createScratchlistEntry: overrides.createScratchlistEntry + ?? ((sessionId: string, text: string) => ({ + outcome: 'created' as const, + entry: { + entryId: `auto-${Date.now()}`, + text, + createdAt: 1000, + updatedAt: 1000 + } + })), + updateScratchlistEntry: overrides.updateScratchlistEntry + ?? ((sessionId: string, entryId: string, text: string) => ({ + entryId, + text, + createdAt: 1000, + updatedAt: 2000 + })), + deleteScratchlistEntry: overrides.deleteScratchlistEntry ?? (() => true) + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', overrides.callerNamespace ?? 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + return app +} + +describe('GET /api/sessions/:id/scratchlist', () => { + it('returns the entries returned by the engine', async () => { + const session = createSession() + const app = createApp(session, { + listScratchlistEntries: () => [ + { entryId: 'a', text: 'note A', createdAt: 1000, updatedAt: 1000 }, + { entryId: 'b', text: 'note B', createdAt: 2000, updatedAt: 2500 } + ] + }) + const res = await app.request('/api/sessions/session-1/scratchlist') + expect(res.status).toBe(200) + const body = await res.json() as { entries: Array<{ entryId: string }> } + expect(body.entries.map((e) => e.entryId)).toEqual(['a', 'b']) + }) + + it('returns 404 when the session is not visible to the caller', async () => { + const session = createSession() + const app = createApp(session, { sessionAccess: 'not-found' }) + const res = await app.request('/api/sessions/session-1/scratchlist') + expect(res.status).toBe(404) + }) + + it('returns 403 when the session belongs to a different namespace', async () => { + const session = createSession({ namespace: 'other' }) + const app = createApp(session, { sessionAccess: 'wrong-namespace' }) + const res = await app.request('/api/sessions/session-1/scratchlist') + expect(res.status).toBe(403) + }) +}) + +describe('POST /api/sessions/:id/scratchlist', () => { + it('creates an entry and returns 201 with the canonical row', async () => { + const session = createSession() + const calls: Array<{ sessionId: string; text: string; entryId?: string; createdAt?: number }> = [] + const app = createApp(session, { + createScratchlistEntry: (sessionId, text, options) => { + calls.push({ sessionId, text, entryId: options?.entryId, createdAt: options?.createdAt }) + return { + outcome: 'created' as const, + entry: { + entryId: options?.entryId ?? 'fresh-id', + text, + createdAt: options?.createdAt ?? 1000, + updatedAt: 1000 + } + } + } + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'first thought' }) + }) + expect(res.status).toBe(201) + const body = await res.json() as { entry: { text: string; entryId: string } } + expect(body.entry.text).toBe('first thought') + expect(calls).toHaveLength(1) + expect(calls[0]?.sessionId).toBe('session-1') + }) + + it('returns 200 with the existing row on duplicate (migration idempotency path)', async () => { + const session = createSession() + const app = createApp(session, { + createScratchlistEntry: () => ({ + outcome: 'duplicate' as const, + entry: { entryId: 'dup', text: 'pre-existing', createdAt: 100, updatedAt: 100 } + }) + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'replay', entryId: 'dup' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { entry: { text: string } } + expect(body.entry.text).toBe('pre-existing') + }) + + it('rejects empty text with 400', async () => { + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: '' }) + }) + expect(res.status).toBe(400) + }) + + it('rejects oversize text (>10_000 chars) with 400', async () => { + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'x'.repeat(10_001) }) + }) + expect(res.status).toBe(400) + }) + + it('returns 409 when the session is at the cap', async () => { + const session = createSession() + const app = createApp(session, { + countScratchlistEntries: () => 200 + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'one too many' }) + }) + expect(res.status).toBe(409) + const body = await res.json() as { code: string } + expect(body.code).toBe('scratchlist_at_cap') + }) + + it('still returns 200 for a duplicate entryId even when the session is at the cap (HAPI Bot, PR #896)', async () => { + // The cap check used to fire BEFORE the duplicate check, which + // turned an idempotent migration retry into a hard 409 the + // moment a session reached `SCRATCHLIST_MAX_ENTRIES`. The fix + // short-circuits on getScratchlistEntry first; this test pins + // that ordering. + const session = createSession() + const createCalls: number[] = [] + const app = createApp(session, { + countScratchlistEntries: () => 200, + getScratchlistEntry: (_sessionId, entryId) => { + if (entryId === 'pre-existing') { + return { + entryId: 'pre-existing', + text: 'already there', + createdAt: 100, + updatedAt: 100 + } + } + return null + }, + createScratchlistEntry: () => { + createCalls.push(1) + return { + outcome: 'created' as const, + entry: { entryId: 'should-not-fire', text: 'noop', createdAt: 0, updatedAt: 0 } + } + } + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'replay', entryId: 'pre-existing' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { entry: { text: string; entryId: string } } + expect(body.entry.entryId).toBe('pre-existing') + expect(body.entry.text).toBe('already there') + // The route must NOT have called createScratchlistEntry: the + // duplicate short-circuit returns BEFORE reaching the engine. + expect(createCalls).toHaveLength(0) + }) + + it('still returns 409 for a NEW entryId at the cap', async () => { + // Mirror of the test above for the not-duplicate case: a fresh + // POST at cap stays a 409. + const session = createSession() + const app = createApp(session, { + countScratchlistEntries: () => 200, + getScratchlistEntry: () => null + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'fresh', entryId: 'never-seen' }) + }) + expect(res.status).toBe(409) + }) + + it('rejects oversized entryId with 400 (HAPI Bot, PR #896 follow-up)', async () => { + // Server-side guard for the SQLite primary key: an authenticated + // client could otherwise grow the table and its index with + // arbitrarily large keys. 129 chars is one over the 128-char + // cap defined in SCRATCHLIST_MAX_ENTRY_ID_LENGTH. + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'oversized id', entryId: 'x'.repeat(129) }) + }) + expect(res.status).toBe(400) + }) + + it('returns 404 when the engine reports session-not-found post-auth', async () => { + // This path covers a race: auth said the session was visible + // (resolveSessionAccess.ok), but by the time we INSERT the row the + // session is gone. The engine returns `session-not-found` and the + // route surfaces a 404. + const session = createSession() + const app = createApp(session, { + createScratchlistEntry: () => ({ outcome: 'session-not-found' as const }) + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'never lands' }) + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when the session is not visible to the caller', async () => { + const session = createSession() + const app = createApp(session, { sessionAccess: 'not-found' }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'auth gate' }) + }) + expect(res.status).toBe(404) + }) +}) + +describe('PUT /api/sessions/:id/scratchlist/:entryId', () => { + it('returns the updated entry on success', async () => { + const session = createSession() + const app = createApp(session, { + updateScratchlistEntry: (_sessionId, entryId, text) => ({ + entryId, + text, + createdAt: 1000, + updatedAt: 5000 + }) + }) + const res = await app.request('/api/sessions/session-1/scratchlist/entry-1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'edited' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { entry: { text: string; entryId: string } } + expect(body.entry.text).toBe('edited') + expect(body.entry.entryId).toBe('entry-1') + }) + + it('returns 404 when the entry does not exist', async () => { + const session = createSession() + const app = createApp(session, { + updateScratchlistEntry: () => null + }) + const res = await app.request('/api/sessions/session-1/scratchlist/missing-id', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'oops' }) + }) + expect(res.status).toBe(404) + }) + + it('rejects empty text with 400', async () => { + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: '' }) + }) + expect(res.status).toBe(400) + }) + + it('returns 403 when the session is in another namespace', async () => { + const session = createSession({ namespace: 'other' }) + const app = createApp(session, { sessionAccess: 'wrong-namespace' }) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'cross-ns' }) + }) + expect(res.status).toBe(403) + }) +}) + +describe('DELETE /api/sessions/:id/scratchlist/:entryId', () => { + it('returns ok:true when the row was removed', async () => { + const session = createSession() + const app = createApp(session, { + deleteScratchlistEntry: () => true + }) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'DELETE' + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('returns 404 when the row did not exist', async () => { + const session = createSession() + const app = createApp(session, { + deleteScratchlistEntry: () => false + }) + const res = await app.request('/api/sessions/session-1/scratchlist/missing', { + method: 'DELETE' + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when the session is not visible to the caller', async () => { + const session = createSession() + const app = createApp(session, { sessionAccess: 'not-found' }) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'DELETE' + }) + expect(res.status).toBe(404) + }) +}) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index f199a2938c..2202522e3b 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -5,6 +5,9 @@ import { isPermissionModeAllowedForFlavor, RenameSessionRequestSchema, ResumeSessionRequestSchema, + SCRATCHLIST_MAX_ENTRIES, + ScratchlistEntryCreateRequestSchema, + ScratchlistEntryUpdateRequestSchema, SessionCollaborationModeRequestSchema, SessionEffortRequestSchema, SessionModelReasoningEffortRequestSchema, @@ -662,6 +665,148 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + /* + * Scratchlist v2 (tiann/hapi#893). + * + * Operator-private notes attached to a session. All four routes use + * the existing `requireSessionFromParam` guard so the same auth / + * namespace check applies as every other session-scoped route - + * scratchlist contents must NOT leak across namespaces, and a 403 / + * 404 is returned for sessions the caller cannot access. + * + * SSE: every successful mutation emits a `session-updated` patch + * carrying `scratchlistUpdatedAt` (handled in `SyncEngine`). The web + * client uses that as a cache-invalidation token to refetch GET. + */ + + app.get('/sessions/:id/scratchlist', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const entries = engine.listScratchlistEntries(sessionResult.sessionId) + return c.json({ entries }) + }) + + app.post('/sessions/:id/scratchlist', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const body = await c.req.json().catch(() => null) + const parsed = ScratchlistEntryCreateRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + + // Idempotent-retry short-circuit (HAPI Bot, PR #896 review): + // when the caller supplies an explicit entryId AND that id + // already exists, return the canonical row with 200 BEFORE the + // cap check fires. Otherwise a session sitting at the + // 200-entry cap would 409 a duplicate POST that should be a + // no-op - which is exactly the path the localStorage migration + // retry uses after a partial failure. + if (parsed.data.entryId) { + const existing = engine.getScratchlistEntry( + sessionResult.sessionId, + parsed.data.entryId + ) + if (existing) { + return c.json({ entry: existing }, 200) + } + } + + // Server-side cap enforcement. Mirrors the web-side cap so a + // malicious / runaway client can't drive the table without + // bound. Bypassing the optimistic add path on the web client + // (e.g. direct REST call) hits this guard. Bumped only with the + // shared SCRATCHLIST_MAX_ENTRIES constant. + const currentCount = engine.countScratchlistEntries(sessionResult.sessionId) + if (currentCount >= SCRATCHLIST_MAX_ENTRIES) { + return c.json({ + error: `Scratchlist is at its ${SCRATCHLIST_MAX_ENTRIES}-entry cap`, + code: 'scratchlist_at_cap' + }, 409) + } + + const result = engine.createScratchlistEntry( + sessionResult.sessionId, + parsed.data.text, + { + entryId: parsed.data.entryId, + createdAt: parsed.data.createdAt + } + ) + if (result.outcome === 'session-not-found') { + return c.json({ error: 'Session not found' }, 404) + } + // `duplicate` (same entryId already exists) returns 200 with the + // canonical row so the migration path can retry idempotently. + // The web client treats 200-with-existing as success either way. + return c.json({ entry: result.entry }, result.outcome === 'created' ? 201 : 200) + }) + + app.put('/sessions/:id/scratchlist/:entryId', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const entryId = c.req.param('entryId') + if (!entryId) { + return c.json({ error: 'Missing entryId' }, 400) + } + + const body = await c.req.json().catch(() => null) + const parsed = ScratchlistEntryUpdateRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + + const updated = engine.updateScratchlistEntry( + sessionResult.sessionId, + entryId, + parsed.data.text + ) + if (!updated) { + return c.json({ error: 'Scratchlist entry not found' }, 404) + } + return c.json({ entry: updated }) + }) + + app.delete('/sessions/:id/scratchlist/:entryId', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const entryId = c.req.param('entryId') + if (!entryId) { + return c.json({ error: 'Missing entryId' }, 400) + } + const removed = engine.deleteScratchlistEntry(sessionResult.sessionId, entryId) + if (!removed) { + return c.json({ error: 'Scratchlist entry not found' }, 404) + } + return c.json({ ok: true }) + }) + app.get('/sessions/:id/slash-commands', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/hub/src/web/routes/systemEvents.test.ts b/hub/src/web/routes/systemEvents.test.ts new file mode 100644 index 0000000000..812c2c5b1f --- /dev/null +++ b/hub/src/web/routes/systemEvents.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { Store } from '../../store' +import { SyncEngine } from '../../sync/syncEngine' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { createSystemEventsRoutes } from './systemEvents' +import type { WebAppEnv } from '../middleware/auth' + +describe('systemEvents routes', () => { + it('lists persisted events', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('evt-test', { flavor: 'codex', path: '/tmp' }, null, 'default') + store.events.insert({ + ts: Date.now(), + sourceKind: 'worker', + sourceRef: 'peer', + eventType: 'completed', + attentionCandidate: 1, + summary: 'Route smoke event', + relatedSessionId: session.id, + provenance: 'test' + }) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSystemEventsRoutes(() => engine)) + + const res = await app.request('/api/system-events?limit=5') + expect(res.status).toBe(200) + const body = await res.json() as { total: number; events: Array<{ summary: string }> } + expect(body.total).toBe(1) + expect(body.events[0]?.summary).toBe('Route smoke event') + }) + + it('filters by related_session_id via sessionId query', async () => { + const store = new Store(':memory:') + const a = store.sessions.getOrCreateSession('sess-a', { flavor: 'codex', path: '/tmp' }, null, 'default') + const b = store.sessions.getOrCreateSession('sess-b', { flavor: 'codex', path: '/tmp' }, null, 'default') + store.events.insert({ + ts: Date.now(), + sourceKind: 'system', + eventType: 'link_seen', + attentionCandidate: 0, + summary: 'Link seen: example.com/a', + relatedSessionId: a.id, + provenance: 'test' + }) + store.events.insert({ + ts: Date.now(), + sourceKind: 'system', + eventType: 'link_seen', + attentionCandidate: 0, + summary: 'Link seen: example.com/b', + relatedSessionId: b.id, + provenance: 'test' + }) + + const io = { of: () => ({ to: () => ({ emit: () => {}, timeout: () => ({ emit: () => {} }) }) }) } as never + const engine = new SyncEngine(store, io, new RpcRegistry(), { broadcast: () => {} } as never) + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSystemEventsRoutes(() => engine)) + + const res = await app.request(`/api/system-events?sessionId=${encodeURIComponent(a.id)}&eventType=link_seen`) + expect(res.status).toBe(200) + const body = await res.json() as { events: Array<{ relatedSessionId: string | null; summary: string }> } + expect(body.events).toHaveLength(1) + expect(body.events[0]?.relatedSessionId).toBe(a.id) + expect(body.events[0]?.summary).toContain('example.com/a') + }) +}) diff --git a/hub/src/web/routes/systemEvents.ts b/hub/src/web/routes/systemEvents.ts new file mode 100644 index 0000000000..7bb4b2bd32 --- /dev/null +++ b/hub/src/web/routes/systemEvents.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + beforeId: z.coerce.number().int().positive().optional(), + sessionId: z.string().min(1).optional(), + attentionCandidate: z.enum(['0', '1']).optional(), + eventType: z.string().min(1).optional() +}) + +export function createSystemEventsRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/system-events', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const parsed = querySchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ error: 'Invalid query', issues: parsed.error.flatten() }, 400) + } + + const attentionCandidate = parsed.data.attentionCandidate === undefined + ? null + : parsed.data.attentionCandidate === '1' ? 1 : 0 + + const events = engine.getSystemEvents({ + limit: parsed.data.limit ?? 50, + beforeId: parsed.data.beforeId ?? null, + sessionId: parsed.data.sessionId ?? null, + attentionCandidate, + eventType: parsed.data.eventType ?? null + }) + + return c.json({ + total: engine.getSystemEventCount(), + events + }) + }) + + return app +} diff --git a/hub/src/web/routes/transcriptImport.ts b/hub/src/web/routes/transcriptImport.ts new file mode 100644 index 0000000000..f4a5f9a9d7 --- /dev/null +++ b/hub/src/web/routes/transcriptImport.ts @@ -0,0 +1,931 @@ +import { appendFileSync, mkdirSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { homedir, hostname, platform } from 'node:os' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { Store, StoredMessage } from '../../store' + +// 中文注释:本文件收敛 Codex / Claude transcript 导入共用的“落库 / 同步 / 去重 / 响应”逻辑。 +// 各 flavor 只需提供 ImporterAdapter(扫描路径 + 解析器 + flavor/metadata),不再复制粘贴第二份并行逻辑。 + +export type ScriptLogKind = 'sync' | 'restart' + +export type TranscriptFlavor = 'codex' | 'claude' + +export type ImportedMessageContent = { + role: 'user' + content: { + type: 'text' + text: string + } + meta: { + sentFrom: 'cli' + } +} | { + role: 'agent' + content: { + type: typeof AGENT_MESSAGE_PAYLOAD_TYPE + data: unknown + } + meta: { + sentFrom: 'cli' + } +} + +// 中文注释:导入消息在落库前包一层 createdAt,保留 transcript 记录里的原始时间戳。 +// content 仍是会被 JSON.stringify 持久化的 payload,createdAt 只参与排序/活跃时间,不写进 content。 +export type ImportedMessage = { + content: ImportedMessageContent + createdAt?: number +} + +export type LocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null +} + +export type TranscriptImportData = LocalSessionSummary & { + messages: ImportedMessage[] +} + +export type ScriptLaunchResponse = { + success: true + message: string + pid: number + command: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} | { + success: false + error: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} + +export type ImportCandidate = { + sessionId: string + active: boolean + updatedAt: number + metadata: Record | null +} + +export type ImportTargetSelection = { + sessionId: string | null + comparablePrefixCount: number +} + +export type SyncSessionRequestParseResult = { + sessionIds: string[] + error?: string +} + +export type DuplicateSessionGroup = { + sessionId: string + hapiSessionIds: string[] + canonicalSessionId?: string + removedSessionIds?: string[] +} + +export type DuplicateSessionsResponse = { + success: true + duplicates: DuplicateSessionGroup[] +} | { + success: false + error: string +} + +export type MergeDuplicateSessionsResponse = { + success: true + merged: DuplicateSessionGroup[] + mergedCount: number +} | { + success: false + error: string +} + +type DuplicateSessionGroupCandidate = { + flavorSessionId: string + sessions: ImportCandidate[] +} + +// 中文注释:ImporterAdapter 是各 flavor 与通用引擎之间的唯一契约。 +// - sessionIdKey 决定去重/绑定时在 metadata 上读哪个键(codexSessionId / claudeSessionId)。 +// - listLocalSessions / parseTranscript 是各 flavor 专属的扫描与解析。 +export interface ImporterAdapter { + flavor: TranscriptFlavor + sessionIdKey: string + listLocalSessions(limit?: number): LocalSessionSummary[] + parseTranscript(summary: LocalSessionSummary): TranscriptImportData | null +} + +export const DIRECT_IMPORT_COMMAND = 'direct-import' +export const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的会话' +export const DEFAULT_SESSION_SCAN_LIMIT = 500 + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +export function expandHomePath(pathValue: string): string { + return pathValue.replace(/^~(?=$|[\\/])/, homedir()) +} + +export function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +export function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +// 中文注释:把 transcript 记录里的 ISO 时间戳(如 "2026-06-15T09:12:00.706Z")解析成毫秒; +// 缺失或非法时返回 undefined,让调用方回退到文件 mtime。Claude / Codex 共用此入口避免各写一份。 +export function parseImportedTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.length > 0) { + const parsed = Date.parse(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + return undefined +} + +export function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value +} + +export function buildImportedUserMessage(text: string): ImportedMessageContent { + return { + role: 'user', + content: { + type: 'text', + text + }, + meta: { + sentFrom: 'cli' + } + } +} + +export function buildImportedAgentMessage(data: unknown): ImportedMessageContent { + return { + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data + }, + meta: { + sentFrom: 'cli' + } + } +} + +function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string { + let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/') + if (normalized.length > 1) { + normalized = normalized.replace(/\/+$/, '') + } + return options?.caseInsensitive ? normalized.toLowerCase() : normalized +} + +function shouldCompareCaseInsensitive(...pathValues: string[]): boolean { + return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\')) +} + +function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean { + if (!pathValue.trim() || !rootValue.trim()) { + return false + } + + const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue) + const path = normalizeComparablePath(pathValue, { caseInsensitive }) + const root = normalizeComparablePath(rootValue, { caseInsensitive }) + if (!path || !root) { + return false + } + if (path === root) { + return true + } + if (root === '/') { + return path.startsWith('/') + } + return path.startsWith(`${root}/`) +} + +function machineOwnsCwd(machine: Machine, cwd: string): boolean { + const workspaceRoots = machine.metadata?.workspaceRoots ?? [] + return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot)) +} + +export function resolveImportMachineId( + cwd: string | null | undefined, + namespace: string, + engine: SyncEngine | null +): string | undefined { + if (!cwd || !engine) { + return undefined + } + + const matches = engine.getOnlineMachinesByNamespace(namespace) + .filter((machine) => machineOwnsCwd(machine, cwd)) + const machineIds = Array.from(new Set(matches.map((machine) => machine.id))) + return machineIds.length === 1 ? machineIds[0] : undefined +} + +export function buildImportedSessionMetadata( + data: TranscriptImportData, + flavor: TranscriptFlavor, + sessionIdKey: string, + existingMetadata?: Record | null, + resolvedMachineId?: string +): Record { + const now = Date.now() + const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) + const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) + const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() + const summaryText = data.lastUserMessage ?? data.title + const machineId = typeof existingMetadata?.machineId === 'string' + ? existingMetadata.machineId + : resolvedMachineId + + return { + ...(existingMetadata ?? {}), + path, + host, + os: osValue, + name: data.title, + summary: summaryText + ? { + text: summaryText, + updatedAt: now + } + : existingMetadata?.summary, + flavor, + [sessionIdKey]: data.id, + ...(machineId ? { machineId } : {}), + lifecycleState: typeof existingMetadata?.lifecycleState === 'string' + ? existingMetadata.lifecycleState + : 'imported', + lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' + ? existingMetadata.lifecycleStateSince + : now + } +} + +export function stableSerialize(value: unknown): string { + if (value === null || value === undefined) { + return String(value) + } + if (typeof value === 'string') { + return JSON.stringify(value) + } + if (typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value) + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + if (typeof value === 'object') { + const record = value as Record + const keys = Object.keys(record).sort() + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +function normalizeComparableAgentData(value: unknown): unknown { + const record = asRecord(value) + if (!record) { + return value + } + + const normalized = { ...record } + if ('id' in normalized) { + delete normalized.id + } + return normalized +} + +export function normalizeComparableContent(content: unknown): string | null { + const record = asRecord(content) + if (!record) { + return null + } + + if (record.role === 'user') { + const body = asRecord(record.content) + if (body?.type !== 'text' || typeof body.text !== 'string') { + return null + } + return stableSerialize({ + role: 'user', + text: body.text + }) + } + + if (record.role === 'agent') { + const body = asRecord(record.content) + if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { + return null + } + return stableSerialize({ + role: 'agent', + data: normalizeComparableAgentData(body.data) + }) + } + + return null +} + +export function getComparableStoredMessageKey(message: StoredMessage): string { + // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 + return normalizeComparableContent(message.content) ?? stableSerialize(message.content) +} + +export function collectImportCandidates( + store: Store, + namespace: string, + getSyncEngine?: () => SyncEngine | null +): ImportCandidate[] { + const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] + if (engineSessions.length > 0) { + return engineSessions.map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) + } + + return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) +} + +export function selectImportTargetSession( + store: Store, + candidates: ImportCandidate[], + sessionIdKey: string, + flavorSessionId: string, + importedComparableMessages: string[] +): ImportTargetSelection { + const relatedCandidates = candidates + .filter((candidate) => candidate.metadata?.[sessionIdKey] === flavorSessionId) + .sort((a, b) => b.updatedAt - a.updatedAt) + + if (relatedCandidates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + let bestSessionId: string | null = null + let bestPrefixCount = -1 + + for (const candidate of relatedCandidates) { + const comparableMessages = store.messages.getAllMessages(candidate.sessionId) + .map((message) => normalizeComparableContent(message.content)) + .filter((value): value is string => value !== null) + + if (comparableMessages.length > importedComparableMessages.length) { + continue + } + + let prefixMatches = true + for (let index = 0; index < comparableMessages.length; index += 1) { + if (comparableMessages[index] !== importedComparableMessages[index]) { + prefixMatches = false + break + } + } + + if (!prefixMatches) { + continue + } + + if (comparableMessages.length > bestPrefixCount) { + bestPrefixCount = comparableMessages.length + bestSessionId = candidate.sessionId + } + } + + return { + sessionId: bestSessionId, + comparablePrefixCount: Math.max(0, bestPrefixCount) + } +} + +export function listDuplicateSessionGroups( + store: Store, + namespace: string, + sessionIdKey: string, + flavorSessionIds: string[], + getSyncEngine?: () => SyncEngine | null +): DuplicateSessionGroupCandidate[] { + const requestedSessionIds = new Set(flavorSessionIds) + if (requestedSessionIds.size === 0) { + return [] + } + + const groups = new Map() + for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { + const flavorSessionId = typeof candidate.metadata?.[sessionIdKey] === 'string' + ? candidate.metadata[sessionIdKey] as string + : null + if (!flavorSessionId || !requestedSessionIds.has(flavorSessionId)) { + continue + } + + const existing = groups.get(flavorSessionId) + if (existing) { + existing.push(candidate) + } else { + groups.set(flavorSessionId, [candidate]) + } + } + + return Array.from(groups.entries()) + .map(([flavorSessionId, sessions]) => ({ + flavorSessionId, + sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) + })) + .filter((group) => group.sessions.length > 1) +} + +export async function mergeDuplicateSessionGroups(options: { + store: Store + namespace: string + sessionIdKey: string + flavorSessionIds: string[] + getSyncEngine?: () => SyncEngine | null +}): Promise { + const groups = listDuplicateSessionGroups( + options.store, + options.namespace, + options.sessionIdKey, + options.flavorSessionIds, + options.getSyncEngine + ) + if (groups.length === 0) { + return { + success: true, + merged: [], + mergedCount: 0 + } + } + + const merged: DuplicateSessionGroup[] = [] + for (const group of groups) { + const result = await mergeSingleDuplicateSessionGroup({ + group, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + merged.push(result) + } + + return { + success: true, + merged, + mergedCount: merged.length + } +} + +async function mergeSingleDuplicateSessionGroup(options: { + group: DuplicateSessionGroupCandidate + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const engine = options.getSyncEngine?.() ?? null + const sessionStates = options.group.sessions + .map((candidate) => ({ + ...candidate, + storedMessages: options.store.messages.getAllMessages(candidate.sessionId), + })) + .map((candidate) => ({ + ...candidate, + comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) + })) + .sort((a, b) => { + if (b.comparableKeys.length !== a.comparableKeys.length) { + return b.comparableKeys.length - a.comparableKeys.length + } + if (b.updatedAt !== a.updatedAt) { + return b.updatedAt - a.updatedAt + } + return a.sessionId.localeCompare(b.sessionId) + }) + + if (sessionStates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + const canonical = sessionStates[0] + if (!canonical) { + throw new Error(`No duplicate Hapi session found for thread: ${options.group.flavorSessionId}`) + } + + const knownKeys = new Set(canonical.comparableKeys) + const removedSessionIds: string[] = [] + const appendedMessages: StoredMessage[] = [] + let latestActivity = canonical.updatedAt + + for (const source of sessionStates.slice(1)) { + latestActivity = Math.max(latestActivity, source.updatedAt) + for (const message of source.storedMessages) { + const comparableKey = getComparableStoredMessageKey(message) + if (knownKeys.has(comparableKey)) { + continue + } + + const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { + content: message.content, + createdAt: message.createdAt, + localId: message.localId, + invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt + }) + knownKeys.add(comparableKey) + appendedMessages.push(copied) + latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) + } + + if (engine) { + await engine.deleteSession(source.sessionId) + } else { + const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) + if (!deleted) { + throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) + } + } + removedSessionIds.push(source.sessionId) + } + + if (appendedMessages.length > 0) { + emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) + } + + if (engine) { + engine.recordSessionActivity(canonical.sessionId, latestActivity) + // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 + engine.handleRealtimeEvent({ + type: 'session-updated', + sessionId: canonical.sessionId + }) + } else { + options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) + } + + return { + sessionId: options.group.flavorSessionId, + hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), + canonicalSessionId: canonical.sessionId, + removedSessionIds + } +} + +export function emitImportedMessageEvents( + engine: SyncEngine | null, + sessionId: string, + appendedMessages: StoredMessage[] +): void { + if (!engine) { + return + } + + // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 + for (const message of appendedMessages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId ?? null, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +export function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { + // 中文注释:导入弹窗直接提交 thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 + if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { + return { sessionIds: [] } + } + + const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds + if (!Array.isArray(rawSessionIds)) { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + + const sessionIds: string[] = [] + for (const value of rawSessionIds) { + if (typeof value !== 'string') { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + const trimmed = value.trim() + if (trimmed) { + sessionIds.push(trimmed) + } + } + + // 中文注释:前端允许多选,这里按 thread 去重,避免重复导入同一条本地 transcript。 + return { sessionIds: Array.from(new Set(sessionIds)) } +} + +function getDirectImportWorkspace(): string { + const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : process.cwd() +} + +export function getDirectImportRouteContext(): { workspace: string } { + return { + workspace: getDirectImportWorkspace() + } +} + +export function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + try { + const logDir = join(workspace, 'logs') + mkdirSync(logDir, { recursive: true }) + const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` + appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') + } catch { + // Best-effort logging only; API response still carries the error. + } +} + +export function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { + const output = results + .map((result, index) => { + // 中文注释:direct import 不依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 + const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) + return detail ? `[${index + 1}] ${detail}` : '' + }) + .filter(Boolean) + .join('\n\n') + .trim() + return output || undefined +} + +export function createImportErrorResponse( + flavor: TranscriptFlavor, + flavorSessionIds: string[], + error: string, + syncedCount = 0 +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${flavorSessionIds.join(',') || '(none)'}`) + return { + success: false, + error, + cwd: workspace, + sessionIds: flavorSessionIds, + syncedCount + } +} + +export function createImportSuccessResponse( + flavor: TranscriptFlavor, + flavorSessionIds: string[], + results: ScriptLaunchResponse[] +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + const flavorLabel = flavor === 'codex' ? 'Codex' : 'Claude' + appendScriptLog( + workspace, + 'sync', + `SUCCESS: imported ${results.length} ${flavorLabel} session(s); sessionIds=${flavorSessionIds.join(',')}` + ) + return { + success: true, + message: `Imported ${results.length} ${flavorLabel} session(s) into Hapi`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: workspace, + output: combineSyncOutputs(results), + sessionIds: flavorSessionIds, + syncedCount: results.length + } +} + +export function importSingleSession(options: { + adapter: ImporterAdapter + sessionId: string + localSessionsById: Map + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): ScriptLaunchResponse { + const { adapter } = options + const flavorLabel = adapter.flavor === 'codex' ? 'Codex' : 'Claude' + const summary = options.localSessionsById.get(options.sessionId) + if (!summary) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `Transcript not found for ${flavorLabel} session: ${options.sessionId}`), + output: `未找到对应的本地 transcript:${options.sessionId}` + } + } + + const transcript = adapter.parseTranscript(summary) + if (!transcript) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `Failed to parse ${flavorLabel} transcript: ${summary.file}`), + output: `解析 transcript 失败:${summary.file}` + } + } + + if (transcript.messages.length === 0) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `No importable conversation content found in transcript: ${summary.file}`), + output: `transcript 中没有可导入的会话内容:${summary.file}` + } + } + + const importedComparableMessages = transcript.messages + .map((message) => normalizeComparableContent(message.content)) + .filter((value): value is string => value !== null) + + try { + const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) + const target = selectImportTargetSession( + options.store, + candidates, + adapter.sessionIdKey, + options.sessionId, + importedComparableMessages + ) + const engine = options.getSyncEngine?.() ?? null + const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null + const metadata = buildImportedSessionMetadata( + transcript, + adapter.flavor, + adapter.sessionIdKey, + asRecord(existingStored?.metadata), + resolveImportMachineId(transcript.cwd, options.namespace, engine) + ) + + let sessionId = existingStored?.id ?? null + let created = false + if (!sessionId) { + // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 + const createdSession = engine?.getOrCreateSession( + randomUUID(), + metadata, + {}, + options.namespace + ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + sessionId = createdSession.id + created = true + } else if (existingStored) { + const updatedMetadata = options.store.sessions.updateSessionMetadata( + existingStored.id, + metadata, + existingStored.metadataVersion, + options.namespace + ) + if (updatedMetadata.result !== 'success') { + throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) + } + engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) + } + + if (!sessionId) { + throw new Error(`Failed to determine target Hapi session for ${flavorLabel} thread: ${options.sessionId}`) + } + + const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 + const messagesToAppend = transcript.messages.slice(comparablePrefixCount) + // 中文注释:导入历史会话走 copyMessageToSession 落库,保留 transcript 记录里的原始时间戳; + // addMessage 会盖成 Date.now(),导致旧会话被排成“今天刚活跃”、消息时间线被压平。 + // 单条记录缺少逐行时间戳时回退到 transcript.modifiedAt(文件 mtime)。localId 置 null 走已读路径, + // invokedAt 跟随 createdAt 让消息直接落入聊天区而非排队浮条。 + const appendedMessages = messagesToAppend.map((message) => { + const createdAt = message.createdAt ?? transcript.modifiedAt + return options.store.messages.copyMessageToSession(sessionId!, { + content: message.content, + createdAt, + localId: null, + invokedAt: createdAt, + scheduledAt: null + }) + }) + + // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 + // 取这批消息里最大的真实时间戳作为最后活跃时间,避免个别乱序记录把会话排到错误位置。 + const latestMessageCreatedAt = appendedMessages.length > 0 + ? appendedMessages.reduce((max, message) => Math.max(max, message.invokedAt ?? message.createdAt), 0) + : Date.now() + if (created) { + // 中文注释:新建的导入会话出生时间是 now(今天),而真实最后活动在历史里;recordSessionActivity / + // touchSessionUpdatedAt 只前进不后退,无法把 updated_at 调回过去,否则历史会话会一直排在列表顶端 + // 显示成“今天刚活跃”。这里对刚建好的导入会话无条件回填真实最后活动时间,再刷新引擎缓存。 + options.store.sessions.setImportedSessionActivity(sessionId, latestMessageCreatedAt, options.namespace) + engine?.handleRealtimeEvent({ type: 'session-updated', sessionId }) + } else if (engine) { + engine.recordSessionActivity(sessionId, latestMessageCreatedAt) + } else { + options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) + } + if (!created) { + emitImportedMessageEvents(engine, sessionId, appendedMessages) + } + + const output = [ + `${flavorLabel} thread: ${options.sessionId}`, + `Hapi session: ${sessionId}`, + `Action: ${created ? 'created' : 'updated'}`, + `Appended messages: ${appendedMessages.length}` + ].join('\n') + + appendScriptLog( + getDirectImportRouteContext().workspace, + 'sync', + `SUCCESS: ${adapter.sessionIdKey}=${options.sessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` + ) + + return { + success: true, + message: created ? `${flavorLabel} session imported into a new Hapi session` : `${flavorLabel} session appended to existing Hapi session`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: getDirectImportRouteContext().workspace, + output, + sessionIds: [options.sessionId], + syncedCount: 1 + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], message), + output: `${flavorLabel} thread: ${options.sessionId}\n${message}` + } + } +} + +export async function importSelectedSessions(options: { + adapter: ImporterAdapter + sessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const { adapter } = options + const sessionIds = options.sessionIds + if (sessionIds.length === 0) { + return createImportErrorResponse(adapter.flavor, sessionIds, NO_SYNC_SESSION_SELECTED_ERROR) + } + + const localSessionsById = new Map(adapter.listLocalSessions().map((session) => [session.id, session])) + const results: ScriptLaunchResponse[] = [] + for (const sessionId of sessionIds) { + const result = importSingleSession({ + adapter, + sessionId, + localSessionsById, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + results.push(result) + + if (!result.success) { + return { + ...result, + sessionIds, + syncedCount: Math.max(0, results.length - 1), + output: combineSyncOutputs(results) ?? result.output + } + } + } + + return createImportSuccessResponse(adapter.flavor, sessionIds, results) +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..b1dc516e99 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -21,8 +21,14 @@ import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' +import { createClaudeDesktopRoutes } from './routes/claudeDesktop' +import { createCursorImportRoutes } from './routes/cursorImport' import { createPushRoutes } from './routes/push' +import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' +import { createSystemEventsRoutes } from './routes/systemEvents' +import { createInboxItemsRoutes } from './routes/inboxItems' +import { createOverseerRoutes } from './routes/overseer' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -245,8 +251,22 @@ function createWebApp(options: { store: options.store, getSyncEngine: options.getSyncEngine })) + // 中文注释:与 Codex 对称,扫描本地 ~/.claude/projects transcript 以导入 Hapi(复用同一套导入引擎)。 + app.route('/api', createClaudeDesktopRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) + // Cursor flavor of the multi-agent session import surface (ACP verify-probe). + app.route('/api', createCursorImportRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) + app.route('/api', createSystemEventsRoutes(options.getSyncEngine)) + app.route('/api', createInboxItemsRoutes(options.getSyncEngine)) + app.route('/api', createOverseerRoutes(options.getSyncEngine)) // Skip static serving in relay mode, show helpful message on root if (options.relayMode) { diff --git a/package.json b/package.json index c33c2acc89..0ecb1dc9fb 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "test:shared": "cd shared && bun run test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", + "test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts", + "seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, @@ -34,5 +37,8 @@ "playwright": "1.60.0", "react-devtools-core": "^7.0.1", "vite-plugin-pwa": "^1.2.0" + }, + "overrides": { + "@bufbuild/protobuf": "^2.10.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index 779efd169c..7ff2c2884b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test' -const PORT = 5179 +const PORT = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 5179) const BASE_URL = `http://localhost:${PORT}` export default defineConfig({ diff --git a/scripts/audit-cursor-acp-verify.ts b/scripts/audit-cursor-acp-verify.ts new file mode 100644 index 0000000000..daa02831fc --- /dev/null +++ b/scripts/audit-cursor-acp-verify.ts @@ -0,0 +1,537 @@ +#!/usr/bin/env bun +/** + * Cursor ACP-verify audit (pre-PR gate for the upstream Cursor import PR). + * + * For every legacy Cursor chat at ~/.cursor/chats///store.db, this + * tool stages an isolated $HOME/$HAPI_HOME, copies the store to its ACP + * location, synthesizes meta.json, and drives `agent acp` through + * `initialize` + `session/load`. Each verify uses its own temp HOME so + * multiple verifies can run in parallel without colliding on the + * agent-acp-active lock or polluting ~/.cursor. + * + * The pass-rate decides whether the strict "ACP or unimportable" UX of the + * upstream Cursor import PR is viable. See: + * docs/plans/2026-06-08-upstream-cursor-import-acp-only.md (Pre-PR audit) + * docs/plans/2026-06-08-cursor-import-peer-briefing.md (gate logic) + * + * Usage: + * bun scripts/audit-cursor-acp-verify.ts # full run, default CSV + * bun scripts/audit-cursor-acp-verify.ts --limit 5 # smoke + * bun scripts/audit-cursor-acp-verify.ts --concurrency 4 --csv /path/out.csv + * bun scripts/audit-cursor-acp-verify.ts --uuid # single chat + * + * Outcomes (mirrors the migrator's refusal contract): + * ok - initialize + session/load both succeeded + * verify_init_failed - initialize RPC failed + * verify_load_failed - session/load RPC failed + * verify_timeout - any RPC timed out + * spawn_failed - agent binary missing / spawn errored + * corrupted_store - sqlite open / sanity check failed + * probe_crash - probe exited mid-RPC + * + * Output CSV columns: + * wsh,uuid,store_size_bytes,store_mtime_iso,result,duration_ms,error_tail + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, + appendFileSync +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join, delimiter as pathDelimiter } from 'node:path' +import { Database } from 'bun:sqlite' + +const AUTH_FILES = ['cli-config.json', 'agent-cli-state.json', 'acp-config.json'] +const DEFAULT_INIT_TIMEOUT_MS = 20_000 +const DEFAULT_LOAD_TIMEOUT_MS = 30_000 +const REPLAY_DRAIN_MS = 1_500 + +// ---------------------------- arg parsing ---------------------------- + +interface AuditArgs { + limit: number | null + concurrency: number + csvPath: string + onlyUuid: string | null + runPrompt: boolean + chatsRoot: string + verbose: boolean +} + +function parseArgs(argv: string[]): AuditArgs { + const args: AuditArgs = { + limit: null, + concurrency: 1, + csvPath: '/home/heavygee/coding/hapi/docs/plans/2026-06-08-cursor-acp-verify-audit.csv', + onlyUuid: null, + runPrompt: false, + chatsRoot: join(homedir(), '.cursor', 'chats'), + verbose: false + } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + const next = () => argv[++i] + if (a === '--limit') args.limit = Number(next()) + else if (a === '--concurrency') args.concurrency = Math.max(1, Number(next())) + else if (a === '--csv') args.csvPath = next() + else if (a === '--uuid') args.onlyUuid = next() + else if (a === '--prompt') args.runPrompt = true + else if (a === '--chats-root') args.chatsRoot = next() + else if (a === '--verbose' || a === '-v') args.verbose = true + else if (a === '--help' || a === '-h') { + console.log(`audit-cursor-acp-verify - run agent acp verify against every legacy chat + +Options: + --limit N cap chats audited (smoke test) + --concurrency N parallel verifies (default 1) + --csv PATH output CSV path + --uuid ID audit a single chat + --prompt also run a tiny session/prompt (token cost) + --chats-root PATH override ~/.cursor/chats + --verbose per-chat progress to stderr`) + process.exit(0) + } + } + return args +} + +// ---------------------------- discovery ---------------------------- + +interface ChatRecord { + wsh: string + uuid: string + storeDbPath: string + sizeBytes: number + mtimeIso: string +} + +function discoverChats(root: string): ChatRecord[] { + const out: ChatRecord[] = [] + if (!existsSync(root)) return out + for (const wsh of readdirSync(root)) { + const wshDir = join(root, wsh) + let wshStat + try { + wshStat = statSync(wshDir) + } catch { + continue + } + if (!wshStat.isDirectory()) continue + let entries: string[] + try { + entries = readdirSync(wshDir) + } catch { + continue + } + for (const uuid of entries) { + const dbPath = join(wshDir, uuid, 'store.db') + try { + const s = statSync(dbPath) + if (!s.isFile()) continue + out.push({ + wsh, + uuid, + storeDbPath: dbPath, + sizeBytes: s.size, + mtimeIso: s.mtime.toISOString() + }) + } catch { + // missing store.db, skip + } + } + } + out.sort((a, b) => (a.mtimeIso < b.mtimeIso ? 1 : -1)) + return out +} + +// ---------------------------- store sanity ---------------------------- + +function storeSanityCheck(storeDbPath: string): { ok: true } | { ok: false; message: string } { + try { + const db = new Database(storeDbPath, { readonly: true }) + try { + db.query("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").get() + return { ok: true } + } finally { + db.close() + } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) } + } +} + +// ---------------------------- minimal ACP probe ---------------------------- + +type RpcResponse = + | { ok: true; result: Record } + | { ok: false; error: { code: number; message: string; data?: unknown } } + +interface RpcNotification { + method: string + params: Record +} + +class MinimalAcpProbe { + private proc: ChildProcessWithoutNullStreams | null = null + private nextId = 0 + private buf = '' + private readonly pending = new Map void; timer: ReturnType }>() + private readonly notifications: RpcNotification[] = [] + private stderr = '' + private exited = false + + constructor(private readonly env: NodeJS.ProcessEnv, private readonly agentLookupHome: string) {} + + start(): void { + const lookupHome = this.agentLookupHome || process.env.HOME || '' + const cursorBins = lookupHome + ? [join(lookupHome, '.local', 'bin'), join(lookupHome, '.npm-global', 'bin')] + : [] + const existingPath = this.env.PATH ?? '' + const augmentedPath = [existingPath, ...cursorBins].filter(Boolean).join(pathDelimiter) + const spawnEnv = { ...this.env, PATH: augmentedPath } + const proc = spawn('agent', ['acp'], { stdio: ['pipe', 'pipe', 'pipe'], env: spawnEnv }) + this.proc = proc + proc.stdout.on('data', (b: Buffer) => this.handleStdout(b.toString('utf8'))) + proc.stderr.on('data', (b: Buffer) => { + this.stderr += b.toString('utf8') + if (this.stderr.length > 4096) this.stderr = this.stderr.slice(-4096) + }) + proc.on('error', (err) => this.failPending(err)) + proc.on('exit', () => { + this.exited = true + if (this.pending.size > 0) this.failPending(new Error('agent acp exited mid-RPC')) + }) + } + + async stop(): Promise { + const p = this.proc + this.proc = null + if (!p) return + try { + p.kill('SIGTERM') + } catch {} + if (!this.exited) { + await new Promise((resolve) => { + let done = false + const fin = () => { + if (done) return + done = true + resolve() + } + p.once('exit', fin) + p.once('close', fin) + setTimeout(fin, 5000) + }) + } + this.failPending(new Error('agent acp killed by stop()')) + } + + getStderrTail(n = 256): string { + return this.stderr.slice(-n).replace(/\s+/g, ' ').trim() + } + + initialize(timeoutMs = DEFAULT_INIT_TIMEOUT_MS): Promise { + return this.send( + 'initialize', + { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: 'hapi-cursor-acp-verify-audit', version: '1' } + }, + timeoutMs + ) + } + + async loadSession( + params: { sessionId: string; cwd: string }, + timeoutMs = DEFAULT_LOAD_TIMEOUT_MS, + replayDrainMs = REPLAY_DRAIN_MS + ): Promise<{ response: RpcResponse; notificationCount: number; durationMs: number }> { + const t0 = Date.now() + const before = this.notifications.length + const response = await this.send( + 'session/load', + { sessionId: params.sessionId, cwd: params.cwd, mcpServers: [] }, + timeoutMs + ) + if (!response.ok) { + return { response, notificationCount: 0, durationMs: Date.now() - t0 } + } + if (replayDrainMs > 0) await sleep(replayDrainMs) + return { + response, + notificationCount: this.notifications.length - before, + durationMs: Date.now() - t0 + } + } + + private send(method: string, params: unknown, timeoutMs: number): Promise { + if (!this.proc || this.exited) { + return Promise.resolve({ ok: false, error: { code: -32603, message: 'agent acp not running' } }) + } + const id = ++this.nextId + const stdin = this.proc.stdin + const req = { jsonrpc: '2.0', id, method, params } + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id) + resolve({ + ok: false, + error: { code: -32603, message: `timeout ${method} after ${timeoutMs}ms`, data: { stderr_tail: this.getStderrTail(512) } } + }) + }, timeoutMs) + this.pending.set(id, { resolve, timer }) + try { + stdin.write(`${JSON.stringify(req)}\n`) + } catch (err) { + clearTimeout(timer) + this.pending.delete(id) + resolve({ + ok: false, + error: { code: -32603, message: `stdin write failed: ${err instanceof Error ? err.message : String(err)}` } + }) + } + }) + } + + private handleStdout(chunk: string): void { + this.buf += chunk + let idx: number + while ((idx = this.buf.indexOf('\n')) !== -1) { + const line = this.buf.slice(0, idx).trim() + this.buf = this.buf.slice(idx + 1) + if (!line) continue + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + continue + } + const id = msg.id + if (typeof id === 'number' && this.pending.has(id)) { + const entry = this.pending.get(id)! + this.pending.delete(id) + clearTimeout(entry.timer) + if (msg.error && typeof msg.error === 'object') { + const err = msg.error as Record + entry.resolve({ + ok: false, + error: { + code: typeof err.code === 'number' ? err.code : -32603, + message: typeof err.message === 'string' ? err.message : 'agent acp error', + data: err.data + } + }) + } else if (msg.result && typeof msg.result === 'object') { + entry.resolve({ ok: true, result: msg.result as Record }) + } else { + entry.resolve({ ok: false, error: { code: -32603, message: 'malformed agent acp response' } }) + } + } else if (typeof msg.method === 'string' && msg.params && typeof msg.params === 'object') { + this.notifications.push({ method: msg.method as string, params: msg.params as Record }) + } + } + } + + private failPending(err: Error): void { + for (const [id, entry] of this.pending.entries()) { + clearTimeout(entry.timer) + entry.resolve({ ok: false, error: { code: -32603, message: err.message } }) + this.pending.delete(id) + } + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +// ---------------------------- per-chat verify ---------------------------- + +interface AuditOutcome { + result: + | 'ok' + | 'verify_init_failed' + | 'verify_load_failed' + | 'verify_timeout' + | 'spawn_failed' + | 'corrupted_store' + | 'probe_crash' + durationMs: number + errorTail: string +} + +async function auditOne(chat: ChatRecord, args: AuditArgs): Promise { + const t0 = Date.now() + const sanity = storeSanityCheck(chat.storeDbPath) + if (!sanity.ok) { + return { result: 'corrupted_store', durationMs: Date.now() - t0, errorTail: shorten(sanity.message) } + } + + const tmpRoot = mkdtempSync(join(tmpdir(), `hapi-acp-audit-${chat.uuid.slice(0, 8)}-`)) + const fakeAcpSessionDir = join(tmpRoot, '.cursor', 'acp-sessions', chat.uuid) + try { + mkdirSync(fakeAcpSessionDir, { recursive: true }) + copyFileSync(chat.storeDbPath, join(fakeAcpSessionDir, 'store.db')) + writeFileSync( + join(fakeAcpSessionDir, 'meta.json'), + JSON.stringify({ schemaVersion: 1, cwd: tmpRoot }) + ) + const realCursor = join(homedir(), '.cursor') + const fakeCursor = join(tmpRoot, '.cursor') + for (const f of AUTH_FILES) { + const src = join(realCursor, f) + if (existsSync(src)) { + try { copyFileSync(src, join(fakeCursor, f)) } catch {} + } + } + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tmpRoot, + HAPI_HOME: tmpRoot, + NO_COLOR: '1' + } + const probe = new MinimalAcpProbe(env, homedir()) + try { + try { + probe.start() + } catch (err) { + return { + result: 'spawn_failed', + durationMs: Date.now() - t0, + errorTail: shorten(err instanceof Error ? err.message : String(err)) + } + } + const initResp = await probe.initialize() + if (!initResp.ok) { + const isTimeout = /^timeout /.test(initResp.error.message) + return { + result: isTimeout ? 'verify_timeout' : 'verify_init_failed', + durationMs: Date.now() - t0, + errorTail: shorten(`${initResp.error.message} | stderr=${probe.getStderrTail(256)}`) + } + } + const load = await probe.loadSession({ sessionId: chat.uuid, cwd: tmpRoot }) + if (!load.response.ok) { + const isTimeout = /^timeout /.test(load.response.error.message) + return { + result: isTimeout ? 'verify_timeout' : 'verify_load_failed', + durationMs: Date.now() - t0, + errorTail: shorten(`${load.response.error.message} | stderr=${probe.getStderrTail(256)}`) + } + } + return { result: 'ok', durationMs: Date.now() - t0, errorTail: '' } + } finally { + await probe.stop() + } + } catch (err) { + return { + result: 'probe_crash', + durationMs: Date.now() - t0, + errorTail: shorten(err instanceof Error ? err.message : String(err)) + } + } finally { + try { rmSync(tmpRoot, { recursive: true, force: true }) } catch {} + } +} + +function shorten(s: string): string { + return s.replace(/\s+/g, ' ').slice(0, 400) +} + +function csvEscape(s: string): string { + if (/[,"\n]/.test(s)) return `"${s.replace(/"/g, '""')}"` + return s +} + +// ---------------------------- main ---------------------------- + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + let chats = discoverChats(args.chatsRoot) + if (args.onlyUuid) chats = chats.filter((c) => c.uuid === args.onlyUuid) + if (args.limit) chats = chats.slice(0, args.limit) + if (chats.length === 0) { + console.error('no chats discovered; nothing to audit') + process.exit(2) + } + + mkdirSync(join(args.csvPath, '..'), { recursive: true }) + writeFileSync(args.csvPath, 'wsh,uuid,store_size_bytes,store_mtime_iso,result,duration_ms,error_tail\n') + + const summary: Record = {} + const startedAt = Date.now() + let done = 0 + + const queue = [...chats] + const inflight: Promise[] = [] + const next = (): Promise | null => { + const c = queue.shift() + if (!c) return null + return (async () => { + const outcome = await auditOne(c, args) + summary[outcome.result] = (summary[outcome.result] ?? 0) + 1 + const row = [ + c.wsh, + c.uuid, + String(c.sizeBytes), + c.mtimeIso, + outcome.result, + String(outcome.durationMs), + csvEscape(outcome.errorTail) + ].join(',') + appendFileSync(args.csvPath, row + '\n') + done += 1 + if (args.verbose || done % 20 === 0 || done === chats.length) { + const pct = Math.round((done / chats.length) * 100) + const elapsedSec = Math.round((Date.now() - startedAt) / 1000) + console.error( + `[${done}/${chats.length} ${pct}%] ${elapsedSec}s elapsed | ${outcome.result.padEnd(20)} ${c.uuid} (${formatBytes(c.sizeBytes)}, ${outcome.durationMs}ms)` + ) + } + })() + } + for (let i = 0; i < args.concurrency; i++) { + const p = next() + if (p) inflight.push(p.then(async function loop(): Promise { + const n = next() + if (n) await n.then(loop) + })) + } + await Promise.all(inflight) + + const total = chats.length + const okCount = summary.ok ?? 0 + const passRate = total > 0 ? (okCount / total) * 100 : 0 + console.error('\n=== AUDIT SUMMARY ===') + console.error(`total: ${total}`) + for (const [k, v] of Object.entries(summary).sort((a, b) => b[1] - a[1])) { + console.error(` ${k.padEnd(22)} ${v} (${((v / total) * 100).toFixed(1)}%)`) + } + console.error(`PASS RATE: ${passRate.toFixed(1)}%`) + console.error(`elapsed: ${Math.round((Date.now() - startedAt) / 1000)}s`) + console.error(`csv: ${args.csvPath}`) + + process.exit(passRate >= 90 ? 0 : 1) +} + +function formatBytes(b: number): string { + if (b < 1024) return `${b}B` + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB` + return `${(b / 1024 / 1024).toFixed(1)}MB` +} + +main().catch((err) => { + console.error('audit fatal:', err) + process.exit(2) +}) diff --git a/scripts/dev/mermaid-lightbox-live-playwright.mjs b/scripts/dev/mermaid-lightbox-live-playwright.mjs new file mode 100644 index 0000000000..e137029cd1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-live-playwright.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** Bounded wrapper: Playwright against a real HAPI chat session (no Vite). */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:live'], + { + cwd: REPO_ROOT, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-playwright.mjs b/scripts/dev/mermaid-lightbox-playwright.mjs new file mode 100644 index 0000000000..6914ef73e1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-playwright.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Bounded wrapper for mermaid lightbox Playwright (web/e2e). + * Vite lifecycle is owned by web/playwright.config.ts webServer — not this process. + * + * Usage (from repo root): + * npm run test:mermaid-lightbox:playwright + */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:e2e'], + { + cwd: WEB_DIR, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-seed-session-db.ts b/scripts/dev/mermaid-lightbox-seed-session-db.ts new file mode 100644 index 0000000000..8b2fa9181b --- /dev/null +++ b/scripts/dev/mermaid-lightbox-seed-session-db.ts @@ -0,0 +1,85 @@ +/** + * Seed assistant messages with mermaid fixtures into a hub SQLite DB. + * Run on the host that owns HAPI_DB_PATH (usually the hub machine). + * + * HAPI_DB_PATH=~/.hapi/hapi.db SESSION_ID= bun run scripts/dev/mermaid-lightbox-seed-session-db.ts + */ +import { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, +} from '../../web/src/dev/mermaid-lightbox-cases' + +const dbPath = process.env.HAPI_DB_PATH ?? join(homedir(), '.hapi', 'hapi.db') +/** Stable id for mermaid Playwright live session (create if missing). */ +const sessionId = process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +const sessionTag = 'mermaid-lightbox-e2e' + +function agentMermaidEnvelope(caseId: string, code: string) { + const text = `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`` + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: randomUUID(), + parentUuid: null, + isSidechain: false, + message: { + content: [{ type: 'text', text }], + }, + }, + }, + } +} + +const db = new Database(dbPath) +const now = Date.now() +const existing = db.prepare('SELECT id, tag FROM sessions WHERE id = ?').get(sessionId) as + | { id: string; tag: string | null } + | undefined + +if (existing && existing.tag !== sessionTag) { + throw new Error( + `Refusing to seed mermaid fixtures into session ${sessionId}: tag is ` + + `${JSON.stringify(existing.tag)}, expected ${JSON.stringify(sessionTag)}. ` + + `Unset SESSION_ID or use a session created by this script.`, + ) +} + +if (!existing) { + db.prepare(` + INSERT INTO sessions ( + id, tag, namespace, created_at, updated_at, active, seq + ) VALUES (?, ?, 'default', ?, ?, 0, 0) + `).run(sessionId, sessionTag, now, now) + console.log(`created session ${sessionId} (${sessionTag})`) +} + +const insert = db.prepare(` + INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, NULL) +`) + +db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId) + +let seqRow = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?').get(sessionId) as { + maxSeq: number +} + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + const code = MERMAID_LIGHTBOX_CASES[caseId] + const envelope = agentMermaidEnvelope(caseId, code) + const seq = (seqRow.maxSeq ?? 0) + 1 + seqRow = { maxSeq: seq } + const messageId = randomUUID() + insert.run(messageId, sessionId, JSON.stringify(envelope), now, seq, now) + console.log(`seeded ${caseId} @ seq ${seq}`) +} + +db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId) +console.log(`Done. Open: /sessions/${sessionId}`) diff --git a/scripts/tooling/hapi-driver-db-prep.sh b/scripts/tooling/hapi-driver-db-prep.sh new file mode 100755 index 0000000000..40f491cead --- /dev/null +++ b/scripts/tooling/hapi-driver-db-prep.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Prepare ~/.hapi/hapi.db for activation of . +# +# Operator-fork soup helper (not used by upstream CI). See driver-soup.md. +# +# Known downgrade transitions (extend as new schema-bumping layers are added): +# v10 -> v9 : DROP fcm_devices + indexes +# v11 -> v10 : DROP soup v11 tables (fcm_devices, session_scratchlist) + PRAGMA 10 +# +# Overseer events tables are NOT SCHEMA_VERSION-gated. When swinging away from +# feat/overseer-events-substrate only (staying on soup v11), run drop-overseer-events. + +set -euo pipefail + +DRY_RUN=0 +TARGET="" +DROP_OVERSEER_EVENTS=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --drop-overseer-events) DROP_OVERSEER_EVENTS=1; shift ;; + -h|--help) + sed -n '2,14p' "$0" + echo " --drop-overseer-events drop events/event_links/FTS only (no user_version change)" + exit 0 + ;; + *) + if [[ -z "$TARGET" ]]; then TARGET="$1"; shift + else echo "Unexpected arg: $1" >&2; exit 2; fi + ;; + esac +done + +[[ -n "$TARGET" ]] || { echo "Usage: hapi-driver-db-prep.sh [--drop-overseer-events] " >&2; exit 2; } +TARGET="$(realpath "$TARGET")" + +PRIMARY="${HAPI_PRIMARY:-$HOME/coding/hapi}" +DB_PATH="${HAPI_DB_PATH:-$HOME/.hapi/hapi.db}" + +[[ -f "$DB_PATH" ]] || { echo "ERROR: DB not found at $DB_PATH" >&2; exit 1; } +[[ -f "$TARGET/hub/src/store/index.ts" ]] || { + echo "ERROR: target $TARGET missing hub/src/store/index.ts" >&2 + exit 1 +} + +extract_schema_version() { + grep -E "^const SCHEMA_VERSION:\s*number\s*=\s*[0-9]+" "$1" \ + | head -1 \ + | grep -oE "[0-9]+$" +} + +target_schema="$(extract_schema_version "$TARGET/hub/src/store/index.ts" || true)" +[[ -n "$target_schema" ]] || { echo "ERROR: could not parse SCHEMA_VERSION in $TARGET" >&2; exit 1; } + +base_schema="$(git -C "$PRIMARY" show upstream/main:hub/src/store/index.ts 2>/dev/null \ + | grep -E "^const SCHEMA_VERSION:\s*number\s*=\s*[0-9]+" | head -1 | grep -oE "[0-9]+$" || true)" +base_schema="${base_schema:-?}" + +live_schema="$(sqlite3 "$DB_PATH" "PRAGMA user_version;" 2>/dev/null || true)" +[[ -n "$live_schema" ]] || { echo "ERROR: could not read PRAGMA user_version from $DB_PATH" >&2; exit 1; } + +echo "hapi-driver-db-prep:" +echo " target_schema = $target_schema (from $TARGET/hub/src/store/index.ts)" +echo " base_schema = $base_schema (from upstream/main)" +echo " live_schema = $live_schema (from $DB_PATH)" + +if [[ "$DROP_OVERSEER_EVENTS" -eq 1 ]]; then + decision="drop-overseer-events -- remove events substrate only (user_version unchanged)" +elif [[ "$live_schema" -eq "$target_schema" ]]; then + decision="match -- no migration needed" +elif [[ "$live_schema" -lt "$target_schema" ]]; then + decision="forward -- hub will auto-migrate $live_schema -> $target_schema via stepMigrations on boot" +else + decision="downgrade -- need to step DB back from $live_schema down to $target_schema" +fi +echo " decision: $decision" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo " (dry-run; exiting)" + exit 0 +fi + +if systemctl is-active --quiet hapi-hub.service; then + echo "ERROR: hapi-hub.service is active. Stop it first:" >&2 + echo " sudo systemctl stop hapi-hub.service" >&2 + exit 1 +fi + +if [[ "${HAPI_DB_PREP_NO_BACKUP:-}" != "1" ]]; then + BACKUP="${DB_PATH}.bak.pre-activate-$(date -u +%Y%m%d-%H%M%SZ)" + echo " backup: $DB_PATH -> $BACKUP" + cp -a "$DB_PATH" "$BACKUP" +fi + +apply_drop_overseer_events() { + echo " dropping Overseer events tables (no user_version change)" + sqlite3 "$DB_PATH" <<'SQL' +BEGIN IMMEDIATE; +DROP TRIGGER IF EXISTS events_fts_delete; +DROP TRIGGER IF EXISTS events_fts_update; +DROP TRIGGER IF EXISTS events_fts_insert; +DROP TABLE IF EXISTS events_fts; +DROP INDEX IF EXISTS idx_events_idempotency_key; +DROP INDEX IF EXISTS idx_event_links_to; +DROP INDEX IF EXISTS idx_event_links_from; +DROP TABLE IF EXISTS event_links; +DROP INDEX IF EXISTS idx_events_dedupe_key; +DROP INDEX IF EXISTS idx_events_type_ts; +DROP INDEX IF EXISTS idx_events_session_ts; +DROP TABLE IF EXISTS events; +DROP INDEX IF EXISTS idx_deleted_sessions_deleted_at; +DROP TABLE IF EXISTS deleted_sessions; +COMMIT; +SQL +} + +apply_downgrade_step() { + local from="$1" to="$2" + case "${from}_to_${to}" in + 10_to_9) + echo " applying v10 -> v9 downgrade: DROP fcm_devices + indexes" + sqlite3 "$DB_PATH" <<'SQL' +BEGIN IMMEDIATE; +DROP INDEX IF EXISTS idx_fcm_devices_token; +DROP INDEX IF EXISTS idx_fcm_devices_namespace; +DROP TABLE IF EXISTS fcm_devices; +PRAGMA user_version = 9; +COMMIT; +SQL + ;; + 11_to_10) + echo " applying v11 -> v10 downgrade: DROP soup v11 (fcm + scratchlist + overseer events)" + sqlite3 "$DB_PATH" <<'SQL' +BEGIN IMMEDIATE; +DROP TRIGGER IF EXISTS events_fts_delete; +DROP TRIGGER IF EXISTS events_fts_update; +DROP TRIGGER IF EXISTS events_fts_insert; +DROP TABLE IF EXISTS events_fts; +DROP INDEX IF EXISTS idx_events_idempotency_key; +DROP INDEX IF EXISTS idx_event_links_to; +DROP INDEX IF EXISTS idx_event_links_from; +DROP TABLE IF EXISTS event_links; +DROP INDEX IF EXISTS idx_events_dedupe_key; +DROP INDEX IF EXISTS idx_events_type_ts; +DROP INDEX IF EXISTS idx_events_session_ts; +DROP TABLE IF EXISTS events; +DROP INDEX IF EXISTS idx_deleted_sessions_deleted_at; +DROP TABLE IF EXISTS deleted_sessions; +DROP INDEX IF EXISTS idx_session_scratchlist_session_created; +DROP TABLE IF EXISTS session_scratchlist; +DROP INDEX IF EXISTS idx_fcm_devices_token; +DROP INDEX IF EXISTS idx_fcm_devices_namespace; +DROP TABLE IF EXISTS fcm_devices; +PRAGMA user_version = 10; +COMMIT; +SQL + ;; + *) + echo "ERROR: no known downgrade for v${from} -> v${to}" >&2 + echo " Add a case to apply_downgrade_step() in $0" >&2 + echo " OR restore from a v${to} backup manually" >&2 + return 1 + ;; + esac +} + +if [[ "$DROP_OVERSEER_EVENTS" -eq 1 ]]; then + apply_drop_overseer_events || exit 1 + echo " overseer events dropped; user_version still $(sqlite3 "$DB_PATH" "PRAGMA user_version;")" + echo " db-prep complete; safe to start hub on $TARGET" + exit 0 +fi + +if [[ "$live_schema" -gt "$target_schema" ]]; then + cur="$live_schema" + while [[ "$cur" -gt "$target_schema" ]]; do + prev=$((cur - 1)) + apply_downgrade_step "$cur" "$prev" || exit 1 + cur="$prev" + done + new_live="$(sqlite3 "$DB_PATH" "PRAGMA user_version;")" + if [[ "$new_live" -ne "$target_schema" ]]; then + echo "ERROR: downgrade left DB at v${new_live}, expected v${target_schema}" >&2 + exit 1 + fi + echo " downgrade done: DB now at v${new_live}" + sqlite3 "$DB_PATH" "VACUUM;" +fi + +echo " db-prep complete; safe to start hub on $TARGET" diff --git a/shared/src/agentBudget.ts b/shared/src/agentBudget.ts new file mode 100644 index 0000000000..3b06ab5608 --- /dev/null +++ b/shared/src/agentBudget.ts @@ -0,0 +1,112 @@ +// Cross-flavor agent budget gauge shape. Seeded by the Codex usage +// indicator (tiann/hapi#537 rebase) and intended to grow to cover the +// matching axes for Claude (5h subscription window + context), +// Cursor (premium request quota + context, gated on telemetry exposure), +// and Gemini (RPM/RPD + context) under umbrella tiann/hapi#846. +// +// The shape is deliberately flavor-agnostic: each agent flavor implements +// an adapter (toCodexBudgetState, toClaudeBudgetState, ...) that maps its +// provider-specific usage payload into this normalized form. The UI +// consumes only AgentBudgetState - it knows nothing about codex credits +// or claude rate-limit headers. +// +// Design rationale (operator review 2026-06-09): +// - One ring centre number = "how much room for THIS task" (operational +// axis, usually context window). Stays consistent regardless of +// account-level state so the gauge does not silently change meaning. +// - Ring colour = "are you about to be blocked" (effective state across +// ALL axes, computed by the flavor adapter which knows the specific +// blocking semantics, e.g. 'Codex Pro credits cover an exhausted +// subscription window so weekly=100% is amber, not red, while +// credits>0'). +// - Popover = full axis breakdown with the dominant axis marked. + +export type AgentBudgetAxisId = + | 'context' + | 'fiveHour' + | 'weekly' + | 'credits' + // Flavor-specific axes (e.g. 'cursorPremiumRequests', 'geminiRpm') + // are permitted. `string & {}` preserves IDE completions for the + // well-known ids while still accepting arbitrary strings at compile + // time (plain `string` would collapse the union and lose completions). + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {}) + +export type AgentBudgetEffectiveState = + // All axes well under their caps - safe to keep working. + | 'green' + // Approaching a cap on at least one axis, or covering-axis scenario + // (e.g. subscription window at 100% but credits available). User + // should be aware but is not blocked. + | 'amber' + // Very close to a cap on at least one axis; further work may hit + // the limit imminently. + | 'red' + // Hard block - no axis has remaining capacity, and there is no + // covering axis. The agent cannot proceed. + | 'blocked' + // No telemetry available for this flavor / account. Adapter returns + // null in this case; the indicator hides entirely rather than + // surfacing a false 'green'. + | 'unknown' + +export type AgentBudgetAxis = { + id: AgentBudgetAxisId + label: string + // 0-100; how close to the cap this axis is. For credit-balance axes + // where there is no declared capacity, the adapter chooses a + // pragmatic mapping (e.g. 0 when has-balance, 100 when zero) and + // sets covering=true to signal the axis is a fallback rather than + // a primary constraint. + pressure: number + // Pre-formatted display value (e.g. '21%', '250', '100% used'). + // The renderer should not re-derive this from pressure. + valueText: string + // Optional supplemental string for the popover (e.g. + // '54k / 258k tokens', 'resets Apr 27, 1:00 PM'). + detail?: string + // True when this axis is covering for another exhausted axis + // (e.g. credits remaining substituting for an exhausted Codex Pro + // weekly window). The popover highlights covering axes so the user + // understands why the effective state is amber rather than red. + covering?: boolean + // Flagged as critical by the adapter (e.g. hard block on this axis). + // The renderer paints critical-severity rows in red. + critical?: boolean +} + +// Non-pressure informational rows (e.g. token breakdown, last-turn +// usage). These render in the popover after the pressure axes but +// do not influence the ring centre, colour, or effective state. +export type AgentBudgetMetadataRow = { + label: string + value: string + detail?: string +} + +export type AgentBudgetState = { + // Which axis to display as the always-visible ring centre number. + // Defaults to 'context' for LLM agents because that is the + // operationally relevant axis during active composition. + operationalAxisId: AgentBudgetAxisId + // All axes the adapter could populate. Order is significant: the + // popover renders axes top-to-bottom in this order. + axes: AgentBudgetAxis[] + // Worst-case state across all axes, computed by the flavor adapter + // using its specific blocking semantics. The renderer uses this + // to colour the ring (not the operational-axis pressure alone). + effective: AgentBudgetEffectiveState + // Human-readable explanation of why the effective state is what + // it is. Used as the ring's title / aria-label so hovering tells + // the user 'Weekly window at cap; credits covering overage' + // instead of just '21%'. + effectiveReason: string + // Which axis (if any) is currently the highest-pressure point. + // The popover marks this row with a left-accent so the user can + // see at a glance why the effective state landed where it did. + dominantAxisId?: AgentBudgetAxisId + // Non-pressure informational rows the flavor wants to surface + // (e.g. token-by-bucket breakdown for Codex). + metadata?: AgentBudgetMetadataRow[] +} diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index e6381da723..581b0d2b46 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -163,6 +163,58 @@ export const RenameSessionRequestSchema = z.object({ export type RenameSessionRequest = z.infer +/** + * Scratchlist v2 (tiann/hapi#893) per-entry caps. + * + * `MAX_ENTRIES` (200) is a per-session ceiling: refuses to create entry + * 201 on the hub. Mirrors `SCRATCHLIST_MAX_ENTRIES` in + * `web/src/lib/scratchlist.ts` so the hub and web agree on the limit - + * the web side has UX for the cap (disabled add button + atCap hint), + * the hub side enforces it as a server-side guard against malicious / + * runaway clients writing arbitrary amounts. + * + * `MAX_TEXT_LENGTH` (10_000) is the per-entry text cap. Mirrors + * `SCRATCHLIST_MAX_TEXT_LENGTH`. The web side truncates rather than + * rejects; the hub-side schema allows up to this length and rejects + * anything longer with 400. + */ +export const SCRATCHLIST_MAX_ENTRIES = 200 +export const SCRATCHLIST_MAX_TEXT_LENGTH = 10_000 +/** + * Hard cap on client-supplied entry id length. The id is persisted as + * part of the SQLite primary key, so without a bound an authenticated + * client could grow the table and its index with arbitrarily large + * keys. 128 chars comfortably fits a UUID (36) plus any prefix scheme + * we might layer on later. + */ +export const SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128 + +export const ScratchlistEntryCreateRequestSchema = z.object({ + /** + * Optional client-supplied entry id. Lets the web client preserve its + * pre-v2 localStorage entry ids during migration so the optimistic- + * update path doesn't have to re-key entries already in the React + * tree. New entries created post-v2 can omit this and let the hub + * generate one. + */ + entryId: z.string().min(1).max(SCRATCHLIST_MAX_ENTRY_ID_LENGTH).optional(), + text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH), + /** + * Optional client-supplied createdAt. Used by the migration path to + * preserve the original timestamps from localStorage. New entries + * omit this and let the hub stamp `Date.now()`. + */ + createdAt: z.number().int().nonnegative().optional() +}) + +export type ScratchlistEntryCreateRequest = z.infer + +export const ScratchlistEntryUpdateRequestSchema = z.object({ + text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH) +}) + +export type ScratchlistEntryUpdateRequest = z.infer + /** Per-session legacy stream-json → ACP migrator request. See tiann/hapi#824. */ export const CursorMigrateToAcpRequestSchema = z.object({ /** Skip removing the legacy ~/.cursor/chats source store.db even after verify passes. */ @@ -250,7 +302,9 @@ export const SpawnSessionRequestSchema = z.object({ modelReasoningEffort: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), - worktreeName: z.string().optional() + worktreeName: z.string().optional(), + resumeSessionId: z.string().optional(), + importHistory: z.boolean().optional() }) export type SpawnSessionRequest = z.infer diff --git a/shared/src/codexUsageSchema.test.ts b/shared/src/codexUsageSchema.test.ts new file mode 100644 index 0000000000..0ee1078ffb --- /dev/null +++ b/shared/src/codexUsageSchema.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { MetadataSchema } from './schemas' + +describe('MetadataSchema codexUsage', () => { + it('accepts structured Codex usage metadata', () => { + const parsed = MetadataSchema.safeParse({ + path: '/repo', + host: 'machine', + flavor: 'codex', + codexUsage: { + contextWindow: { + usedTokens: 2000, + limitTokens: 100_000, + percent: 2, + updatedAt: 1 + }, + rateLimits: { + fiveHour: { + usedPercent: 25, + windowMinutes: 300, + resetAt: 2 + } + }, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + }) + + expect(parsed.success).toBe(true) + expect(parsed.success ? parsed.data.codexUsage : undefined).toMatchObject({ + contextWindow: { + usedTokens: 2000, + limitTokens: 100_000, + percent: 2 + } + }) + }) + + it('accepts premium-credits Codex metadata (credits + reached-type + plan fields)', () => { + const parsed = MetadataSchema.safeParse({ + path: '/repo', + host: 'machine', + flavor: 'codex', + codexUsage: { + contextWindow: { + usedTokens: 206_000, + limitTokens: 258_400, + percent: 80, + updatedAt: 1 + }, + rateLimits: {}, + credits: { hasCredits: false, unlimited: false, balance: '0' }, + rateLimitReachedType: 'weekly', + planType: 'pro', + limitId: 'premium' + } + }) + + expect(parsed.success).toBe(true) + const codexUsage = parsed.success ? parsed.data.codexUsage : undefined + expect(codexUsage?.credits).toEqual({ hasCredits: false, unlimited: false, balance: '0' }) + expect(codexUsage?.rateLimitReachedType).toBe('weekly') + expect(codexUsage?.planType).toBe('pro') + expect(codexUsage?.limitId).toBe('premium') + }) +}) diff --git a/shared/src/index.ts b/shared/src/index.ts index 0aaf85f2aa..2cb0ecd18d 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,6 +1,10 @@ +export * from './agentBudget' export * from './apiTypes' export * from './cursorCliSku' export * from './messages' +export * from './overseerEvents' +export * from './overseerInbox' +export * from './overseerEntity' export * from './buildInfo' export * from './effort' export * from './flavors' diff --git a/shared/src/messages.test.ts b/shared/src/messages.test.ts new file mode 100644 index 0000000000..80df94903c --- /dev/null +++ b/shared/src/messages.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from 'bun:test' +import { + collapseRepeats, + extractAssistantPlainText, + extractNotifySummary, + isRedundantGoalStatusEventContent, + matchNotifySummaryLine, + NOTIFY_SUMMARY_TOKEN, + type NotifySummary +} from './messages' + +describe('extractAssistantPlainText', () => { + test('returns null for non-objects', () => { + expect(extractAssistantPlainText(null)).toBeNull() + expect(extractAssistantPlainText(undefined)).toBeNull() + expect(extractAssistantPlainText('string')).toBeNull() + expect(extractAssistantPlainText(42)).toBeNull() + }) + + test('extracts codex/message text', () => { + const content = { + type: 'codex', + data: { + type: 'message', + message: 'Hello there.' + } + } + expect(extractAssistantPlainText(content)).toBe('Hello there.') + }) + + test('returns null for codex/tool-call (no text)', () => { + const content = { + type: 'codex', + data: { + type: 'tool-call', + name: 'Edit', + callId: 'x', + input: {} + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null for codex/tool-call-result (no text)', () => { + const content = { + type: 'codex', + data: { + type: 'tool-call-result', + output: {} + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null when codex/message string is empty', () => { + const content = { type: 'codex', data: { type: 'message', message: '' } } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('extracts output/assistant text from claude SDK content array', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Line one.' }, + { type: 'tool_use', name: 'Edit' }, + { type: 'text', text: 'Line two.' } + ] + } + } + } + expect(extractAssistantPlainText(content)).toBe('Line one.\nLine two.') + }) + + test('returns null for output/assistant with no text blocks', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { content: [{ type: 'tool_use', name: 'Edit' }] } + } + } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null for output/user (not assistant)', () => { + const content = { type: 'output', data: { type: 'user', message: { content: [] } } } + expect(extractAssistantPlainText(content)).toBeNull() + }) + + test('returns null for unknown content shapes', () => { + expect(extractAssistantPlainText({ type: 'event', data: {} })).toBeNull() + expect(extractAssistantPlainText({ type: 'text' })).toBeNull() + }) +}) + +describe('extractNotifySummary', () => { + const FULL_LINE = 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"hapi-monitor agent","project":"hapi-monitor","status":"done","action":"Revoke tokens","summary":"Published v0.1.0"}' + + test('returns null on non-string input', () => { + expect(extractNotifySummary(null)).toBeNull() + expect(extractNotifySummary(undefined)).toBeNull() + expect(extractNotifySummary({})).toBeNull() + expect(extractNotifySummary(42)).toBeNull() + expect(extractNotifySummary('')).toBeNull() + }) + + test('parses a summary on its own line at the very end', () => { + const result = extractNotifySummary(FULL_LINE) + expect(result).not.toBeNull() + const r = result as NotifySummary + expect(r.version).toBe(1) + expect(r.agent).toBe('hapi-monitor agent') + expect(r.project).toBe('hapi-monitor') + expect(r.status).toBe('done') + expect(r.action).toBe('Revoke tokens') + expect(r.summary).toBe('Published v0.1.0') + }) + + test('parses summary as last non-empty line after preceding prose', () => { + const text = `Here is what I did.\n\nThings worked.\n\n${FULL_LINE}` + const r = extractNotifySummary(text) + expect(r?.summary).toBe('Published v0.1.0') + }) + + test('tolerates trailing whitespace and blank lines', () => { + const r = extractNotifySummary(`prose\n\n${FULL_LINE}\n\n \n`) + expect(r?.summary).toBe('Published v0.1.0') + }) + + test('returns null when summary is not on the LAST non-empty line', () => { + // Operator wrote prose AFTER the line - non-compliant. + const text = `${FULL_LINE}\nOh, one more thing.` + expect(extractNotifySummary(text)).toBeNull() + }) + + test('returns null when prefix is missing', () => { + expect(extractNotifySummary('NOTIFY_SUMMARY {"summary":"x"}')).toBeNull() + expect(extractNotifySummary('agent_notify_summary {"summary":"x"}')).toBeNull() + }) + + test('returns null when JSON is malformed', () => { + expect(extractNotifySummary('AGENT_NOTIFY_SUMMARY {bogus}')).toBeNull() + expect(extractNotifySummary('AGENT_NOTIFY_SUMMARY {"summary":')).toBeNull() + expect(extractNotifySummary('AGENT_NOTIFY_SUMMARY not-json')).toBeNull() + }) + + test('drops fields with wrong types but keeps valid ones', () => { + const text = 'AGENT_NOTIFY_SUMMARY {"version":"oops","summary":"x","action":42,"status":"done"}' + const r = extractNotifySummary(text) + expect(r?.summary).toBe('x') + expect(r?.status).toBe('done') + expect(r?.version).toBeUndefined() + expect(r?.action).toBeUndefined() + }) + + test('ignores in-message quotes of the line - only the LAST line is parsed', () => { + // This very test message contains the literal prefix in a quoted explanation, + // but the trailing line is plain prose, so we return null. + const text = `Earlier I described the format as 'AGENT_NOTIFY_SUMMARY {...}', but here is plain text.` + expect(extractNotifySummary(text)).toBeNull() + }) + + test('returns null for whitespace-only input', () => { + expect(extractNotifySummary(' \n\n ')).toBeNull() + }) + + test('handles JSON with internal braces (escaped within strings)', () => { + const text = 'AGENT_NOTIFY_SUMMARY {"summary":"thing {nested} thing","status":"done"}' + const r = extractNotifySummary(text) + expect(r?.summary).toBe('thing {nested} thing') + expect(r?.status).toBe('done') + }) +}) + +describe('extractNotifySummary + extractAssistantPlainText (integration)', () => { + test('codex assistant text containing a trailing summary line', () => { + const content = { + type: 'codex', + data: { + type: 'message', + message: 'Did the work.\n\nAGENT_NOTIFY_SUMMARY {"summary":"Done","status":"done"}' + } + } + const text = extractAssistantPlainText(content) + expect(text).not.toBeNull() + const r = extractNotifySummary(text!) + expect(r?.summary).toBe('Done') + }) + + test('claude SDK output with summary in the last text block', () => { + const content = { + type: 'output', + data: { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Quick update.' }, + { type: 'text', text: 'AGENT_NOTIFY_SUMMARY {"summary":"All checks green","status":"done","action":"Merge PR"}' } + ] + } + } + } + const text = extractAssistantPlainText(content) + const r = extractNotifySummary(text!) + expect(r?.summary).toBe('All checks green') + expect(r?.action).toBe('Merge PR') + }) +}) + +describe('collapseRepeats (corruption normalizer)', () => { + test('collapses runs of a repeated character to one', () => { + expect(collapseRepeats('SUMMARY')).toBe('SUMARY') + expect(collapseRepeats('aaa-bb-c')).toBe('a-b-c') + expect(collapseRepeats('')).toBe('') + expect(collapseRepeats('abc')).toBe('abc') + }) + + test('the correct token and the Cursor dup-drop collapse to the same form', () => { + // AGENT_NOTIFY_SUMMARY -> AGENT_NOTIFY_SUMARY (drop one M); + // both must normalize equal so a corrupted line still matches. + expect(collapseRepeats(NOTIFY_SUMMARY_TOKEN)).toBe(collapseRepeats('AGENT_NOTIFY_SUMARY')) + }) +}) + +describe('matchNotifySummaryLine (corruption-tolerant detector)', () => { + test('matches the correct token line and returns the JSON', () => { + expect(matchNotifySummaryLine('AGENT_NOTIFY_SUMMARY {"status":"done"}')).toBe('{"status":"done"}') + }) + + test('matches the Cursor dup-dropped token (SUMARY)', () => { + expect(matchNotifySummaryLine('AGENT_NOTIFY_SUMARY {"status":"done"}')).toBe('{"status":"done"}') + }) + + test('tolerates trailing/leading whitespace on the line', () => { + expect(matchNotifySummaryLine(' AGENT_NOTIFY_SUMMARY {"a":1} ')).toBe('{"a":1}') + }) + + test('rejects a token embedded in prose (not a bare token)', () => { + expect(matchNotifySummaryLine('see the AGENT_NOTIFY_SUMMARY {"x":1}')).toBeNull() + }) + + test('rejects lines with no JSON or no leading token', () => { + expect(matchNotifySummaryLine('AGENT_NOTIFY_SUMMARY not json')).toBeNull() + expect(matchNotifySummaryLine('{"x":1}')).toBeNull() + expect(matchNotifySummaryLine('')).toBeNull() + }) + + test('rejects a non-dup deletion (NOTFY) - acceptable residual loss', () => { + // Non-adjacent-duplicate corruption is not recoverable by collapse-norm; + // observed rate in the wild is ~2 in 5800. Documented as acceptable. + expect(matchNotifySummaryLine('AGENT_NOTFY_SUMMARY {"x":1}')).toBeNull() + }) +}) + +describe('extractNotifySummary (corruption-tolerant end-to-end)', () => { + test('parses a corrupted (SUMARY) trailing line into a summary object', () => { + const text = 'Did the thing.\n\nAGENT_NOTIFY_SUMARY {"version":1,"status":"blocked","summary":"needs key"}' + const r = extractNotifySummary(text) + expect(r?.status).toBe('blocked') + expect(r?.summary).toBe('needs key') + }) +}) + +describe('isRedundantGoalStatusEventContent (regression-guard for messages.ts edits)', () => { + test('still detects goal-active events', () => { + const value = { + role: 'agent', + content: { + type: 'event', + data: { type: 'message', message: 'Goal active · build the thing' } + } + } + expect(isRedundantGoalStatusEventContent(value)).toBe(true) + }) +}) diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 7b65149d1a..65ddfcd4f5 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -70,4 +70,141 @@ export function isRedundantGoalStatusEventContent(value: unknown): boolean { return isRedundantGoalStatusMessageText(data.message) } +/** + * Best-effort plain-text extraction from a stored agent message's `content`. + * + * Two structural shapes are common in this fork: + * + * 1. `codex` flavor: content.type = 'codex', content.data.type = 'message' + * -> assistant text at `content.data.message` (string). + * + * 2. `output` flavor (Claude SDK passthrough): content.type = 'output', + * content.data.type = 'assistant' -> text at + * `content.data.message.content[i].text` (array of `{type:'text', text}`). + * + * Returns `null` when the content does not look like assistant *text* + * (tool calls, tool results, reasoning, token counts, etc.) so callers can + * skip those messages and fall back to the previous one. + */ +export function extractAssistantPlainText(content: unknown): string | null { + if (!isObject(content)) return null + + if (content.type === 'codex') { + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'message') return null + return typeof data.message === 'string' && data.message.length > 0 + ? data.message + : null + } + + if (content.type === 'output') { + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'assistant') return null + const message = isObject(data.message) ? data.message : null + const blocks = Array.isArray(message?.content) ? message.content : null + if (!blocks) return null + const textParts: string[] = [] + for (const block of blocks) { + if (!isObject(block)) continue + if (block.type === 'text' && typeof block.text === 'string') { + textParts.push(block.text) + } + } + if (textParts.length === 0) return null + return textParts.join('\n') + } + + return null +} + +export const NOTIFY_SUMMARY_TOKEN = 'AGENT_NOTIFY_SUMMARY' + +export type NotifySummary = { + version?: number + agent?: string + project?: string + status?: string + action?: string + summary?: string +} + +/** + * Collapse every run of a repeated character down to a single instance + * (`"SUMMARY"` -> `"SUMARY"`, `"aaa-bb"` -> `"a-b"`). + * + * This is the corruption-normalizer for the notify contract. Observed in + * the wild (354k stored messages): Cursor drops one of a doubled letter in + * roughly 1 of 7 turns, mangling `AGENT_NOTIFY_SUMMARY` -> `AGENT_NOTIFY_SUMARY` + * (Claude/Codex: 0 occurrences). Matching on the collapse-normalized form + * makes the correct and dup-dropped tokens compare equal, so a corrupted + * line is still detected (and therefore both stripped from the human view + * and parsed into an inbox event) instead of double-failing. + */ +export function collapseRepeats(value: string): string { + return value.replace(/(.)\1+/g, '$1') +} + +const NOTIFY_SUMMARY_TOKEN_NORM = collapseRepeats(NOTIFY_SUMMARY_TOKEN) + +/** + * If `line` is a notify-summary line, return the raw JSON substring `{...}`; + * otherwise `null`. `line` is expected to already be the last non-empty line + * of a message (callers enforce the end-anchor). + * + * Corruption-tolerant: the leading token is matched by collapse-normalized + * equality (see `collapseRepeats`), so Cursor's `SUMMARY`->`SUMARY` dup-drop + * still matches. The JSON must start with `{` and end with `}`; a token that + * is embedded in prose (`"see the AGENT_NOTIFY_SUMMARY {..}"`) fails because + * its collapse-normalized prefix will not equal the bare token. + */ +export function matchNotifySummaryLine(line: string): string | null { + const trimmed = line.trim() + const braceIdx = trimmed.indexOf('{') + if (braceIdx <= 0) return null + const token = trimmed.slice(0, braceIdx).trim() + if (!token || collapseRepeats(token) !== NOTIFY_SUMMARY_TOKEN_NORM) return null + const jsonPart = trimmed.slice(braceIdx).trim() + if (!jsonPart.startsWith('{') || !jsonPart.endsWith('}')) return null + return jsonPart +} + +/** + * Look for an `AGENT_NOTIFY_SUMMARY {...json...}` line as the **last + * non-empty line** of an agent's plain-text message. + * + * Strict end-anchor: trailing whitespace-only lines are fine, but prose + * AFTER the JSON line is treated as non-compliant. This also makes a + * `AGENT_NOTIFY_SUMMARY` quoted inside an earlier paragraph harmless, + * because such a quote is never the last line. + * + * The token match is corruption-tolerant (see `matchNotifySummaryLine`). + * Returns the parsed object on success, `null` on any deviation. + */ +export function extractNotifySummary(text: unknown): NotifySummary | null { + if (typeof text !== 'string' || text.length === 0) return null + + const lines = text.split('\n') + let lastIdx = lines.length - 1 + while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 + if (lastIdx < 0) return null + + const jsonPart = matchNotifySummaryLine(lines[lastIdx]) + if (!jsonPart) return null + + try { + const parsed: unknown = JSON.parse(jsonPart) + if (!isObject(parsed)) return null + const result: NotifySummary = {} + if (typeof parsed.version === 'number') result.version = parsed.version + if (typeof parsed.agent === 'string') result.agent = parsed.agent + if (typeof parsed.project === 'string') result.project = parsed.project + if (typeof parsed.status === 'string') result.status = parsed.status + if (typeof parsed.action === 'string') result.action = parsed.action + if (typeof parsed.summary === 'string') result.summary = parsed.summary + return result + } catch { + return null + } +} + export type { RoleWrappedRecord } diff --git a/shared/src/overseerEntity.test.ts b/shared/src/overseerEntity.test.ts new file mode 100644 index 0000000000..aa11d6e883 --- /dev/null +++ b/shared/src/overseerEntity.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'bun:test' +import { + OVERSEER_CONVO_TURN_EVENT_TYPE, + OVERSEER_ENTITY_ID, + OVERSEER_SOURCE_KIND, + OVERSEER_TOOL_CATALOG, + OVERSEER_TOOL_NAMES, + buildOverseerConvoTurnEventInput, + buildOverseerIdentity, + buildOverseerSystemPrompt, + deriveObservedWorkerState, + inferWorkerState, + mapEventTypeToWorkerState, + mapNotifyStatusToWorkerState, + overseerToolArgsSchemas +} from './overseerEntity' + +const STALE = 30 * 60 * 1000 + +describe('overseer entity protocol', () => { + it('catalog covers every tool name and is read-only', () => { + const catalogNames = OVERSEER_TOOL_CATALOG.map((t) => t.name).sort() + expect(catalogNames).toEqual([...OVERSEER_TOOL_NAMES].sort()) + expect(OVERSEER_TOOL_CATALOG.every((t) => t.readonly === true)).toBe(true) + }) + + it('exposes a read-only (cannot dispatch) identity', () => { + const identity = buildOverseerIdentity() + expect(identity.id).toBe(OVERSEER_ENTITY_ID) + expect(identity.kind).toBe(OVERSEER_SOURCE_KIND) + expect(identity.canDispatch).toBe(false) + expect(identity.tools).toHaveLength(OVERSEER_TOOL_NAMES.length) + }) + + it('system prompt frames chief-of-staff + read-only + provenance', () => { + const prompt = buildOverseerSystemPrompt() + expect(prompt).toContain('chief-of-staff') + expect(prompt.toLowerCase()).toContain('read only') + expect(prompt).toContain('CANNOT dispatch') + expect(prompt).toContain('Show receipts') + }) + + describe('worker state derivation', () => { + it('maps notify status and event type to worker state', () => { + expect(mapNotifyStatusToWorkerState('blocked')).toBe('blocked') + expect(mapNotifyStatusToWorkerState('done')).toBe('complete') + expect(mapNotifyStatusToWorkerState('needs_decision')).toBe('waiting_on_operator') + expect(mapNotifyStatusToWorkerState(undefined)).toBe('unknown') + expect(mapEventTypeToWorkerState('approval_requested')).toBe('waiting_on_operator') + expect(mapEventTypeToWorkerState('tool_call')).toBe('working') + expect(mapEventTypeToWorkerState('completed')).toBe('complete') + }) + + it('observed: pending request beats everything', () => { + expect(deriveObservedWorkerState({ + active: true, thinking: true, silenceMs: 0, pendingRequestCount: 1, staleSilenceMs: STALE + })).toBe('waiting_on_operator') + }) + + it('observed: thinking is working; inactive is idle; long silence is stale', () => { + expect(deriveObservedWorkerState({ + active: true, thinking: true, silenceMs: 0, pendingRequestCount: 0, staleSilenceMs: STALE + })).toBe('working') + expect(deriveObservedWorkerState({ + active: false, thinking: false, silenceMs: 0, pendingRequestCount: 0, staleSilenceMs: STALE + })).toBe('idle') + expect(deriveObservedWorkerState({ + active: true, thinking: false, silenceMs: STALE + 1, pendingRequestCount: 0, staleSilenceMs: STALE + })).toBe('stale') + }) + }) + + describe('inferWorkerState (contradiction-aware)', () => { + it('terminal reported states win with high confidence', () => { + const r = inferWorkerState({ reported: 'blocked', observed: 'idle', silenceMs: 0, staleSilenceMs: STALE }) + expect(r.state).toBe('blocked') + expect(r.confidence).toBeGreaterThan(0.8) + }) + + it('reports working but long silence => stale with LOW confidence (does not paper over)', () => { + const r = inferWorkerState({ + reported: 'working', observed: 'stale', silenceMs: STALE + 60_000, staleSilenceMs: STALE + }) + expect(r.state).toBe('stale') + expect(r.confidence).toBeLessThan(0.5) + expect(r.note).toContain('possibly wedged') + }) + + it('falls back to observed when no report', () => { + const r = inferWorkerState({ reported: null, observed: 'working', silenceMs: 0, staleSilenceMs: STALE }) + expect(r.state).toBe('working') + }) + }) + + describe('convo_turn event input', () => { + it('is memory-bearing (attention_candidate=0) and overseer-sourced', () => { + const input = buildOverseerConvoTurnEventInput({ + operatorText: 'who is blocked?', + overseerText: 'peer-15 on CI auth', + relatedSessionId: 'sess-1', + toolCalls: [{ tool: 'query_inbox' }], + ts: 1000 + }) + expect(input.eventType).toBe(OVERSEER_CONVO_TURN_EVENT_TYPE) + expect(input.attentionCandidate).toBe(0) + expect(input.operatorActionRequired).toBe(0) + expect(input.sourceKind).toBe(OVERSEER_SOURCE_KIND) + expect(input.relatedSessionId).toBe('sess-1') + expect(input.summary).toContain('who is blocked?') + const payload = JSON.parse(input.payloadJson) as { operatorText: string; overseerText: string; toolCalls: unknown[] } + expect(payload.overseerText).toBe('peer-15 on CI auth') + expect(payload.toolCalls).toHaveLength(1) + }) + }) + + describe('tool arg schemas', () => { + it('rejects bad severity and accepts valid filter', () => { + expect(overseerToolArgsSchemas.query_events.safeParse({ severityMin: 9 }).success).toBe(false) + expect(overseerToolArgsSchemas.query_events.safeParse({ severityMin: 4, project: 'web' }).success).toBe(true) + expect(overseerToolArgsSchemas.explain_priority.safeParse({ itemId: 0 }).success).toBe(false) + expect(overseerToolArgsSchemas.explain_priority.safeParse({ itemId: 12 }).success).toBe(true) + }) + }) +}) diff --git a/shared/src/overseerEntity.ts b/shared/src/overseerEntity.ts new file mode 100644 index 0000000000..8043ab5506 --- /dev/null +++ b/shared/src/overseerEntity.ts @@ -0,0 +1,510 @@ +/** + * Overseer entity — Step 3 (read-only / Stage 0). + * + * The Overseer is a continuous conversational entity over the fleet. Unlike a + * worker session it has no agent process; its "session-equivalent" is the + * events table (`convo_turn` / `decided` / `dispatched` records) plus the + * read-only query tools defined here. This module owns the protocol-level + * surface shared by hub + web + voice: + * + * - stable identity constants + * - the read-only tool catalog (names, descriptions, zod arg schemas) + * - the voice/system prompt builder (chief-of-staff framing, Stage 0 = inform only) + * - worker-state derivation helpers (contracts §2: reported / observed / inferred) + * - the `convo_turn` event-input builder (memory-bearing, never an inbox item) + * + * Persona tuning (warmth/wit/pushback calibration) is deliberately thin here: + * the build-sequence gates persona iteration on the Step 2.75 replay harness + * ("vibes in a trench coat" otherwise). This module ships the *plumbing* prompt: + * factual, provenance-first, contradiction-aware, read-only. + */ + +import { z } from 'zod' +import type { InboxItemStatus } from './overseerInbox' + +/** Stable id for the single fleet-level Overseer entity. */ +export const OVERSEER_ENTITY_ID = 'overseer' + +/** `source_kind` / `sink_kind` used for Overseer-authored events. */ +export const OVERSEER_SOURCE_KIND = 'overseer' as const + +/** Event type for an operator<->Overseer conversation segment (memory-bearing). */ +export const OVERSEER_CONVO_TURN_EVENT_TYPE = 'convo_turn' as const + +// --------------------------------------------------------------------------- +// Worker state model (contracts §2) +// --------------------------------------------------------------------------- + +export const OVERSEER_WORKER_STATES = [ + 'idle', + 'working', + 'waiting_on_operator', + 'waiting_on_external', + 'blocked', + 'failed', + 'complete', + 'stale', + 'unknown' +] as const + +export type OverseerWorkerState = typeof OVERSEER_WORKER_STATES[number] + +/** A worker-health view distinguishes three sources of truth (contracts §2). */ +export type OverseerWorkerHealth = { + sessionId: string + name: string | null + project: string | null + flavor: string | null + /** What the worker says — strongest but may lie or be silent. */ + reportedState: OverseerWorkerState + /** Session activity the hub can directly observe — factual but coarse. */ + observedState: OverseerWorkerState + /** Combination + heuristics — useful but explicitly uncertain. */ + inferredState: OverseerWorkerState + /** 0..1 confidence in the inferred state; lower when sources disagree. */ + inferredConfidence: number + /** Human-readable signal trail backing the inferred state ("show receipts"). */ + signals: string[] + lastActivityAt: number | null + silenceMs: number | null + pendingRequestCount: number +} + +export type OverseerSessionStateView = { + sessionId: string + name: string | null + project: string | null + flavor: string | null + active: boolean + thinking: boolean + observedState: OverseerWorkerState + workerReportedState: OverseerWorkerState | null + lastActivityAt: number | null + silenceMs: number | null + /** Age of the most recent observed tool call, when known. */ + lastToolCallAgeMs: number | null + pendingRequestCount: number +} + +export type OverseerActiveWorker = { + sessionId: string + name: string | null + project: string | null + flavor: string | null + observedState: OverseerWorkerState + active: boolean + lastActivityAt: number | null + ageMs: number | null +} + +export type OverseerRecentOutputChunk = { + messageId: string + role: 'operator' | 'worker' | 'unknown' + text: string + createdAt: number +} + +export type OverseerExplainPriority = { + inboxItemId: number + title: string + category: string + status: InboxItemStatus | string + priority: number + basePriority: number + agingFactor: number | null + timeCriticality: number | null + /** The provenance string stored at scoring time — recited, not recomputed. */ + reasonForPriority: string | null + sourceEventIds: number[] + relatedSessionId: string | null + /** Lightweight detail for each contributing event (provenance trail). */ + sourceEvents: Array<{ + id: number + eventType: string + summary: string + ts: number + severity: number | null + sourceKind: string + }> +} + +/** + * Map a worker `AGENT_NOTIFY_SUMMARY` status to a worker state (contracts §2). + * Used to derive `worker_reported_state` from the latest worker event. + */ +export function mapNotifyStatusToWorkerState(status: string | null | undefined): OverseerWorkerState { + switch (status) { + case 'done': + return 'complete' + case 'blocked': + return 'blocked' + case 'failed': + return 'failed' + case 'needs_decision': + case 'needs_review': + return 'waiting_on_operator' + case 'stalled': + return 'stale' + default: + return status ? 'working' : 'unknown' + } +} + +/** Map a stored `event_type` to the worker state it implies (reported view). */ +export function mapEventTypeToWorkerState(eventType: string): OverseerWorkerState { + switch (eventType) { + case 'completed': + return 'complete' + case 'blocked': + return 'blocked' + case 'failed': + return 'failed' + case 'needs_decision': + case 'needs_review': + case 'approval_requested': + case 'permission_request': + return 'waiting_on_operator' + case 'stale': + return 'stale' + case 'progress': + case 'tool_call': + case 'tool_result': + return 'working' + default: + return 'unknown' + } +} + +export type ObservedStateInput = { + active: boolean + thinking: boolean + /** ms since the last observed activity, or null if unknown. */ + silenceMs: number | null + pendingRequestCount: number + staleSilenceMs: number +} + +/** Hub-observed state from raw session signals (factual but coarse). */ +export function deriveObservedWorkerState(input: ObservedStateInput): OverseerWorkerState { + if (input.pendingRequestCount > 0) { + return 'waiting_on_operator' + } + if (input.thinking) { + return 'working' + } + if (!input.active) { + return 'idle' + } + if (input.silenceMs !== null && input.silenceMs >= input.staleSilenceMs) { + return 'stale' + } + return 'idle' +} + +export type InferStateInput = { + reported: OverseerWorkerState | null + observed: OverseerWorkerState + silenceMs: number | null + staleSilenceMs: number +} + +export type InferredStateResult = { + state: OverseerWorkerState + confidence: number + /** Note appended to the signal trail explaining the inference. */ + note: string +} + +/** + * Combine reported + observed into the inferred view, preserving uncertainty + * (contracts §2, §14). Never papers over a reported/observed conflict — when + * the worker claims `working` but the hub has seen long silence, the inferred + * state degrades to `stale` with a low confidence and an explicit note. + */ +export function inferWorkerState(input: InferStateInput): InferredStateResult { + const { reported, observed, silenceMs, staleSilenceMs } = input + const longSilence = silenceMs !== null && silenceMs >= staleSilenceMs + + // Terminal / unambiguous reported states win — they are the strongest signal + // and not contradicted by silence (a blocked worker is correctly quiet). + if (reported === 'blocked' || reported === 'failed' || reported === 'complete') { + return { state: reported, confidence: 0.9, note: `worker self-reported ${reported}` } + } + + if (reported === 'working' && longSilence) { + return { + state: 'stale', + confidence: 0.4, + note: 'worker reports working but hub has seen no output past the silence threshold — possibly wedged' + } + } + + if (reported && reported !== 'unknown') { + return { state: reported, confidence: 0.75, note: `worker self-reported ${reported}` } + } + + // No usable report — fall back to the observed signal. + return { + state: observed, + confidence: observed === 'unknown' ? 0.2 : 0.6, + note: 'no worker self-report; using hub-observed state' + } +} + +// --------------------------------------------------------------------------- +// Read-only tool catalog +// --------------------------------------------------------------------------- + +export const OVERSEER_TOOL_NAMES = [ + 'query_events', + 'query_inbox', + 'get_session_state', + 'get_session_recent_output', + 'get_worker_health', + 'explain_priority', + 'list_active_workers' +] as const + +export type OverseerToolName = typeof OVERSEER_TOOL_NAMES[number] + +const sessionIdSchema = z.string().min(1) + +export const queryEventsArgsSchema = z.object({ + sessionId: sessionIdSchema.optional(), + project: z.string().min(1).optional(), + eventType: z.string().min(1).optional(), + sourceKind: z.enum(['worker', 'overseer', 'operator', 'system', 'channel']).optional(), + attentionCandidate: z.union([z.literal(0), z.literal(1)]).optional(), + severityMin: z.number().int().min(1).max(5).optional(), + sinceTs: z.number().int().nonnegative().optional(), + untilTs: z.number().int().nonnegative().optional(), + beforeId: z.number().int().positive().optional(), + limit: z.number().int().min(1).max(200).optional() +}) +export type QueryEventsArgs = z.infer + +export const queryInboxArgsSchema = z.object({ + statuses: z.array(z.string().min(1)).min(1).optional(), + sessionId: sessionIdSchema.optional(), + category: z.string().min(1).optional(), + limit: z.number().int().min(1).max(200).optional() +}) +export type QueryInboxArgs = z.infer + +export const getSessionStateArgsSchema = z.object({ + sessionId: sessionIdSchema +}) +export type GetSessionStateArgs = z.infer + +export const getSessionRecentOutputArgsSchema = z.object({ + sessionId: sessionIdSchema, + n: z.number().int().min(1).max(50).optional() +}) +export type GetSessionRecentOutputArgs = z.infer + +export const getWorkerHealthArgsSchema = z.object({ + sessionId: sessionIdSchema +}) +export type GetWorkerHealthArgs = z.infer + +export const explainPriorityArgsSchema = z.object({ + itemId: z.number().int().positive() +}) +export type ExplainPriorityArgs = z.infer + +export const listActiveWorkersArgsSchema = z.object({ + project: z.string().min(1).optional(), + state: z.enum(OVERSEER_WORKER_STATES).optional(), + minAgeMs: z.number().int().nonnegative().optional(), + limit: z.number().int().min(1).max(200).optional() +}) +export type ListActiveWorkersArgs = z.infer + +export const overseerToolArgsSchemas = { + query_events: queryEventsArgsSchema, + query_inbox: queryInboxArgsSchema, + get_session_state: getSessionStateArgsSchema, + get_session_recent_output: getSessionRecentOutputArgsSchema, + get_worker_health: getWorkerHealthArgsSchema, + explain_priority: explainPriorityArgsSchema, + list_active_workers: listActiveWorkersArgsSchema +} as const satisfies Record + +export type OverseerToolCatalogEntry = { + name: OverseerToolName + description: string + readonly: true +} + +/** Catalog surfaced to the voice/system layer; all entries are read-only. */ +export const OVERSEER_TOOL_CATALOG: OverseerToolCatalogEntry[] = [ + { + name: 'query_events', + description: 'Read the fleet events stream, filtered by session, project, type, source, severity, time window, or attention-candidate flag.', + readonly: true + }, + { + name: 'query_inbox', + description: 'Read current inbox items — candidates, surfaced items, and held intent items — optionally filtered by status, session, or category.', + readonly: true + }, + { + name: 'get_session_state', + description: 'Hub-observed state for one session: activity, tool-call recency, pending requests, and the worker-reported state when available.', + readonly: true + }, + { + name: 'get_session_recent_output', + description: 'Last N transcript chunks for one session, for context.', + readonly: true + }, + { + name: 'get_worker_health', + description: 'Combined worker health: reported + observed + inferred state with a signal trail (never collapses a reported/observed conflict).', + readonly: true + }, + { + name: 'explain_priority', + description: 'Provenance trail for one inbox item: why it sits where it does, reciting the stored reason and contributing event IDs.', + readonly: true + }, + { + name: 'list_active_workers', + description: 'Summary roster of workers, filterable by project, state, and minimum age.', + readonly: true + } +] + +export type OverseerIdentity = { + id: string + kind: typeof OVERSEER_SOURCE_KIND + /** Stage 0: read-only. The Overseer can inform but cannot dispatch. */ + canDispatch: false + tools: OverseerToolCatalogEntry[] +} + +export function buildOverseerIdentity(): OverseerIdentity { + return { + id: OVERSEER_ENTITY_ID, + kind: OVERSEER_SOURCE_KIND, + canDispatch: false, + tools: OVERSEER_TOOL_CATALOG + } +} + +// --------------------------------------------------------------------------- +// System / voice prompt +// --------------------------------------------------------------------------- + +/** + * Build the Overseer system prompt for the dedicated voice/conversation surface. + * + * This is the *fleet* surface — distinct from per-session voice (which routes to + * one worker via `messageCodingAgent`). The Overseer answers questions about the + * whole fleet using its read-only tools. It cannot dispatch at Stage 0. + */ +export function buildOverseerSystemPrompt(): string { + return [ + '# Identity', + '', + 'You are the Overseer: a chief-of-staff for a fleet of autonomous coding agents ("workers").', + 'You hold a continuous view of the whole fleet and speak to the operator about it. You are not', + 'any single worker, and you never speak as one.', + '', + '# What you can do (Stage 0 — read only)', + '', + 'You can READ the fleet and ANSWER questions. You have read-only tools and nothing else:', + '', + '- query_events — the events stream (blockers, completions, decisions, progress, errors).', + '- query_inbox — what currently needs the operator: candidates, surfaced items, held items.', + '- get_session_state — one session\'s observed state, activity, and reported state.', + '- get_session_recent_output — the last few transcript chunks of a session.', + '- get_worker_health — reported + observed + inferred state for one worker.', + '- explain_priority — why an inbox item sits where it does, with its provenance.', + '- list_active_workers — the current roster, filterable by project / state / age.', + '', + 'You CANNOT dispatch, message workers, spawn, confirm, or change any state. If the operator asks', + 'you to act on a worker, say plainly that you can advise but cannot dispatch yet, and tell them', + 'what you would recommend.', + '', + '# How to answer', + '', + '- Lead with the answer, not the method. "Peer 15 is blocked on CI auth" — not "let me check".', + '- Show receipts on request. Every claim traces to event IDs and timestamps; if the operator asks', + ' "are you sure?" or "show your sources", recite the underlying signals.', + '- Surface conflicts; never paper over them. If a worker says tests pass and CI says fail, say so:', + ' "peer-15 reports pass; CI says fail — which signal are we acting on?" Do not synthesize a', + ' confident single answer from disagreeing sources.', + '- Prefer direct tool/system evidence over a worker\'s self-report when they conflict.', + '- Prioritize. Surface the root cause, not five symptoms ("GitHub auth is blocking 5 workers",', + ' not a roll-call of each blocked worker).', + '', + '# Voice output', + '', + '- Keep spoken answers to 1-3 sentences unless asked for depth.', + '- Never read hashes, IDs, or full paths character-by-character. Say "the session ending in ZAJ".', + '- Summarize; do not narrate a log file.' + ].join('\n') +} + +// --------------------------------------------------------------------------- +// convo_turn writeback +// --------------------------------------------------------------------------- + +export type OverseerConvoTurnInput = { + /** What the operator said. */ + operatorText: string + /** What the Overseer answered. */ + overseerText: string + /** Session this turn is about, if any (threads the convo to a worker). */ + relatedSessionId?: string | null + /** Parent convo-turn event id, to thread a multi-turn exchange. */ + relatedEventId?: number | null + /** Tool calls made while answering, for the audit/replay trail. */ + toolCalls?: Array<{ tool: OverseerToolName; argsSummary?: string }> + ts?: number +} + +export type OverseerConvoTurnEventInput = { + ts: number + sourceKind: typeof OVERSEER_SOURCE_KIND + sourceRef: string + sinkKind: 'operator' + eventType: typeof OVERSEER_CONVO_TURN_EVENT_TYPE + /** Memory-bearing, never an inbox item (contracts §1 / three-layer model). */ + attentionCandidate: 0 + operatorActionRequired: 0 + summary: string + payloadJson: string + relatedSessionId: string | null + relatedEventId: number | null + provenance: string +} + +/** Build the `convo_turn` event-input for a single operator<->Overseer exchange. */ +export function buildOverseerConvoTurnEventInput(input: OverseerConvoTurnInput): OverseerConvoTurnEventInput { + const ts = input.ts ?? Date.now() + const operatorText = input.operatorText.trim() + const overseerText = input.overseerText.trim() + const summary = operatorText.length > 0 + ? `Operator: ${operatorText.slice(0, 160)}` + : 'Overseer conversation turn' + return { + ts, + sourceKind: OVERSEER_SOURCE_KIND, + sourceRef: OVERSEER_ENTITY_ID, + sinkKind: 'operator', + eventType: OVERSEER_CONVO_TURN_EVENT_TYPE, + attentionCandidate: 0, + operatorActionRequired: 0, + summary, + payloadJson: JSON.stringify({ + operatorText, + overseerText, + toolCalls: input.toolCalls ?? [] + }), + relatedSessionId: input.relatedSessionId ?? null, + relatedEventId: input.relatedEventId ?? null, + provenance: 'overseer-convo' + } +} diff --git a/shared/src/overseerEvents.test.ts b/shared/src/overseerEvents.test.ts new file mode 100644 index 0000000000..147eca8621 --- /dev/null +++ b/shared/src/overseerEvents.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from 'bun:test' +import { + AGENT_NOTIFY_CONTRACT_INLINE_PREFIX, + buildOverseerSessionIdentity, + buildUrlArtifactRefs, + defaultAttentionCandidate, + deriveAttentionCandidate, + deriveSessionDisplayName, + deriveSessionProject, + extractHttpUrls, + mapNotifyStatusToEventType, + buildEventSummaryFromNotify, + detectEmptyHapiEventsSentinel, + mergeEventPayloadWithSession, + normalizeUrlIdempotencyKey, + OVERSEER_EVENT_TYPES, + HAPI_EVENTS_BEGIN, + HAPI_EVENTS_END, + stripAgentContract +} from './overseerEvents' +import { extractNotifySummary } from './messages' + +describe('overseerEvents mapping', () => { + test('maps notify status to event_type', () => { + expect(mapNotifyStatusToEventType('done')).toBe('completed') + expect(mapNotifyStatusToEventType('stalled')).toBe('stale') + expect(mapNotifyStatusToEventType('blocked')).toBe('blocked') + }) + + test('derives attention_candidate from status and action', () => { + expect(deriveAttentionCandidate('blocked')).toBe(1) + expect(deriveAttentionCandidate('done', '')).toBe(0) + expect(deriveAttentionCandidate('done', 'Merge PR')).toBe(1) + expect(deriveAttentionCandidate('needs_decision')).toBe(1) + }) + + test('buildEventSummaryFromNotify prefers summary', () => { + expect(buildEventSummaryFromNotify({ summary: 'Shipped fix', action: 'Review' })).toBe('Shipped fix') + }) + + test('detectEmptyHapiEventsSentinel finds empty sentinel pair', () => { + const text = `prose\n${HAPI_EVENTS_BEGIN}${HAPI_EVENTS_END}` + expect(detectEmptyHapiEventsSentinel(text)).toBe(true) + }) + + test('contract prefix is non-empty', () => { + expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX.length).toBeGreaterThan(40) + expect(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX).toContain('AGENT_NOTIFY_SUMMARY') + }) + + test('buildOverseerSessionIdentity captures name tag project flavor', () => { + const identity = buildOverseerSessionIdentity({ + id: 'sess-1', + flavor: 'codex', + tag: 'meta-triage', + metadata: { name: 'meta HAPI triage', path: '/coding/hapi' }, + notifyProject: 'hapi' + }) + expect(identity.name).toBe('meta HAPI triage') + expect(identity.tag).toBe('meta-triage') + expect(identity.project).toBe('hapi') + expect(identity.flavor).toBe('codex') + }) + + test('mergeEventPayloadWithSession embeds session snapshot', () => { + const json = mergeEventPayloadWithSession( + { notify_summary: { summary: 'ok' } }, + { + id: 'sess-1', + tag: 'meta-triage', + name: 'meta HAPI triage', + project: 'hapi', + flavor: 'codex' + } + ) + const payload = JSON.parse(json) as { session: { name: string } } + expect(payload.session.name).toBe('meta HAPI triage') + }) + + test('deriveSessionDisplayName prefers metadata name over tag', () => { + expect(deriveSessionDisplayName({ name: 'Display' }, 'tag-only')).toBe('Display') + expect(deriveSessionDisplayName(null, 'tag-only')).toBe('tag-only') + }) + + test('deriveSessionProject uses path basename', () => { + expect(deriveSessionProject({ path: '/coding/hapi' })).toBe('hapi') + }) + + test('extractHttpUrls strips trailing punctuation and dedupes', () => { + const urls = extractHttpUrls( + 'see https://github.com/tiann/hapi/pull/22. also https://example.com/docs and https://example.com/docs' + ) + expect(urls).toEqual([ + 'https://github.com/tiann/hapi/pull/22', + 'https://example.com/docs' + ]) + }) + + test('buildUrlArtifactRefs uses kind url', () => { + const refs = buildUrlArtifactRefs(['https://example.com'], 'inferred', 1000) + expect(refs).toEqual([{ + kind: 'url', + url: 'https://example.com', + source: 'inferred', + created_at: 1000 + }]) + }) + + test('OVERSEER_EVENT_TYPES includes link_seen', () => { + expect(OVERSEER_EVENT_TYPES).toContain('link_seen') + expect(defaultAttentionCandidate('link_seen')).toBe(0) + }) + + test('normalizeUrlIdempotencyKey drops hash and lowercases host', () => { + expect(normalizeUrlIdempotencyKey('https://Example.COM/path/#frag')).toBe('https://example.com/path') + }) +}) + +describe('stripAgentContract (render-only, human-facing)', () => { + test('strips a correct trailing summary line + the blank line above it', () => { + const text = 'Here is the answer.\n\nAGENT_NOTIFY_SUMMARY {"status":"done","summary":"ok"}' + expect(stripAgentContract(text)).toBe('Here is the answer.') + }) + + test('strips a corrupted (SUMARY) trailing summary line', () => { + const text = 'Here is the answer.\nAGENT_NOTIFY_SUMARY {"status":"done"}' + expect(stripAgentContract(text)).toBe('Here is the answer.') + }) + + test('strips the leading inline-contract prefix block (historical stored msgs)', () => { + const text = `${AGENT_NOTIFY_CONTRACT_INLINE_PREFIX}please do the thing` + expect(stripAgentContract(text)).toBe('please do the thing') + }) + + test('strips both leading prefix and trailing summary in one pass', () => { + const text = `${AGENT_NOTIFY_CONTRACT_INLINE_PREFIX}real content\nAGENT_NOTIFY_SUMMARY {"status":"done"}` + expect(stripAgentContract(text)).toBe('real content') + }) + + test('leaves a quoted-but-not-last token untouched', () => { + const text = 'I emit AGENT_NOTIFY_SUMMARY {json} at the end.\nThen more prose.' + expect(stripAgentContract(text)).toBe(text) + }) + + test('no-ops on clean text and empty input', () => { + expect(stripAgentContract('just a normal reply')).toBe('just a normal reply') + expect(stripAgentContract('')).toBe('') + }) + + test('round-trip invariant: overseer still parses raw, human view has no marker', () => { + const raw = 'Work done.\nAGENT_NOTIFY_SUMARY {"status":"done","summary":"shipped"}' + // overseer reads the RAW text and still gets the event (corruption-tolerant) + expect(extractNotifySummary(raw)?.summary).toBe('shipped') + // the human render is stripped clean and can no longer parse a marker + const human = stripAgentContract(raw) + expect(human).toBe('Work done.') + expect(extractNotifySummary(human)).toBeNull() + }) +}) diff --git a/shared/src/overseerEvents.ts b/shared/src/overseerEvents.ts new file mode 100644 index 0000000000..6c5a8654bd --- /dev/null +++ b/shared/src/overseerEvents.ts @@ -0,0 +1,356 @@ +import type { NotifySummary } from './messages' +import { matchNotifySummaryLine } from './messages' + +export const NOTIFY_SUMMARY_STATUSES = [ + 'done', + 'blocked', + 'needs_review', + 'needs_decision', + 'failed', + 'stalled' +] as const + +export type NotifySummaryStatus = typeof NOTIFY_SUMMARY_STATUSES[number] + +/** Inline prefix injected for non-Cursor flavors on outbound user messages (#20). */ +export const AGENT_NOTIFY_CONTRACT_INLINE_PREFIX = [ + 'End every response with one machine-parseable line (no backticks):', + 'AGENT_NOTIFY_SUMMARY {"version":1,"agent":"","project":"","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"}', + 'Use blocked if unsure. action must be concrete when status is done and follow-up is needed.', + '', + '---', + '' +].join('\n') + +/** + * Strip the machine-only notify contract from text destined for HUMAN eyes. + * + * The `AGENT_NOTIFY_SUMMARY` contract rides fully in-band so it works across + * every agent flavor, but it must never reach the human render. Two removals: + * 1. The trailing `AGENT_NOTIFY_SUMMARY {...}` line (collapse-normalized, so + * Cursor's corrupted `SUMARY` variant strips too) plus any blank lines it + * leaves behind. + * 2. A leading inline-contract prefix block - only present on historical + * operator messages stored before input-side decoupling (the hub now + * injects the prefix into the agent-bound copy only, never the stored one). + * + * Overseer event capture and notification builders MUST read the raw text, not + * this - stripping is render-only so the machine signal survives in the store. + */ +export function stripAgentContract(text: string): string { + if (typeof text !== 'string' || text.length === 0) return text + let out = text + + if (out.startsWith(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX)) { + out = out.slice(AGENT_NOTIFY_CONTRACT_INLINE_PREFIX.length) + } + + const lines = out.split('\n') + let lastIdx = lines.length - 1 + while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 + if (lastIdx >= 0 && matchNotifySummaryLine(lines[lastIdx])) { + const kept = lines.slice(0, lastIdx) + while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop() + out = kept.join('\n') + } + + return out +} + +export const HAPI_EVENTS_BEGIN = '' +export const HAPI_EVENTS_END = '' + +export const OVERSEER_STALE_SILENCE_MS = 30 * 60 * 1000 + +/** Cap URLs scooped from a single message (avoids tool-dump explosions). */ +export const OVERSEER_LINK_SCOOP_MAX_URLS = 20 + +/** + * Worker / hub-observed event_type taxonomy (contracts §1). + * `link_seen` is hub-observed memory-bearing; captured-only by default. + */ +export const OVERSEER_EVENT_TYPES = [ + 'progress', + 'tool_call', + 'tool_result', + 'commit_pushed', + 'pr_opened', + 'needs_decision', + 'needs_review', + 'blocked', + 'risk_detected', + 'approval_requested', + 'failed', + 'completed', + 'heartbeat', + 'stale', + 'validation_error', + 'convo_turn', + 'decided', + 'dispatched', + 'link_seen' +] as const + +export type OverseerEventType = typeof OVERSEER_EVENT_TYPES[number] + +export type OverseerArtifactRef = { + kind: string + url?: string + title?: string + ref?: string + source?: string + created_at?: number +} + +/** Denormalized session identity written into every overseer event payload. */ +export type OverseerSessionIdentity = { + id: string + tag: string | null + name: string | null + project: string | null + flavor: string +} + +const HTTP_URL_RE = /https?:\/\/[^\s<>\[\](){}'"`]+/gi +const TRAILING_URL_PUNCT_RE = /[.,;:!?)]+$/ + +/** + * Extract http(s) URLs from free text. Strips common trailing punctuation. + * Dedupes while preserving first-seen order. Caps at OVERSEER_LINK_SCOOP_MAX_URLS. + */ +export function extractHttpUrls(text: string, max: number = OVERSEER_LINK_SCOOP_MAX_URLS): string[] { + if (!text) return [] + const seen = new Set() + const urls: string[] = [] + HTTP_URL_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = HTTP_URL_RE.exec(text)) !== null) { + const url = match[0].replace(TRAILING_URL_PUNCT_RE, '') + if (!url || seen.has(url)) continue + seen.add(url) + urls.push(url) + if (urls.length >= max) break + } + return urls +} + +/** Stable idempotency fragment for a scooped URL (case-insensitive host/path). */ +export function normalizeUrlIdempotencyKey(url: string): string { + try { + const parsed = new URL(url) + parsed.hash = '' + const host = parsed.hostname.toLowerCase() + const path = parsed.pathname.replace(/\/+$/, '') || '' + const search = parsed.search + return `${parsed.protocol}//${host}${path}${search}` + } catch { + return url.trim().toLowerCase() + } +} + +export function buildUrlArtifactRefs( + urls: string[], + source: string = 'inferred', + createdAt: number = Date.now() +): OverseerArtifactRef[] { + return urls.map((url) => ({ + kind: 'url', + url, + source, + created_at: createdAt + })) +} + +export function buildLinkSeenSummary(url: string): string { + try { + const parsed = new URL(url) + const path = parsed.pathname === '/' ? '' : parsed.pathname + const display = `${parsed.hostname}${path}` + return display.length > 120 ? `Link seen: ${display.slice(0, 117)}...` : `Link seen: ${display}` + } catch { + const trimmed = url.length > 120 ? `${url.slice(0, 117)}...` : url + return `Link seen: ${trimmed}` + } +} + +export function deriveSessionDisplayName( + metadata: { name?: string } | null | undefined, + tag?: string | null +): string | null { + const name = metadata?.name?.trim() + if (name) return name + const tagValue = tag?.trim() + return tagValue || null +} + +export function deriveSessionProject( + metadata: { path?: string; worktree?: { name?: string } } | null | undefined +): string | null { + if (!metadata) return null + const worktreeName = metadata.worktree?.name?.trim() + if (worktreeName) return worktreeName + const path = metadata.path?.trim() + if (!path) return null + const parts = path.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1]! : null +} + +export function buildOverseerSessionIdentity(input: { + id: string + flavor: string + tag?: string | null + metadata?: { name?: string; path?: string; worktree?: { name?: string } } | null + notifyProject?: string | null +}): OverseerSessionIdentity { + const tag = input.tag ?? null + const derivedProject = deriveSessionProject(input.metadata) + const notifyProject = input.notifyProject?.trim() + return { + id: input.id, + tag, + name: deriveSessionDisplayName(input.metadata, tag), + project: notifyProject || derivedProject, + flavor: input.flavor + } +} + +export function mergeEventPayloadWithSession( + payloadFields: Record, + session: OverseerSessionIdentity +): string { + return JSON.stringify({ + ...payloadFields, + session: { + id: session.id, + tag: session.tag, + name: session.name, + project: session.project, + flavor: session.flavor + } + }) +} + +export function mapNotifyStatusToEventType(status: string | undefined): string { + switch (status) { + case 'done': + return 'completed' + case 'blocked': + return 'blocked' + case 'needs_review': + return 'needs_review' + case 'needs_decision': + return 'needs_decision' + case 'failed': + return 'failed' + case 'stalled': + return 'stale' + default: + return 'progress' + } +} + +export function deriveAttentionCandidate(status: string | undefined, action?: string): 0 | 1 { + switch (status) { + case 'needs_decision': + case 'blocked': + case 'failed': + case 'needs_review': + case 'stalled': + return 1 + case 'done': + return action && action.trim().length > 0 ? 1 : 0 + default: + return 0 + } +} + +export function deriveOperatorActionRequired(status: string | undefined, action?: string): 0 | 1 { + return deriveAttentionCandidate(status, action) +} + +export function deriveSeverity(eventType: string): number { + switch (eventType) { + case 'approval_requested': + case 'permission_request': + case 'needs_decision': + return 5 + case 'blocked': + case 'failed': + return 4 + case 'needs_review': + case 'stale': + return 3 + case 'completed': + return 2 + case 'link_seen': + return 1 + default: + return 1 + } +} + +/** Default attention_candidate for known taxonomy types (safer default: false). */ +export function defaultAttentionCandidate(eventType: string): 0 | 1 { + switch (eventType) { + case 'commit_pushed': + case 'pr_opened': + case 'needs_decision': + case 'needs_review': + case 'blocked': + case 'risk_detected': + case 'approval_requested': + case 'failed': + return 1 + case 'link_seen': + case 'progress': + case 'tool_call': + case 'tool_result': + case 'heartbeat': + case 'convo_turn': + case 'decided': + case 'dispatched': + case 'stale': + case 'validation_error': + default: + return 0 + } +} + +export function buildEventSummaryFromNotify(notify: NotifySummary): string { + const summary = notify.summary?.trim() + if (summary) return summary + const action = notify.action?.trim() + if (action) return action + const status = notify.status?.trim() + if (status) return `Agent status: ${status}` + return 'Agent turn summary' +} + +export function detectEmptyHapiEventsSentinel(text: string): boolean { + const pattern = new RegExp( + `${escapeRegExp(HAPI_EVENTS_BEGIN)}\\s*${escapeRegExp(HAPI_EVENTS_END)}`, + 'm' + ) + return pattern.test(text) +} + +export function detectMalformedNotifySummaryLine(text: string): boolean { + const lines = text.split('\n') + let lastIdx = lines.length - 1 + while (lastIdx >= 0 && lines[lastIdx].trim() === '') lastIdx -= 1 + if (lastIdx < 0) return false + const lastLine = lines[lastIdx].trim() + if (!lastLine.startsWith('AGENT_NOTIFY_SUMMARY ')) return false + const jsonPart = lastLine.slice('AGENT_NOTIFY_SUMMARY '.length).trim() + if (!jsonPart.startsWith('{')) return true + try { + JSON.parse(jsonPart) + return false + } catch { + return true + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/shared/src/overseerInbox.test.ts b/shared/src/overseerInbox.test.ts new file mode 100644 index 0000000000..7714146bd5 --- /dev/null +++ b/shared/src/overseerInbox.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test' +import { + buildExplainPriority, + buildInboxTitle, + buildInboxTitleFromEvent, + computeCoarseBasePriority, + mapEventTypeToInboxCategory, + mapOperatorActionToStatus +} from './overseerInbox' + +describe('overseerInbox', () => { + it('orders coarse rank permission > blocked > needs_decision > completed > stale', () => { + expect(computeCoarseBasePriority('approval_requested')).toBeLessThan(computeCoarseBasePriority('blocked')) + expect(computeCoarseBasePriority('blocked')).toBeLessThan(computeCoarseBasePriority('needs_decision')) + expect(computeCoarseBasePriority('needs_decision')).toBeLessThan(computeCoarseBasePriority('completed')) + expect(computeCoarseBasePriority('completed')).toBeLessThan(computeCoarseBasePriority('stale')) + }) + + it('maps event types to inbox category badges', () => { + expect(mapEventTypeToInboxCategory('approval_requested')).toBe('APPROVAL') + expect(mapEventTypeToInboxCategory('blocked')).toBe('BLOCKED') + expect(mapEventTypeToInboxCategory('needs_decision')).toBe('QUESTION') + expect(mapEventTypeToInboxCategory('completed')).toBe('FINALE') + expect(mapEventTypeToInboxCategory('stale')).toBe('STALE') + }) + + it('prefers artifact title over denormalized session name', () => { + const refs = JSON.stringify([{ kind: 'github_pr', title: 'fix: inbox substrate' }]) + const payload = JSON.stringify({ session: { name: 'my-session' } }) + expect(buildInboxTitleFromEvent(refs, payload, 'summary body')).toBe('fix: inbox substrate') + expect(buildInboxTitleFromEvent(null, payload, 'summary body')).toBe('my-session') + expect(buildInboxTitleFromEvent(null, null, 'summary body')).toBe('summary body') + }) + + it('builds explain_priority lite from category age and source ids', () => { + const now = Date.UTC(2026, 5, 20, 12, 0, 0) + const createdAt = now - 15 * 60_000 + const text = buildExplainPriority('BLOCKED', createdAt, [42, 43], now) + expect(text).toContain('BLOCKED tier') + expect(text).toContain('15m ago') + expect(text).toContain('2 events') + }) + + it('maps operator actions to inbox status transitions', () => { + expect(mapOperatorActionToStatus('done')).toBe('resolved') + expect(mapOperatorActionToStatus('dismiss')).toBe('obsoleted') + expect(mapOperatorActionToStatus('snooze')).toBe('snoozed') + }) +}) diff --git a/shared/src/overseerInbox.ts b/shared/src/overseerInbox.ts new file mode 100644 index 0000000000..b8a115548e --- /dev/null +++ b/shared/src/overseerInbox.ts @@ -0,0 +1,197 @@ +export const INBOX_ITEM_STATUSES = [ + 'new', + 'surfaced', + 'deferred', + 'snoozed', + 'resolved', + 'obsoleted', + 'held' +] as const + +export type InboxItemStatus = typeof INBOX_ITEM_STATUSES[number] + +export const INBOX_OPERATOR_ACTIONS = [ + 'open', + 'snooze', + 'done', + 'dismiss', + 'route', + 'retry' +] as const + +export type InboxOperatorAction = typeof INBOX_OPERATOR_ACTIONS[number] + +export const INBOX_CATEGORIES = [ + 'APPROVAL', + 'BLOCKED', + 'QUESTION', + 'REVIEW', + 'ERROR', + 'FINALE', + 'STALE' +] as const + +export type InboxCategory = typeof INBOX_CATEGORIES[number] + +export type ArtifactRef = { + kind?: string + url?: string + title?: string + ref?: string +} + +const TITLE_PRIORITY_KINDS = [ + 'github_pr', + 'github_issue', + 'branch', + 'commit', + 'deploy_id' +] as const + +/** Fixed coarse rank — lower number = higher priority (v1, not learned). */ +export function computeCoarseBasePriority(eventType: string): number { + switch (eventType) { + case 'approval_requested': + case 'permission_request': + return 10 + case 'blocked': + return 20 + case 'needs_decision': + return 30 + case 'failed': + return 35 + case 'needs_review': + return 40 + case 'completed': + return 50 + case 'stale': + return 60 + default: + return 70 + } +} + +export function mapEventTypeToInboxCategory(eventType: string): InboxCategory { + switch (eventType) { + case 'approval_requested': + case 'permission_request': + return 'APPROVAL' + case 'blocked': + return 'BLOCKED' + case 'needs_decision': + return 'QUESTION' + case 'needs_review': + return 'REVIEW' + case 'failed': + return 'ERROR' + case 'completed': + return 'FINALE' + case 'stale': + return 'STALE' + default: + return 'QUESTION' + } +} + +export function parseArtifactRefs(raw: string | null | undefined): ArtifactRef[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((entry): entry is ArtifactRef => typeof entry === 'object' && entry !== null) + } catch { + return [] + } +} + +export function pickPrimaryArtifactTitle(artifactRefs: ArtifactRef[]): string | null { + for (const kind of TITLE_PRIORITY_KINDS) { + const match = artifactRefs.find((ref) => ref.kind === kind) + if (!match) continue + if (match.title?.trim()) return match.title.trim() + if (match.ref?.trim()) return match.ref.trim() + if (match.url?.trim()) return match.url.trim() + } + for (const ref of artifactRefs) { + if (ref.title?.trim()) return ref.title.trim() + if (ref.ref?.trim()) return ref.ref.trim() + } + return null +} + +export function buildInboxTitle( + artifactRefsJson: string | null | undefined, + sessionName: string | null | undefined, + summary: string +): string { + const artifactTitle = pickPrimaryArtifactTitle(parseArtifactRefs(artifactRefsJson)) + if (artifactTitle) return artifactTitle + const sessionLabel = sessionName?.trim() + if (sessionLabel) return sessionLabel + const trimmed = summary.trim() + return trimmed.length > 0 ? trimmed.slice(0, 120) : 'Attention item' +} + +/** Read denormalized session.name from event payload (#22) — no live sessions lookup. */ +export function extractDenormalizedSessionName(payloadJson: string | null | undefined): string | null { + if (!payloadJson) return null + try { + const payload = JSON.parse(payloadJson) as { session?: { name?: unknown } } + const name = payload.session?.name + return typeof name === 'string' && name.trim().length > 0 ? name.trim() : null + } catch { + return null + } +} + +export function buildInboxTitleFromEvent( + artifactRefsJson: string | null | undefined, + payloadJson: string | null | undefined, + summary: string +): string { + return buildInboxTitle(artifactRefsJson, extractDenormalizedSessionName(payloadJson), summary) +} + +export function formatAgeMinutes(ageMs: number): string { + const minutes = Math.max(1, Math.round(ageMs / 60_000)) + if (minutes < 60) return `${minutes}m` + const hours = Math.round(minutes / 60) + if (hours < 48) return `${hours}h` + const days = Math.round(hours / 24) + return `${days}d` +} + +export function buildExplainPriority( + category: string, + createdAt: number, + sourceEventIds: number[], + now: number = Date.now() +): string { + const age = formatAgeMinutes(now - createdAt) + const eventLabel = sourceEventIds.length === 1 + ? `event #${sourceEventIds[0]}` + : `${sourceEventIds.length} events (#${sourceEventIds.slice(0, 3).join(', ')}${sourceEventIds.length > 3 ? ', …' : ''})` + return `${category} tier · queued ${age} ago · from ${eventLabel}` +} + +export function mapOperatorActionToStatus(action: InboxOperatorAction): InboxItemStatus { + switch (action) { + case 'open': + return 'surfaced' + case 'snooze': + return 'snoozed' + case 'done': + return 'resolved' + case 'dismiss': + return 'obsoleted' + case 'route': + case 'retry': + return 'surfaced' + default: + return 'surfaced' + } +} + +export function isActiveInboxStatus(status: string): boolean { + return status === 'new' || status === 'surfaced' || status === 'deferred' || status === 'snoozed' +} diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index c4284451ca..71ed4c484f 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -26,6 +26,7 @@ export const RPC_METHODS = { ListSlashCommands: 'listSlashCommands', ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', + ListCodexSessions: 'listCodexSessions', ListCursorModels: 'listCursorModels', ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', diff --git a/shared/src/schemas.sessionPatch.test.ts b/shared/src/schemas.sessionPatch.test.ts new file mode 100644 index 0000000000..ef50209485 --- /dev/null +++ b/shared/src/schemas.sessionPatch.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { SessionPatchSchema } from './schemas'; + +// Guard the contract for the second-half-of-#884 fix. The web client routes +// `session-updated` events to the structured-patch path only when the event's +// `data` parses as a SessionPatch — these tests pin the schema shape so a +// future refactor that drops `.strict()`, the versioned (version, value) +// metadata/agentState wrappers, or any of the new optional fields breaks the +// build instead of silently re-introducing the refetch storm. +describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => { + it('parses a bare todos patch', () => { + const parsed = SessionPatchSchema.safeParse({ + todos: [ + { content: 'thing', status: 'pending' } + ] + }); + expect(parsed.success).toBe(true); + }); + + it('parses a bare teamState patch', () => { + const parsed = SessionPatchSchema.safeParse({ + teamState: { + teamName: 'crew', + members: [{ name: 'one' }] + } + }); + expect(parsed.success).toBe(true); + }); + + it('parses a teamState clear patch (null = TeamDelete clears the cached row)', () => { + // PR #897 review (HAPI Bot, 2026-06-13 Major): teamState must accept + // null on the wire so TeamDelete events propagate the clear instead + // of collapsing to an empty patch on JSON serialization. The hub + // emit-site sends { teamState: null }; the patch consumers + // hasOwnProperty-discriminate "absent" vs "null clear". + const parsed = SessionPatchSchema.safeParse({ teamState: null }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.teamState).toBeNull(); + } + }); + + it('parses a versioned metadata patch', () => { + const parsed = SessionPatchSchema.safeParse({ + metadata: { + version: 7, + value: { path: '/tmp', host: 'h' } + } + }); + expect(parsed.success).toBe(true); + }); + + it('parses a versioned agentState patch with null value', () => { + const parsed = SessionPatchSchema.safeParse({ + agentState: { version: 3, value: null } + }); + expect(parsed.success).toBe(true); + }); + + it('rejects metadata without a version (must stay versioned for cache safety)', () => { + const parsed = SessionPatchSchema.safeParse({ + metadata: { value: { path: '/tmp', host: 'h' } } + }); + expect(parsed.success).toBe(false); + }); + + it('stays strict and rejects unknown keys (catches silent .strict() removal)', () => { + const parsed = SessionPatchSchema.safeParse({ + todos: [], + notARealField: true + }); + expect(parsed.success).toBe(false); + }); + + it('rejects a full Session payload (full-session SSE goes through isSessionRecord instead)', () => { + const fullSession = { + id: 's1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0 + }; + expect(SessionPatchSchema.safeParse(fullSession).success).toBe(false); + }); +}); diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1b891fbef5..bd4160e12b 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -25,6 +25,62 @@ export const WorktreeMetadataSchema = z.object({ export type WorktreeMetadata = z.infer +export const CodexTokenUsageSchema = z.object({ + inputTokens: z.number(), + cachedInputTokens: z.number(), + outputTokens: z.number(), + reasoningOutputTokens: z.number(), + totalTokens: z.number() +}) + +export type CodexTokenUsage = z.infer + +export const CodexUsageRateLimitSchema = z.object({ + usedPercent: z.number(), + windowMinutes: z.number(), + resetAt: z.number().optional() +}) + +export type CodexUsageRateLimit = z.infer + +// Credit-based plans (e.g. Codex Pro on `limit_id: premium`) bill from a +// balance instead of a 5h/weekly rolling window. When the subscription +// is also exhausted the codex transcript shows primary=null + secondary=null +// + credits.has_credits=false; the indicator needs to surface that +// distinctly from a fresh-account "no rate-limit data yet" state. +export const CodexUsageCreditsSchema = z.object({ + hasCredits: z.boolean().optional(), + unlimited: z.boolean().optional(), + balance: z.string().optional() +}) + +export type CodexUsageCredits = z.infer + +export const CodexUsageSchema = z.object({ + contextWindow: z.object({ + usedTokens: z.number(), + limitTokens: z.number(), + percent: z.number(), + updatedAt: z.number() + }).optional(), + rateLimits: z.object({ + fiveHour: CodexUsageRateLimitSchema.optional(), + weekly: CodexUsageRateLimitSchema.optional() + }).optional().default({}), + credits: CodexUsageCreditsSchema.optional(), + // Codex surfaces 'primary' / 'secondary' / 'credits' as the canonical + // names. Carry through as-is so the UI can render the exact phrase + // (e.g. 'You have exceeded your weekly limit', or 'You have run out + // of credits') without re-deriving from the boolean state. + rateLimitReachedType: z.string().optional(), + planType: z.string().optional(), + limitId: z.string().optional(), + totalTokenUsage: CodexTokenUsageSchema.optional(), + lastTokenUsage: CodexTokenUsageSchema.optional() +}) + +export type CodexUsage = z.infer + export const MetadataSchema = z.object({ path: z.string(), host: z.string(), @@ -34,6 +90,10 @@ export const MetadataSchema = z.object({ summary: MetadataSummarySchema.optional(), machineId: z.string().optional(), claudeSessionId: z.string().optional(), + // Source session ID when this session was created by forking a live one + // (`claude --resume --fork-session`). Lets the web list mark the new + // session as a branch of `` instead of an unrelated duplicate. + forkedFrom: z.string().optional(), codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), @@ -73,7 +133,8 @@ export const MetadataSchema = z.object({ // field stores only modelId (shared across all flavors); this preserves // the provider so web can resolve the exact model when two providers // share a modelId. - piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional() + piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional(), + codexUsage: CodexUsageSchema.optional() }) export type Metadata = z.infer @@ -231,22 +292,79 @@ export const SessionSchema = z.object({ export type Session = z.infer +// Versioned wrappers mirror the socket.io `update-session` broadcast shape so +// metadata/agentState always travel as an atomic (version, value) pair — the +// version is the only safe way for downstream caches to reject stale patches. +const VersionedMetadataPatchSchema = z.object({ + version: z.number(), + value: MetadataSchema.nullable() +}) + +const VersionedAgentStatePatchSchema = z.object({ + version: z.number(), + value: AgentStateSchema.nullable() +}) + export const SessionPatchSchema = z.object({ active: z.boolean().optional(), thinking: z.boolean().optional(), activeAt: z.number().optional(), updatedAt: z.number().optional(), + // Structured-patch fields for the second half of #884. Letting the four + // hub-side emit-sites in cli/sessionHandlers.ts (todos, teamState, + // metadata, agentState writes) carry their delta means the web client's + // SSE handler can patch the cache in place instead of falling through to + // the invalidation fallback that triggers per-session REST refetches. + // Versioned wrappers for metadata/agentState mirror the socket.io + // `update-session` broadcast shape - the version field is the only safe + // way for downstream caches to reject stale patches. + metadata: VersionedMetadataPatchSchema.optional(), + agentState: VersionedAgentStatePatchSchema.optional(), + todos: TodosSchema.optional(), + // `null` is the clear signal (TeamDelete events drive + // applyTeamStateDelta to return null, which `setSessionTeamState` + // persists as a cleared row). The patch must distinguish "field + // absent" (don't touch teamState) from "field is null" (clear it); + // consumers use hasOwnProperty for the discriminator. + teamState: TeamStateSchema.nullable().optional(), model: z.string().nullable().optional(), modelReasoningEffort: z.string().nullable().optional(), effort: z.string().nullable().optional(), serviceTier: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional(), - backgroundTaskCount: z.number().optional() + backgroundTaskCount: z.number().optional(), + // tiann/hapi#893 (scratchlist v2). Bumped whenever any entry on the + // session_scratchlist table mutates. Web client uses the change as a + // trigger to refetch the entries query - the timestamp itself is the + // signal, not the payload. Keep this minimal: per the operator's 80/20 + // ruling, scratchlist mutations are rare relative to keep-alive + // patches, so a fresh event type would be overkill. + scratchlistUpdatedAt: z.number().optional() }).strict() export type SessionPatch = z.infer +// tiann/hapi#893: per-session scratchlist entries (operator notes / +// drafts / parking-lot ideas). Hub-side typed-table source of truth; +// web treats localStorage as offline cache only. Single-user notes - +// no collaborative edit semantics (no version field, no conflict +// resolution beyond last-write-wins). +export const ScratchlistEntrySchema = z.object({ + entryId: z.string().min(1), + text: z.string(), + createdAt: z.number(), + updatedAt: z.number() +}) + +export type ScratchlistEntry = z.infer + +export const ScratchlistEntriesResponseSchema = z.object({ + entries: z.array(ScratchlistEntrySchema) +}) + +export type ScratchlistEntriesResponse = z.infer + export const MachineMetadataSchema = z.object({ host: z.string(), platform: z.string(), diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index c490c1bc75..d830395cb8 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from 'bun:test' import type { Session } from './schemas' import { PENDING_REQUEST_SUMMARY_CAP, + computePendingRequestKinds, + computePendingRequestsCount, + computeTodoProgress, getPendingRequestKinds, getPendingRequests, - toSessionSummary + toSessionSummary, + toSessionSummaryMetadata } from './sessionSummary' function makeSession(overrides: Partial = {}): Session { @@ -190,3 +194,52 @@ describe('getPendingRequestKinds', () => { expect(kinds).toEqual(['permission', 'input']) }) }) + +// The SSE patch path (useSSE.ts patchSessionSummary) calls these directly +// against the patch payload — no full Session needed — to keep the session +// list summary consistent with structured todos/teamState/metadata/agentState +// patches landing for the second half of #884. +describe('summary derivation helpers', () => { + it('computeTodoProgress returns null for empty / undefined todos', () => { + expect(computeTodoProgress(undefined)).toBeNull() + expect(computeTodoProgress([])).toBeNull() + }) + + it('computeTodoProgress counts completed vs total', () => { + const progress = computeTodoProgress([ + { content: 'a', status: 'pending', priority: 'medium', id: '1' }, + { content: 'b', status: 'completed', priority: 'medium', id: '2' }, + { content: 'c', status: 'completed', priority: 'medium', id: '3' } + ]) + expect(progress).toEqual({ completed: 2, total: 3 }) + }) + + it('computePendingRequestKinds works on a bare AgentState without a Session', () => { + const kinds = computePendingRequestKinds({ + requests: { + req1: { tool: 'Bash', arguments: {} }, + req2: { tool: 'AskUserQuestion', arguments: {} } + } + }) + expect(kinds).toEqual(['permission', 'input']) + }) + + it('computePendingRequestsCount handles null agentState', () => { + expect(computePendingRequestsCount(null)).toBe(0) + expect(computePendingRequestsCount(undefined)).toBe(0) + }) + + it('toSessionSummaryMetadata returns null for null metadata', () => { + expect(toSessionSummaryMetadata(null)).toBeNull() + expect(toSessionSummaryMetadata(undefined)).toBeNull() + }) + + it('toSessionSummaryMetadata derives agentSessionId from the first non-null source id', () => { + const summary = toSessionSummaryMetadata({ + path: '/p', + host: 'h', + cursorSessionId: 'cursor-xyz' + }) + expect(summary?.agentSessionId).toBe('cursor-xyz') + }) +}) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 32a3a48c23..6abc779db8 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,4 @@ -import type { Session, WorktreeMetadata } from './schemas' +import type { AgentState, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas' export type PendingRequestKind = 'permission' | 'input' @@ -20,8 +20,9 @@ export type PendingRequest = { id: string kind: PendingRequestKind tool: string - /** Epoch ms when the request was raised; falls back to `session.updatedAt` - * for older requests that were stored without `createdAt`. */ + /** Epoch ms when the request was raised; falls back to the caller-supplied + * `fallbackSince` (typically `session.updatedAt`) for older requests + * stored without `createdAt`. */ since: number } @@ -62,11 +63,31 @@ export type SessionSummary = { effort: string | null } -export function getPendingRequests( - session: Session, +// Re-exported as a standalone derivation so SSE patch handlers can recompute +// summary fields from a structured `agentState` patch without needing the +// full Session in hand. +export function computePendingRequestKinds(agentState: AgentState | null | undefined): PendingRequestKind[] { + const requests = agentState?.requests + if (!requests) { + return [] + } + + const kinds = new Set() + for (const request of Object.values(requests)) { + kinds.add(classifyKind(request.tool)) + } + + return kinds.has('permission') && kinds.has('input') + ? ['permission', 'input'] + : Array.from(kinds) +} + +export function computePendingRequests( + agentState: AgentState | null | undefined, + fallbackSince: number, cap: number = PENDING_REQUEST_SUMMARY_CAP ): PendingRequest[] { - const requests = session.agentState?.requests + const requests = agentState?.requests if (!requests) { return [] } @@ -77,7 +98,7 @@ export function getPendingRequests( id, kind: classifyKind(request.tool), tool: request.tool, - since: typeof request.createdAt === 'number' ? request.createdAt : session.updatedAt + since: typeof request.createdAt === 'number' ? request.createdAt : fallbackSince }) } @@ -90,57 +111,64 @@ export function getPendingRequests( } export function getPendingRequestKinds(session: Session): PendingRequestKind[] { - const requests = session.agentState?.requests - if (!requests) { - return [] - } + return computePendingRequestKinds(session.agentState) +} - const kinds = new Set() - for (const request of Object.values(requests)) { - kinds.add(classifyKind(request.tool)) - } +export function getPendingRequests( + session: Session, + cap: number = PENDING_REQUEST_SUMMARY_CAP +): PendingRequest[] { + return computePendingRequests(session.agentState, session.updatedAt, cap) +} - return kinds.has('permission') && kinds.has('input') - ? ['permission', 'input'] - : Array.from(kinds) +export function computePendingRequestsCount(agentState: AgentState | null | undefined): number { + return agentState?.requests ? Object.keys(agentState.requests).length : 0 } -export function toSessionSummary(session: Session): SessionSummary { - const pendingRequestsCount = session.agentState?.requests ? Object.keys(session.agentState.requests).length : 0 - - const metadata: SessionSummaryMetadata | null = session.metadata ? { - name: session.metadata.name, - path: session.metadata.path, - machineId: session.metadata.machineId ?? undefined, - summary: session.metadata.summary ? { text: session.metadata.summary.text } : undefined, - flavor: session.metadata.flavor ?? null, - worktree: session.metadata.worktree, - agentSessionId: session.metadata.codexSessionId - ?? session.metadata.claudeSessionId - ?? session.metadata.geminiSessionId - ?? session.metadata.opencodeSessionId - ?? session.metadata.cursorSessionId - ?? session.metadata.kimiSessionId - ?? undefined, - lifecycleState: session.metadata.lifecycleState - } : null +export function computeTodoProgress(todos: TodoItem[] | undefined): SessionSummary['todoProgress'] { + if (!todos?.length) { + return null + } + return { + completed: todos.filter((todo) => todo.status === 'completed').length, + total: todos.length + } +} - const todoProgress = session.todos?.length ? { - completed: session.todos.filter(t => t.status === 'completed').length, - total: session.todos.length - } : null +export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): SessionSummaryMetadata | null { + if (!metadata) { + return null + } + return { + name: metadata.name, + path: metadata.path, + machineId: metadata.machineId ?? undefined, + summary: metadata.summary ? { text: metadata.summary.text } : undefined, + flavor: metadata.flavor ?? null, + worktree: metadata.worktree, + agentSessionId: metadata.codexSessionId + ?? metadata.claudeSessionId + ?? metadata.geminiSessionId + ?? metadata.opencodeSessionId + ?? metadata.cursorSessionId + ?? metadata.kimiSessionId + ?? undefined, + lifecycleState: metadata.lifecycleState + } +} +export function toSessionSummary(session: Session): SessionSummary { return { id: session.id, active: session.active, thinking: session.thinking, activeAt: session.activeAt, updatedAt: session.updatedAt, - metadata, - todoProgress, - pendingRequestsCount, - pendingRequestKinds: getPendingRequestKinds(session), - pendingRequests: getPendingRequests(session), + metadata: toSessionSummaryMetadata(session.metadata), + todoProgress: computeTodoProgress(session.todos), + pendingRequestsCount: computePendingRequestsCount(session.agentState), + pendingRequestKinds: computePendingRequestKinds(session.agentState), + pendingRequests: computePendingRequests(session.agentState, session.updatedAt), backgroundTaskCount: session.backgroundTaskCount ?? 0, futureScheduledMessageCount: 0, nextScheduledAt: null, diff --git a/shared/src/types.ts b/shared/src/types.ts index 9c38fda221..cff1ea95a4 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -3,6 +3,19 @@ export type { AgentStateCompletedRequest, AgentStateRequest, AttachmentMetadata, + CodexTokenUsage, + CodexUsage, + CodexUsageCredits, + CodexUsageRateLimit, +} from './schemas' +export type { + AgentBudgetAxis, + AgentBudgetAxisId, + AgentBudgetEffectiveState, + AgentBudgetMetadataRow, + AgentBudgetState +} from './agentBudget' +export type { DecryptedMessage, Metadata, Machine, diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd8..f9f5a543f4 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ dev-dist/ +test-results/ diff --git a/web/e2e-fixtures/events-debug-fixture.html b/web/e2e-fixtures/events-debug-fixture.html new file mode 100644 index 0000000000..bfe0bce75e --- /dev/null +++ b/web/e2e-fixtures/events-debug-fixture.html @@ -0,0 +1,18 @@ + + + + + + HAPI events debug e2e fixture + + + +
+ + + diff --git a/web/e2e-fixtures/events-debug-fixture.tsx b/web/e2e-fixtures/events-debug-fixture.tsx new file mode 100644 index 0000000000..dfe3110b59 --- /dev/null +++ b/web/e2e-fixtures/events-debug-fixture.tsx @@ -0,0 +1,82 @@ +/* + * Standalone fixture for EventsDebugControls Playwright smoke (#22). + * Mocks ApiClient.fetchSystemEvents so the panel renders without hub auth. + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { AppContextProvider } from '../src/lib/app-context' +import { EventsDebugControls } from '../src/components/settings/EventsDebugControls' +import type { ApiClient } from '../src/api/client' + +declare global { + interface Window { + __eventsDebugE2E?: { + setEvents(events: Array<{ + id: number + ts: number + sourceKind: string + sourceRef: string | null + eventType: string + attentionCandidate: number + summary: string + provenance: string | null + relatedSessionId: string | null + payloadJson: string | null + severity: number | null + }>): void + setError(message: string | null): void + } + } +} + +const sampleEvents = [ + { + id: 1, + ts: Date.UTC(2026, 5, 19, 12, 0, 0), + sourceKind: 'agent', + sourceRef: 'cursor', + eventType: 'completed', + attentionCandidate: 0, + summary: 'Substrate smoke event', + provenance: 'notify_summary', + relatedSessionId: 'sess-smoke-01', + payloadJson: null, + severity: null, + }, +] + +function createMockApi(): ApiClient { + let events = [...sampleEvents] + let shouldFail = false + + const api = { + async fetchSystemEvents() { + if (shouldFail) { + throw new Error('fixture fetch failed') + } + return { total: events.length, events } + }, + } as unknown as ApiClient + + window.__eventsDebugE2E = { + setEvents(next) { + events = next + }, + setError(message) { + shouldFail = message !== null + }, + } + + return api +} + +const root = ReactDOM.createRoot(document.getElementById('root')!) +root.render( + +
+ +
+
+) diff --git a/web/e2e-fixtures/inbox-debug-fixture.html b/web/e2e-fixtures/inbox-debug-fixture.html new file mode 100644 index 0000000000..a23ee16ca8 --- /dev/null +++ b/web/e2e-fixtures/inbox-debug-fixture.html @@ -0,0 +1,12 @@ + + + + + + Inbox debug fixture + + +
+ + + diff --git a/web/e2e-fixtures/inbox-debug-fixture.tsx b/web/e2e-fixtures/inbox-debug-fixture.tsx new file mode 100644 index 0000000000..e7b05a2a8c --- /dev/null +++ b/web/e2e-fixtures/inbox-debug-fixture.tsx @@ -0,0 +1,93 @@ +/* + * Standalone fixture for InboxDebugControls Playwright smoke (#23). + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { AppContextProvider } from '../src/lib/app-context' +import { InboxDebugControls } from '../src/components/settings/InboxDebugControls' +import type { ApiClient } from '../src/api/client' + +declare global { + interface Window { + __inboxDebugE2E?: { + setItems(items: Array<{ + id: number + status: string + priority: number + basePriority: number + title: string + category: string + summary: string + suggestedAction: string | null + reasonForPriority: string | null + sourceEventIds: number[] + relatedSessionId: string | null + createdAt: number + updatedAt: number + }>): void + setError(message: string | null): void + } + } +} + +const sampleItems = [ + { + id: 1, + status: 'new', + priority: 20, + basePriority: 20, + title: 'feat: inbox substrate', + category: 'BLOCKED', + summary: 'CI auth failed on push', + suggestedAction: 'Fix GitHub token', + reasonForPriority: 'BLOCKED tier · queued 12m ago · from event #42', + sourceEventIds: [42], + relatedSessionId: 'sess-smoke-01', + createdAt: Date.UTC(2026, 5, 19, 12, 0, 0), + updatedAt: Date.UTC(2026, 5, 19, 12, 0, 0), + }, +] + +function createMockApi(): ApiClient { + let items = [...sampleItems] + let shouldFail = false + + const api = { + async fetchInboxItems() { + if (shouldFail) { + throw new Error('fixture fetch failed') + } + return { total: items.length, items } + }, + async recordInboxOperatorAction(itemId: number, action: string) { + items = items.map((item) => ( + item.id === itemId + ? { ...item, status: action === 'done' ? 'resolved' : item.status, updatedAt: Date.now() } + : item + )) + return { item: items.find((item) => item.id === itemId) } + }, + } as unknown as ApiClient + + window.__inboxDebugE2E = { + setItems(next) { + items = next + }, + setError(message) { + shouldFail = message !== null + }, + } + + return api +} + +const root = ReactDOM.createRoot(document.getElementById('root')!) +root.render( + +
+ +
+
+) diff --git a/web/e2e-fixtures/mermaid-lightbox-e2e.html b/web/e2e-fixtures/mermaid-lightbox-e2e.html new file mode 100644 index 0000000000..a1cfc7ead9 --- /dev/null +++ b/web/e2e-fixtures/mermaid-lightbox-e2e.html @@ -0,0 +1,16 @@ + + + + + + Mermaid lightbox e2e + + + +
+ + + diff --git a/web/e2e-fixtures/mermaid-lightbox-smoke.html b/web/e2e-fixtures/mermaid-lightbox-smoke.html new file mode 100644 index 0000000000..6e18f25a52 --- /dev/null +++ b/web/e2e-fixtures/mermaid-lightbox-smoke.html @@ -0,0 +1,16 @@ + + + + + + Mermaid lightbox smoke + + + +
+ + + diff --git a/web/e2e/helpers/hapi-live.ts b/web/e2e/helpers/hapi-live.ts new file mode 100644 index 0000000000..60f5aef2f5 --- /dev/null +++ b/web/e2e/helpers/hapi-live.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@playwright/test' + +export function getHapiBaseUrl(): string { + return (process.env.HAPI_URL ?? 'http://127.0.0.1:3006').replace(/\/$/, '') +} + +export function getMermaidTestSessionId(): string { + return process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +} + +export function readCliAccessToken(): string { + if (process.env.HAPI_ACCESS_TOKEN?.trim()) { + return process.env.HAPI_ACCESS_TOKEN.trim() + } + const settingsPath = process.env.HAPI_SETTINGS_PATH ?? join(homedir(), '.hapi', 'settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { cliApiToken?: string } + if (!settings.cliApiToken) { + throw new Error(`Missing cliApiToken in ${settingsPath}`) + } + return settings.cliApiToken +} + +export async function installHapiAuth(page: Page, baseUrl: string, accessToken: string) { + await page.addInitScript(({ token, url }) => { + localStorage.setItem(`hapi_access_token::${url}`, token) + }, { token: accessToken, url: baseUrl }) +} + +export async function scrollChatToBottom(page: Page) { + for (let i = 0; i < 24; i += 1) { + const found = await page.locator('[data-mermaid-diagram][data-rendered="true"]').count() + if (found > 0) break + await page.evaluate(() => { + const scrollers = [...document.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 80, + ) + scrollers.sort((a, b) => b.scrollHeight - a.scrollHeight) + const target = scrollers[0] + if (target) target.scrollTop = target.scrollHeight + window.scrollTo(0, document.body.scrollHeight) + }) + await page.waitForTimeout(400) + } +} + +export type LiveLightboxMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + hasShadowSvg: boolean + shapeTotal: number + coverage: number +} + +export async function readLiveLightboxMetrics(page: Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = + (lightboxSvg?.querySelectorAll('rect').length ?? 0) + + (lightboxSvg?.querySelectorAll('path').length ?? 0) + + (lightboxSvg?.querySelectorAll('line').length ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + hasShadowSvg: Boolean(lightboxSvg), + shapeTotal: shapes, + coverage: Math.max((lightboxBox?.width ?? 0) / vw, (lightboxBox?.height ?? 0) / vh), + } + }) +} diff --git a/web/e2e/mermaid-lightbox-session.spec.ts b/web/e2e/mermaid-lightbox-session.spec.ts new file mode 100644 index 0000000000..f5af7c9e59 --- /dev/null +++ b/web/e2e/mermaid-lightbox-session.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' +import { + getHapiBaseUrl, + getMermaidTestSessionId, + installHapiAuth, + readCliAccessToken, + readLiveLightboxMetrics, + scrollChatToBottom, +} from './helpers/hapi-live' + +const liveEnabled = process.env.HAPI_LIVE === '1' +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_EXPAND_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.25') + +test.describe.configure({ mode: 'serial' }) + +test.describe('mermaid lightbox (live HAPI session)', () => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + test.beforeAll(() => { + readCliAccessToken() + }) + + for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`live session lightbox: ${caseId}`, async ({ page }) => { + const baseUrl = getHapiBaseUrl() + const sessionId = getMermaidTestSessionId() + const token = readCliAccessToken() + + await installHapiAuth(page, baseUrl, token) + await page.goto(`${baseUrl}/sessions/${sessionId}`, { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + await page.waitForTimeout(2000) + await scrollChatToBottom(page) + + const diagramIndex = MERMAID_LIGHTBOX_CASE_IDS.indexOf(caseId) + const rendered = page.locator('[data-mermaid-diagram][data-rendered="true"]') + await expect( + rendered, + `Expected ${MERMAID_LIGHTBOX_CASE_IDS.length} seeded diagrams. Run: bun run seed:mermaid-lightbox:session`, + ).toHaveCount(MERMAID_LIGHTBOX_CASE_IDS.length, { timeout: 20_000 }) + + const diagram = rendered.nth(diagramIndex) + + await diagram.scrollIntoViewIfNeeded() + const before = await diagram.evaluate((el) => { + const svg = el.querySelector('svg') + const box = svg?.getBoundingClientRect() + return { inlineW: box?.width ?? 0, inlineH: box?.height ?? 0 } + }) + + await diagram.click({ timeout: 15_000 }) + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + const lightboxKind = await page.waitForFunction(() => { + const dialog = document.querySelector('[role="dialog"]') + if (!dialog) return 'no-dialog' + const shadowSvg = dialog.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + if (shadowSvg) { + const box = shadowSvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'shadow' + } + const legacySvg = dialog.querySelector('.rounded-lg svg') + if (legacySvg) { + const box = legacySvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'legacy' + } + return 'empty' + }, { timeout: 15_000 }).then((h) => h.jsonValue() as Promise) + + expect( + lightboxKind, + `${caseId}: expected shadow-DOM lightbox (rebuild driver web after feat/mermaid-lightbox-737)`, + ).toBe('shadow') + + const after = await readLiveLightboxMetrics(page) + const areaRatio = + before.inlineW > 0 && before.inlineH > 0 + ? (after.lightboxW * after.lightboxH) / (before.inlineW * before.inlineH) + : 0 + + expect(after.hasShadowSvg, `${caseId}: shadow SVG in live chat`).toBe(true) + expect(after.shapeTotal, `${caseId}: diagram shapes`).toBeGreaterThan(0) + expect(after.coverage, `${caseId}: viewport coverage`).toBeGreaterThanOrEqual(MIN_COVERAGE) + expect( + areaRatio >= MIN_EXPAND_RATIO || after.lightboxW > before.inlineW * 1.05, + `${caseId}: expand ${areaRatio.toFixed(2)}x inline ${Math.round(before.inlineW)}x${Math.round(before.inlineH)} → lightbox ${Math.round(after.lightboxW)}x${Math.round(after.lightboxH)}`, + ).toBe(true) + + if (caseId === 'sequence') { + const seqShapes = await page.evaluate(() => { + const svg = document.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + return { + rect: svg?.querySelectorAll('rect').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + }) + expect(seqShapes.rect >= 2 || seqShapes.line >= 2, `${caseId}: sequence content`).toBe(true) + } + + await page.keyboard.press('Escape') + await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => undefined) + }) + } +}) diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts new file mode 100644 index 0000000000..2f1c7f3452 --- /dev/null +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' + +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') + +type LightboxMetrics = { + hasShadowSvg: boolean + usesDataUrlImg: boolean + svgW: number + svgH: number + coverageW: number + coverageH: number + shapeTotal: number + shapes: { rect: number; path: number; line: number } +} + +type ExpandMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + areaRatio: number +} + +async function readExpandMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const inlineArea = (inlineBox?.width ?? 0) * (inlineBox?.height ?? 0) + const lightboxArea = (lightboxBox?.width ?? 0) * (lightboxBox?.height ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + areaRatio: inlineArea > 0 ? lightboxArea / inlineArea : 0, + } + }) +} + +async function readLightboxMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]') + const host = dialog?.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = { + rect: svg?.querySelectorAll('rect').length ?? 0, + path: svg?.querySelectorAll('path').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + const shapeTotal = + shapes.rect + + shapes.path + + shapes.line + + (svg?.querySelectorAll('text').length ?? 0) + + (svg?.querySelectorAll('circle').length ?? 0) + return { + hasShadowSvg: Boolean(svg), + usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), + svgW: box?.width ?? 0, + svgH: box?.height ?? 0, + coverageW: (box?.width ?? 0) / vw, + coverageH: (box?.height ?? 0) / vh, + shapeTotal, + shapes, + } + }) +} + +function assertMetrics(caseId: string, metrics: LightboxMetrics) { + expect(metrics.hasShadowSvg, `${caseId}: shadow-root svg`).toBe(true) + expect(metrics.usesDataUrlImg, `${caseId}: data-url img regression`).toBe(false) + expect( + metrics.svgW >= MIN_SVG_PX || metrics.svgH >= MIN_SVG_PX, + `${caseId}: svg ${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px`, + ).toBe(true) + const coverage = Math.max(metrics.coverageW, metrics.coverageH) + const wideShort = caseId === 'gantt' || caseId === 'kanban' + if (wideShort) { + expect( + metrics.coverageW >= MIN_COVERAGE || metrics.svgW >= 600, + `${caseId}: wide chart width cov ${(metrics.coverageW * 100).toFixed(0)}%`, + ).toBe(true) + } else { + expect(coverage, `${caseId}: viewport coverage ${(coverage * 100).toFixed(0)}%`).toBeGreaterThanOrEqual( + MIN_COVERAGE, + ) + } + expect(metrics.shapeTotal, `${caseId}: shape count`).toBeGreaterThan(0) + if (caseId === 'sequence') { + expect( + metrics.shapes.rect >= 2 || metrics.shapes.line >= 2, + `${caseId}: sequence actors/lines`, + ).toBe(true) + } +} + +/** Lightbox should be visibly larger than the inline chat preview after click. */ +const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.4') + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`mermaid lightbox: ${caseId}`, async ({ page }) => { + await page.goto(`/e2e-fixtures/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + + const beforeExpand = await readExpandMetrics(page) + expect(beforeExpand.lightboxW, `${caseId}: dialog closed before click`).toBe(0) + + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + return Boolean(box && box.width > 0 && box.height > 0) + }, { timeout: 15_000 }) + + await page + .waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + return Math.max(box.width / vw, box.height / vh) >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8_000 }, + ) + .catch(() => undefined) + + const expand = await readExpandMetrics(page) + const inlineMax = Math.max(expand.inlineW, expand.inlineH) + const lightboxMax = Math.max(expand.lightboxW, expand.lightboxH) + const expandedVisibly = + expand.areaRatio >= MIN_EXPAND_AREA_RATIO || lightboxMax > inlineMax * 1.05 + expect(expandedVisibly, `${caseId}: expand inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)}`).toBe(true) + + const metrics = await readLightboxMetrics(page) + assertMetrics(caseId, metrics) + + test.info().annotations.push({ + type: 'expand', + description: `${caseId}: inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)} (${expand.areaRatio.toFixed(1)}x area)`, + }) + }) +} diff --git a/web/package.json b/web/package.json index ba28bffca1..d676f7253c 100644 --- a/web/package.json +++ b/web/package.json @@ -9,9 +9,15 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts" }, "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@react-three/xr": "^6.6.29", + "@types/three": "^0.184.1", + "three": "^0.184.0", "@assistant-ui/react": "^0.11.53", "@assistant-ui/react-markdown": "^0.11.9", "@elevenlabs/react": "^0.13.0", @@ -37,6 +43,7 @@ "mermaid": "^11.12.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "qrcode": "^1.5.4", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -55,6 +62,7 @@ "@testing-library/react": "^16.3.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/qrcode": "^1.5.6", "@tailwindcss/postcss": "^4.1.18", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", @@ -67,6 +75,7 @@ "typescript": "^5.9.3", "unified": "^11.0.5", "vite": "^7.3.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "@playwright/test": "1.60.0" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000000..e80617226a --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +export default defineConfig({ + testDir: './e2e', + timeout: 45_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:5173', + viewport: { width: 1440, height: 900 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/web/playwright.live.config.ts b/web/playwright.live.config.ts new file mode 100644 index 0000000000..73b93f45af --- /dev/null +++ b/web/playwright.live.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +/** Live hub session tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */ +export default defineConfig({ + testDir: './e2e', + testMatch: 'mermaid-lightbox-session.spec.ts', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1100 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, +}) diff --git a/web/src/App.tsx b/web/src/App.tsx index b8a8f0101d..6c966e3fcb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import { clearMessageWindow, fetchLatestMessages } from '@/lib/message-window-st import { useAppGoBack } from '@/hooks/useAppGoBack' import { useTranslation } from '@/lib/use-translation' import { VoiceProvider } from '@/lib/voice-context' +import { GardenXrLauncher } from '@/garden/GardenXrLauncher' import { requireHubUrlForLogin } from '@/lib/runtime-config' import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib/appSseSubscriptions' import { LoginPrompt } from '@/components/LoginPrompt' @@ -439,18 +440,20 @@ function AppInner() { isSyncing={isSyncing} isReconnecting={sseDisconnected && !isSyncing} /> - - - - -
- -
- - + + + + + +
+ +
+ + +
) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d37e0828bc..8dfc14adaa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -8,9 +8,17 @@ import type { CodexDesktopSyncRequest, CodexDesktopStatusResponse, CodexCollaborationMode, + ClaudeLocalSessionsResponse, + ClaudeImportScriptResponse, + ClaudeImportSyncRequest, + ClaudeStatusResponse, + CursorImportableSessionsResponse, + CursorImportRequest, + CursorImportResponse, FileSearchResponse, MachinesResponse, MessagesResponse, + CodexSessionsResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -237,6 +245,33 @@ export class ApiClient { }) } + async syncClaudeSession(payload?: ClaudeImportSyncRequest): Promise { + // 中文注释:提交本地 transcript 对应的 Claude session ID 列表,后端按这些会话直接导入到 Hapi。 + return await this.request('/api/claude/sync-session', { + method: 'POST', + ...(payload ? { body: JSON.stringify(payload) } : {}) + }) + } + + async getClaudeSessions(): Promise { + return await this.request('/api/claude/sessions') + } + + async getClaudeStatus(): Promise { + return await this.request('/api/claude/status') + } + + async getCursorImportableSessions(): Promise { + return await this.request('/api/cursor/importable-sessions') + } + + async importCursorSessions(payload: CursorImportRequest): Promise { + return await this.request('/api/cursor/import', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise { await this.request('/api/push/subscribe', { method: 'DELETE', @@ -596,14 +631,41 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + resumeSessionId?: string, + importHistory?: boolean ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, resumeSessionId, importHistory }) }) } + + async getMachineCodexSessions( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + const params = new URLSearchParams() + if (options?.includeOld !== undefined) { + params.set('includeOld', options.includeOld ? '1' : '0') + } + if (options?.olderThanDays !== undefined) { + params.set('olderThanDays', String(options.olderThanDays)) + } + if (options?.limit !== undefined) { + params.set('limit', String(options.limit)) + } + if (options?.cursor) { + params.set('cursor', options.cursor) + } + + const query = params.toString() + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/codex-sessions${query ? `?${query}` : ''}` + ) + } + async getMachineCodexModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/codex-models` @@ -679,6 +741,60 @@ export class ApiClient { }) } + /* + * Scratchlist v2 (tiann/hapi#893). + * + * The hub is the durable store; localStorage is demoted to an + * offline cache. Mutations return the canonical entry so optimistic + * updates can reconcile with the hub-stamped `updatedAt`. + */ + + async getScratchlist(sessionId: string): Promise<{ + entries: Array<{ entryId: string; text: string; createdAt: number; updatedAt: number }> + }> { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist` + ) + } + + async createScratchlistEntry( + sessionId: string, + body: { text: string; entryId?: string; createdAt?: number } + ): Promise<{ + entry: { entryId: string; text: string; createdAt: number; updatedAt: number } + }> { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist`, + { + method: 'POST', + body: JSON.stringify(body) + } + ) + } + + async updateScratchlistEntry( + sessionId: string, + entryId: string, + text: string + ): Promise<{ + entry: { entryId: string; text: string; createdAt: number; updatedAt: number } + }> { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`, + { + method: 'PUT', + body: JSON.stringify({ text }) + } + ) + } + + async deleteScratchlistEntry(sessionId: string, entryId: string): Promise { + await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`, + { method: 'DELETE' } + ) + } + async fetchVoiceToken(options?: { customAgentId?: string; customApiKey?: string; voiceId?: string }): Promise<{ allowed: boolean token?: string @@ -741,4 +857,50 @@ export class ApiClient { body: JSON.stringify({}) }) } + + async fetchSystemEvents(params: { + limit?: number + beforeId?: number + sessionId?: string + attentionCandidate?: 0 | 1 + eventType?: string + } = {}): Promise<{ total: number; events: unknown[] }> { + const query = new URLSearchParams() + if (params.limit !== undefined) query.set('limit', String(params.limit)) + if (params.beforeId !== undefined) query.set('beforeId', String(params.beforeId)) + if (params.sessionId) query.set('sessionId', params.sessionId) + if (params.attentionCandidate !== undefined) query.set('attentionCandidate', String(params.attentionCandidate)) + if (params.eventType) query.set('eventType', params.eventType) + const suffix = query.toString() + return await this.request(`/api/system-events${suffix ? `?${suffix}` : ''}`) + } + + async fetchInboxItems(params: { + limit?: number + activeOnly?: boolean + sessionId?: string + } = {}): Promise<{ total: number; items: unknown[] }> { + const query = new URLSearchParams() + if (params.limit !== undefined) query.set('limit', String(params.limit)) + if (params.activeOnly) query.set('activeOnly', '1') + if (params.sessionId) query.set('sessionId', params.sessionId) + const suffix = query.toString() + return await this.request(`/api/inbox-items${suffix ? `?${suffix}` : ''}`) + } + + async recordInboxOperatorAction( + inboxItemId: number, + action: import('@hapi/protocol').InboxOperatorAction, + feedback?: string | null, + snoozedUntil?: number | null + ): Promise<{ item: unknown }> { + return await this.request(`/api/inbox-items/${inboxItemId}/actions`, { + method: 'POST', + body: JSON.stringify({ + action, + feedback: feedback ?? undefined, + snoozedUntil: snoozedUntil ?? undefined + }) + }) + } } diff --git a/web/src/components/AgentSessionImportDialog.test.tsx b/web/src/components/AgentSessionImportDialog.test.tsx new file mode 100644 index 0000000000..c10e54c523 --- /dev/null +++ b/web/src/components/AgentSessionImportDialog.test.tsx @@ -0,0 +1,223 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { AgentSessionImportDialog } from './AgentSessionImportDialog' +import type { + AgentImportFlavor, + CodexLocalSessionSummary, + CursorImportableSessionSummary, + CursorImportRowOutcome, + ClaudeLocalSessionSummary +} from '@/types/api' + +interface RenderOpts { + flavor?: AgentImportFlavor + onChangeFlavor?: (flavor: AgentImportFlavor) => void + codexSessions?: CodexLocalSessionSummary[] + currentCodexSessionId?: string | null + isLoadingCodex?: boolean + isPendingCodex?: boolean + onConfirmCodex?: (sessionIds: string[]) => Promise + onRestartCodexDesktop?: () => Promise + cursorSessions?: CursorImportableSessionSummary[] + isLoadingCursor?: boolean + isPendingCursor?: boolean + cursorLastOutcomes?: CursorImportRowOutcome[] | null + onConfirmCursor?: (uuids: string[]) => Promise + claudeSessions?: ClaudeLocalSessionSummary[] + currentClaudeSessionId?: string | null + isLoadingClaude?: boolean + isPendingClaude?: boolean + onConfirmClaude?: (sessionIds: string[]) => Promise +} + +function renderDialog(opts: RenderOpts = {}) { + const onChangeFlavor = opts.onChangeFlavor ?? vi.fn() + const onConfirmCodex = opts.onConfirmCodex ?? vi.fn(async () => {}) + const onConfirmCursor = opts.onConfirmCursor ?? vi.fn(async () => {}) + const onConfirmClaude = opts.onConfirmClaude ?? vi.fn(async () => {}) + const view = render( + + + + ) + return { ...view, onChangeFlavor, onConfirmCodex, onConfirmCursor, onConfirmClaude } +} + +const codexSampleSession: CodexLocalSessionSummary = { + id: 'codex-session-1', + title: 'Codex session title', + lastUserMessage: 'Last prompt', + cwd: '/home/user/project', + file: '/home/user/.codex/sessions/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'codex_cli', + cliVersion: '0.124.0' +} + +const cursorSampleAcp: CursorImportableSessionSummary = { + id: 'cursor-acp-uuid', + title: 'Cursor ACP chat', + firstUserMessage: 'First user prompt', + workspacePath: '/home/user/repo', + storeDbPath: '/home/user/.cursor/acp-sessions/cursor-acp-uuid/store.db', + sourceFormat: 'acp', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5), + sizeBytes: 12_345, + alreadyImportedHapiSessionId: null +} + +const cursorSampleLegacyAlreadyImported: CursorImportableSessionSummary = { + id: 'cursor-legacy-uuid', + title: 'Cursor legacy chat', + workspacePath: '/home/user/other', + storeDbPath: '/home/user/.cursor/chats/wsh/cursor-legacy-uuid/store.db', + sourceFormat: 'legacy', + modifiedAt: Date.UTC(2026, 0, 4, 3, 4, 5), + sizeBytes: 7_777, + alreadyImportedHapiSessionId: 'hapi-existing-id' +} + +const claudeSampleSession: ClaudeLocalSessionSummary = { + id: 'claude-session-1', + title: 'Claude session title', + lastUserMessage: 'Claude prompt', + cwd: '/home/user/claude-project', + file: '/home/user/.claude/projects/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 5, 3, 4, 5), + originator: 'claude_cli', + cliVersion: '1.0.0' +} + +describe('AgentSessionImportDialog', () => { + afterEach(() => { + cleanup() + }) + + it('shows the Codex panel by default and lists Codex sessions', () => { + renderDialog({ codexSessions: [codexSampleSession] }) + expect(screen.getByText('Codex session title')).toBeInTheDocument() + expect(screen.getAllByText('/home/user/project').length).toBeGreaterThan(0) + expect(screen.getByRole('tab', { name: 'Codex' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Cursor' })).toHaveAttribute('aria-selected', 'false') + }) + + it('switches to the Cursor panel when the flavor tab is clicked', () => { + const onChangeFlavor = vi.fn() + renderDialog({ flavor: 'codex', onChangeFlavor }) + fireEvent.click(screen.getByRole('tab', { name: 'Cursor' })) + expect(onChangeFlavor).toHaveBeenCalledWith('cursor') + }) + + it('switches to the Claude panel when the flavor tab is clicked', () => { + const onChangeFlavor = vi.fn() + renderDialog({ flavor: 'codex', onChangeFlavor }) + fireEvent.click(screen.getByRole('tab', { name: 'Claude' })) + expect(onChangeFlavor).toHaveBeenCalledWith('claude') + }) + + it('renders the Claude panel and confirms selection', async () => { + const onConfirmClaude = vi.fn(async () => {}) + renderDialog({ + flavor: 'claude', + claudeSessions: [claudeSampleSession], + onConfirmClaude + }) + expect(screen.getByText('Claude session title')).toBeInTheDocument() + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByText('Import')) + await waitFor(() => expect(onConfirmClaude).toHaveBeenCalledWith(['claude-session-1'])) + }) + + it('renders the Cursor panel with ACP / legacy badges and the ACP-strict hint', () => { + renderDialog({ + flavor: 'cursor', + cursorSessions: [cursorSampleAcp, cursorSampleLegacyAlreadyImported] + }) + expect(screen.getByText('Cursor ACP chat')).toBeInTheDocument() + expect(screen.getByText('Cursor legacy chat')).toBeInTheDocument() + expect(screen.getByText('ACP')).toBeInTheDocument() + expect(screen.getByText('Legacy')).toBeInTheDocument() + expect(screen.getByText('Already imported')).toBeInTheDocument() + // Strict ACP hint visible above the list. + expect(screen.getByText(/acp verify-probe/i)).toBeInTheDocument() + }) + + it('disables the already-imported row and skips it when selecting all', () => { + const onConfirmCursor = vi.fn(async () => {}) + renderDialog({ + flavor: 'cursor', + cursorSessions: [cursorSampleAcp, cursorSampleLegacyAlreadyImported], + onConfirmCursor + }) + fireEvent.click(screen.getByText('Select all')) + fireEvent.click(screen.getByText('Import')) + expect(onConfirmCursor).toHaveBeenCalledTimes(1) + expect(onConfirmCursor).toHaveBeenCalledWith(['cursor-acp-uuid']) + }) + + it('surfaces a per-row refusal chip when the last outcome failed', () => { + const lastOutcomes: CursorImportRowOutcome[] = [ + { + ok: false, + uuid: 'cursor-acp-uuid', + reason: 'verify_load_failed', + message: 'agent acp session/load failed: bad blob graph', + durationMs: 123 + } + ] + renderDialog({ + flavor: 'cursor', + cursorSessions: [cursorSampleAcp], + cursorLastOutcomes: lastOutcomes + }) + expect(screen.getByText('agent acp could not load this chat')).toBeInTheDocument() + expect(screen.getByText(/bad blob graph/)).toBeInTheDocument() + }) + + it('confirms Codex selection and forwards selected session ids', async () => { + const onConfirmCodex = vi.fn(async () => {}) + renderDialog({ + flavor: 'codex', + codexSessions: [codexSampleSession], + onConfirmCodex + }) + const checkbox = screen.getByRole('checkbox') + fireEvent.click(checkbox) + fireEvent.click(screen.getByText('Import')) + await waitFor(() => expect(onConfirmCodex).toHaveBeenCalled()) + expect(onConfirmCodex).toHaveBeenCalledWith(['codex-session-1']) + }) + + it('shows the loading state on the active flavor', () => { + renderDialog({ flavor: 'cursor', isLoadingCursor: true }) + expect(screen.getByText('Loading local Cursor chats…')).toBeInTheDocument() + }) + + it('disables flavor switching while an import is in flight', () => { + renderDialog({ flavor: 'codex', isPendingCodex: true }) + expect(screen.getByRole('tab', { name: 'Cursor' })).toBeDisabled() + }) +}) diff --git a/web/src/components/AgentSessionImportDialog.tsx b/web/src/components/AgentSessionImportDialog.tsx new file mode 100644 index 0000000000..4926fdbf2a --- /dev/null +++ b/web/src/components/AgentSessionImportDialog.tsx @@ -0,0 +1,414 @@ +import { useEffect, useMemo, useState } from 'react' +import type { + AgentImportFlavor, + CodexLocalSessionSummary, + CursorImportableSessionSummary, + CursorImportRefusalReason, + CursorImportRowOutcome, + ClaudeLocalSessionSummary +} from '@/types/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SessionImportPicker, type ImportSessionSummary } from '@/components/SessionImportPicker' +import { useTranslation } from '@/lib/use-translation' + +const CODEX_IMPORT_PICKER_LABELS = { + selectedCount: 'codexSync.confirm.selectedCount', + selectAll: 'codexSync.confirm.selectAll', + clearAll: 'codexSync.confirm.clearAll', + cwdFilter: 'codexSync.confirm.cwdFilter', + cwdFilterAll: 'codexSync.confirm.cwdFilterAll', + cwd: 'codexSync.confirm.cwd', + current: 'codexSync.confirm.current', + loading: 'codexSync.confirm.loading', + empty: 'codexSync.confirm.empty', + emptyForWorkdir: 'codexSync.confirm.emptyForWorkdir' +} as const + +const CURSOR_IMPORT_PICKER_LABELS = { + selectedCount: 'cursorSync.confirm.selectedCount', + selectAll: 'cursorSync.confirm.selectAll', + clearAll: 'cursorSync.confirm.clearAll', + cwdFilter: 'cursorSync.confirm.cwdFilter', + cwdFilterAll: 'cursorSync.confirm.cwdFilterAll', + cwd: 'cursorSync.confirm.cwd', + current: 'cursorSync.confirm.current', + loading: 'cursorSync.confirm.loading', + empty: 'cursorSync.confirm.empty', + emptyForWorkdir: 'cursorSync.confirm.emptyForWorkdir' +} as const + +const CLAUDE_IMPORT_PICKER_LABELS = { + selectedCount: 'claudeSync.confirm.selectedCount', + selectAll: 'claudeSync.confirm.selectAll', + clearAll: 'claudeSync.confirm.clearAll', + cwdFilter: 'claudeSync.confirm.cwdFilter', + cwdFilterAll: 'claudeSync.confirm.cwdFilterAll', + cwd: 'claudeSync.confirm.cwd', + current: 'claudeSync.confirm.current', + loading: 'claudeSync.confirm.loading', + empty: 'claudeSync.confirm.empty', + emptyForWorkdir: 'claudeSync.confirm.emptyForWorkdir' +} as const + +function toCodexImportSession(session: CodexLocalSessionSummary): ImportSessionSummary { + return { + id: session.id, + title: session.title, + lastUserMessage: session.lastUserMessage, + cwd: session.cwd, + modifiedAt: session.modifiedAt, + originator: session.originator, + cliVersion: session.cliVersion + } +} + +function toCursorImportSession(session: CursorImportableSessionSummary): ImportSessionSummary { + return { + id: session.id, + title: session.title, + lastUserMessage: session.firstUserMessage, + cwd: session.workspacePath, + modifiedAt: session.modifiedAt + } +} + +function toClaudeImportSession(session: ClaudeLocalSessionSummary): ImportSessionSummary { + return { + id: session.id, + title: session.title, + lastUserMessage: session.lastUserMessage, + cwd: session.cwd, + modifiedAt: session.modifiedAt, + originator: session.originator, + cliVersion: session.cliVersion + } +} + +function cursorRefusalKey(reason: CursorImportRefusalReason): string { + return `cursorSync.refusal.${reason}` +} + +export function AgentSessionImportDialog(props: { + isOpen: boolean + onClose: () => void + flavor: AgentImportFlavor + onChangeFlavor: (flavor: AgentImportFlavor) => void + codexSessions: CodexLocalSessionSummary[] + currentCodexSessionId: string | null + isLoadingCodex: boolean + isPendingCodex: boolean + isRestartingCodexDesktop: boolean + onConfirmCodex: (sessionIds: string[]) => Promise + onRestartCodexDesktop: () => Promise + cursorSessions: CursorImportableSessionSummary[] + isLoadingCursor: boolean + isPendingCursor: boolean + cursorLastOutcomes: CursorImportRowOutcome[] | null + onConfirmCursor: (uuids: string[]) => Promise + claudeSessions: ClaudeLocalSessionSummary[] + currentClaudeSessionId: string | null + isLoadingClaude: boolean + isPendingClaude: boolean + onConfirmClaude: (sessionIds: string[]) => Promise +}) { + const { t } = useTranslation() + const { + isOpen, + onClose, + flavor, + onChangeFlavor, + codexSessions, + currentCodexSessionId, + isLoadingCodex, + isPendingCodex, + isRestartingCodexDesktop, + onConfirmCodex, + onRestartCodexDesktop, + cursorSessions, + isLoadingCursor, + isPendingCursor, + cursorLastOutcomes, + onConfirmCursor, + claudeSessions, + currentClaudeSessionId, + isLoadingClaude, + isPendingClaude, + onConfirmClaude + } = props + + const [selectedCodexIds, setSelectedCodexIds] = useState([]) + const [selectedCursorIds, setSelectedCursorIds] = useState([]) + const [selectedClaudeIds, setSelectedClaudeIds] = useState([]) + + const isPending = flavor === 'codex' + ? isPendingCodex + : flavor === 'cursor' + ? isPendingCursor + : isPendingClaude + const isLoading = flavor === 'codex' + ? isLoadingCodex + : flavor === 'cursor' + ? isLoadingCursor + : isLoadingClaude + + const codexImportSessions = useMemo( + () => codexSessions.map(toCodexImportSession), + [codexSessions] + ) + const cursorImportSessions = useMemo( + () => cursorSessions.map(toCursorImportSession), + [cursorSessions] + ) + const claudeImportSessions = useMemo( + () => claudeSessions.map(toClaudeImportSession), + [claudeSessions] + ) + + const cursorSessionsById = useMemo(() => { + const map = new Map() + for (const session of cursorSessions) { + map.set(session.id, session) + } + return map + }, [cursorSessions]) + + const outcomesByUuid = useMemo(() => { + const map = new Map() + for (const outcome of cursorLastOutcomes ?? []) { + map.set(outcome.uuid, outcome) + } + return map + }, [cursorLastOutcomes]) + + useEffect(() => { + if (!isOpen) { + setSelectedCodexIds([]) + setSelectedCursorIds([]) + setSelectedClaudeIds([]) + } + }, [isOpen]) + + const handleConfirm = async () => { + if (flavor === 'codex') { + if (selectedCodexIds.length === 0 || isPendingCodex || isLoadingCodex) return + await onConfirmCodex(selectedCodexIds) + return + } + if (flavor === 'cursor') { + if (selectedCursorIds.length === 0 || isPendingCursor || isLoadingCursor) return + await onConfirmCursor(selectedCursorIds) + return + } + if (selectedClaudeIds.length === 0 || isPendingClaude || isLoadingClaude) return + await onConfirmClaude(selectedClaudeIds) + } + + return ( + !open && onClose()}> + +
+ + {t('agentImport.confirm.title')} + + {flavor === 'codex' + ? t('codexSync.confirm.description') + : flavor === 'cursor' + ? t('cursorSync.confirm.description') + : t('claudeSync.confirm.description')} + + + {flavor === 'codex' ? ( + + ) : null} +
+ +
+ + + +
+ + {flavor === 'codex' ? ( + + ) : flavor === 'cursor' ? ( + <> +
+ {t('cursorSync.confirm.acpStrictHint')} +
+ { + const raw = cursorSessionsById.get(session.id) + return Boolean(raw?.alreadyImportedHapiSessionId) + }} + renderSessionBadges={(session) => { + const raw = cursorSessionsById.get(session.id) + if (!raw) return null + const outcome = outcomesByUuid.get(session.id) + return ( + <> + + {raw.sourceFormat === 'acp' + ? t('cursorSync.confirm.sourceAcp') + : t('cursorSync.confirm.sourceLegacy')} + + {raw.alreadyImportedHapiSessionId ? ( + + {t('cursorSync.confirm.alreadyImported')} + + ) : null} + {outcome?.ok ? ( + + {t('cursorSync.outcome.ok')} + + ) : null} + + ) + }} + renderSessionFooter={(session) => { + const outcome = outcomesByUuid.get(session.id) + if (!outcome || outcome.ok) return null + return ( +
+
+ {t(cursorRefusalKey(outcome.reason))} +
+
+ {outcome.message} +
+
+ ) + }} + /> + + ) : ( + + )} + +
+ + +
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx new file mode 100644 index 0000000000..ad5d4e5ef5 --- /dev/null +++ b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx @@ -0,0 +1,188 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { AgentBudgetAxis, AgentBudgetEffectiveState, AgentBudgetState } from '@hapi/protocol/types' + +// Flavor-agnostic budget indicator. Consumes a normalized AgentBudgetState +// (see shared/src/agentBudget.ts) so it works for any agent flavor that +// can produce that shape - Codex today, Claude / Cursor / Gemini under +// umbrella tiann/hapi#846 as their adapters land. +// +// Visual contract (operator review 2026-06-09): +// - Centre number = state.operationalAxisId's pressure (usually context). +// Stays consistent across all account states so the gauge never +// silently changes meaning between context-fill and usage-exhaustion. +// - Ring colour = state.effective (green / amber / red / blocked). +// Computed by the flavor adapter using its specific blocking rules +// (e.g. Codex Pro credits cover an exhausted subscription window so +// weekly=100% with credits>0 is amber, not red). +// - Popover = full axis breakdown + metadata rows, with the dominant +// axis row carrying a left-accent + bold so the user can see at a +// glance why the ring colour landed where it did. + +type EffectivePalette = { + ring: string + text: string + accent: string +} + +const PALETTE: Record = { + green: { ring: 'var(--app-link)', text: 'var(--app-hint)', accent: 'var(--app-link)' }, + amber: { ring: '#b45309', text: '#b45309', accent: '#b45309' }, + red: { ring: '#991b1b', text: '#991b1b', accent: '#991b1b' }, + blocked: { ring: '#991b1b', text: '#991b1b', accent: '#991b1b' }, + // Unknown should never reach the renderer (adapter returns null + // instead, hiding the indicator) - mapped to green defensively. + unknown: { ring: 'var(--app-link)', text: 'var(--app-hint)', accent: 'var(--app-link)' } +} + +function operationalAxis(state: AgentBudgetState): AgentBudgetAxis | undefined { + return state.axes.find((axis) => axis.id === state.operationalAxisId) +} + +export function AgentBudgetIndicator(props: { state: AgentBudgetState | null | undefined; popoverTitle?: string }) { + const [open, setOpen] = useState(false) + const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null) + const buttonRef = useRef(null) + + const updatePosition = useCallback(() => { + const button = buttonRef.current + if (!button) return + const rect = button.getBoundingClientRect() + const width = 288 + const margin = 8 + const maxLeft = Math.max(margin, window.innerWidth - width - margin) + setPosition({ + left: Math.min(Math.max(margin, rect.right - width), maxLeft), + bottom: Math.max(margin, window.innerHeight - rect.top + margin) + }) + }, []) + + useLayoutEffect(() => { + if (!open) return + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + return () => { + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [open, updatePosition]) + + useLayoutEffect(() => { + if (!open) return + const handlePointerDown = (e: PointerEvent) => { + if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open]) + + if (!props.state) return null + + const state = props.state + const opAxis = operationalAxis(state) + // The ring centre is the operational axis pressure; ring fill is the + // EFFECTIVE pressure (max axis) so a red-effective state with low + // context still visibly fills the ring. Without this, an amber/red + // state with context=20 would render 'amber colour, 20% fill' which + // looks like a healthy account. + const opPressure = opAxis ? opAxis.pressure : 0 + const effectivePressure = Math.max(...state.axes.map((a) => a.pressure), 0) + const palette = PALETTE[state.effective] + const fillPercent = state.effective === 'blocked' ? 100 : Math.max(opPressure, effectivePressure) + const background = `conic-gradient(${palette.ring} ${fillPercent * 3.6}deg, var(--app-divider) 0deg)` + const centreNumber = Math.round(opPressure) + const tooltip = state.effectiveReason + + return ( +
+ + {open && position ? ( +
+
+ {props.popoverTitle ?? 'Agent Budget'} +
+
{tooltip}
+
+ {state.axes.map((axis) => { + const isDominant = state.dominantAxisId === axis.id + const isCritical = axis.critical === true + const isCovering = axis.covering === true + const labelColor = isCritical ? '#991b1b' : 'var(--app-fg)' + const valueColor = isCritical ? '#991b1b' : 'var(--app-fg)' + const borderColor = isCritical + ? '#991b1b' + : isDominant + ? palette.accent + : isCovering + ? 'var(--app-link)' + : 'transparent' + const emphasised = isCritical || isDominant + return ( +
+
+
{axis.label}
+ {axis.detail ? ( +
{axis.detail}
+ ) : null} +
+
{axis.valueText}
+
+ ) + })} + {state.metadata && state.metadata.length > 0 ? ( +
+ {state.metadata.map((row) => ( +
+
+
{row.label}
+ {row.detail ? ( +
{row.detail}
+ ) : null} +
+
{row.value}
+
+ ))} +
+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 0bb8484b11..f11e11a269 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,4 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { CodexUsage } from '@hapi/protocol/types' import type { ConversationStatus } from '@/realtime/types' import { useTranslation } from '@/lib/use-translation' import { ScheduleIcon } from '@/components/icons' @@ -6,7 +8,8 @@ import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' -import { useRef, useState } from 'react' +import { AgentBudgetIndicator } from './AgentBudgetIndicator' +import { toCodexBudgetState } from './codexBudgetAdapter' function ChevronIcon() { return @@ -430,6 +433,11 @@ export function UnifiedButton(props: { ) } +function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { + const state = toCodexBudgetState(props.usage) + return +} + export function ComposerButtons(props: { canSend: boolean controlsDisabled: boolean @@ -477,6 +485,7 @@ export function ComposerButtons(props: { scratchlistMode?: boolean scratchlistCount?: number onScratchlistToggle?: () => void + codexUsage?: CodexUsage | null }) { const { t } = useTranslation() const isVoiceConnected = props.voiceStatus === 'connected' @@ -661,29 +670,32 @@ export function ComposerButtons(props: { ) : null} - +
+ + +
) } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 10287ae6e7..81d47ca009 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -13,6 +13,7 @@ import { useState } from 'react' import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary, ThreadGoal } from '@/types/api' +import type { CodexUsage } from '@hapi/protocol/types' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' @@ -98,6 +99,7 @@ export function HappyComposer(props: { contextSize?: number contextCacheRead?: number contextWindow?: number | null + codexUsage?: CodexUsage | null controlledByUser?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> @@ -168,6 +170,7 @@ export function HappyComposer(props: { contextSize, contextCacheRead, contextWindow, + codexUsage, controlledByUser = false, agentFlavor, availableModelOptions, @@ -1282,6 +1285,7 @@ export function HappyComposer(props: { scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} onScratchlistToggle={props.onScratchlistToggle} + codexUsage={agentFlavor === 'codex' ? codexUsage : undefined} /> diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index b361f58f58..a366c5dd48 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -13,6 +13,7 @@ import { Spinner } from '@/components/Spinner' import { useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { useTranslation } from '@/lib/use-translation' import { CloseIcon } from '@/components/icons' +import { SessionLogPanel } from '@/components/AssistantChat/SessionLogPanel' type ScrollAnchor = { id: string @@ -263,6 +264,8 @@ export function HappyThread(props: { outlineItems: readonly ConversationOutlineItem[] onOutlineOpenChange: (open: boolean) => void onOutlineItemClick?: (item: ConversationOutlineItem) => void + sessionLogOpen?: boolean + onSessionLogOpenChange?: (open: boolean) => void }) { const { t } = useTranslation() const { terminalToolDisplayMode } = useTerminalToolDisplayMode() @@ -771,6 +774,22 @@ export function HappyThread(props: { /> ) : null} + {props.sessionLogOpen ? ( + <> + + + + ) +} diff --git a/web/src/components/AssistantChat/ScratchlistPanel.test.tsx b/web/src/components/AssistantChat/ScratchlistPanel.test.tsx index 79cd55c6bb..47768e7c03 100644 --- a/web/src/components/AssistantChat/ScratchlistPanel.test.tsx +++ b/web/src/components/AssistantChat/ScratchlistPanel.test.tsx @@ -306,4 +306,72 @@ describe('ScratchlistPanel', () => { expect(b.getByText('B note')).toBeTruthy() expect(b.queryByText('A note')).toBeNull() }) + + it('renders the per-entry age indicator with a tooltip showing the smart-relative time', () => { + // 5 minutes ago, deterministic via fake timers below. + vi.useFakeTimers() + const now = new Date('2026-06-13T17:00:00Z').getTime() + vi.setSystemTime(new Date(now)) + try { + persistScratchlist(SID, [ + makeEntry({ + id: 'aged', + text: 'aged note', + createdAt: now - 10 * 60_000, + updatedAt: now - 5 * 60_000, + }), + ]) + renderPanel() + expandPanel() + const indicator = screen.getByTestId('scratchlist-entry-age') + // Tooltip carries the smart-relative time + an absolute + // timestamp; aria-label carries the relative time only. + const title = indicator.getAttribute('title') ?? '' + expect(title).toContain('5m ago') + expect(title).toContain('Saved') + const aria = indicator.getAttribute('aria-label') ?? '' + expect(aria).toContain('5m ago') + // data-entry-age mirrors the relative bucket so a future + // assertion can target it without scraping the title. + expect(indicator.getAttribute('data-entry-age')).toBe('5m ago') + } finally { + vi.useRealTimers() + } + }) + + it('falls back to createdAt when updatedAt is absent (legacy v1 row)', () => { + vi.useFakeTimers() + const now = new Date('2026-06-13T17:00:00Z').getTime() + vi.setSystemTime(new Date(now)) + try { + persistScratchlist(SID, [ + makeEntry({ + id: 'legacy', + text: 'legacy v1 note', + createdAt: now - 2 * 60 * 60_000, + // updatedAt deliberately omitted - simulates a + // localStorage row written by v1 before the v2 hub + // sync work added the column. + }), + ]) + renderPanel() + expandPanel() + const indicator = screen.getByTestId('scratchlist-entry-age') + expect(indicator.getAttribute('data-entry-age')).toBe('2h ago') + } finally { + vi.useRealTimers() + } + }) + + it('renders no age indicator when both timestamps are unusable', () => { + // The schema validator (`isEntry`) rejects rows with a + // non-finite `createdAt`, so the only realistic path to a + // missing-stamp entry is rendering an in-memory entry directly. + // We simulate that by writing a row with a sentinel `0` + // createdAt - the indicator returns null per its guard. + persistScratchlist(SID, [makeEntry({ id: 'no-stamp', text: 'note', createdAt: 0 })]) + renderPanel() + expandPanel() + expect(screen.queryByTestId('scratchlist-entry-age')).toBeNull() + }) }) diff --git a/web/src/components/AssistantChat/ScratchlistPanel.tsx b/web/src/components/AssistantChat/ScratchlistPanel.tsx index f684b69229..c34fca95c8 100644 --- a/web/src/components/AssistantChat/ScratchlistPanel.tsx +++ b/web/src/components/AssistantChat/ScratchlistPanel.tsx @@ -20,6 +20,7 @@ import { } from '@/lib/scratchlist' import { safeCopyToClipboard } from '@/lib/clipboard' import { useTranslation } from '@/lib/use-translation' +import { formatAbsoluteDateTime, formatRelativeTime } from '@/lib/relative-time' const STORAGE_KEY_PREFIX = 'hapi.scratchlist-collapsed.v1.' @@ -150,6 +151,57 @@ function CopyIcon() { ) } +function ClockIcon() { + return ( + + ) +} + +/** + * Per-entry age indicator: clock icon with a tooltip showing + * smart-relative time (e.g. "2m ago") and the absolute timestamp on a + * second line, so an operator can tell at-a-glance how stale a note is. + * + * Renders nothing when no usable timestamp is available - this happens + * for legacy localStorage entries that pre-date the v2 hub-sync work + * (no `updatedAt` recorded) AND have no `createdAt` either, which is + * vanishingly rare but still a guard against `NaN` titles. + * + * Falls back to `createdAt` when `updatedAt` is missing so newly-loaded + * v1-only rows still get a useful tooltip during the migration window. + */ +function EntryAgeIndicator({ + entry, +}: { + entry: ScratchlistEntry +}) { + const { t } = useTranslation() + const stamp = entry.updatedAt ?? entry.createdAt + if (!Number.isFinite(stamp) || stamp <= 0) return null + const relative = formatRelativeTime(stamp, t) + if (!relative) return null + const absolute = formatAbsoluteDateTime(stamp) + const ariaLabel = t('scratchlist.entry.lastSavedAriaLabel', { time: relative }) + const title = absolute + ? `${t('scratchlist.entry.lastSaved', { time: relative })}\n${absolute}` + : t('scratchlist.entry.lastSaved', { time: relative }) + return ( + + + + ) +} + function ClipboardCheckIcon() { return (
+ + +
+ +
+ + +
+ +
+ {error || olderError ? ( +
+ {error ?? olderError} +
+ ) : null} + {isLoading && events.length === 0 ? ( +
+ + {t('misc.loading')} +
+ ) : events.length === 0 ? ( +
+ {filter === 'links' ? t('session.log.emptyLinks') : t('session.log.empty')} +
+ ) : ( +
    + {events.map((event) => ( + + ))} +
+ )} +
+ + {!reachedEnd && events.length > 0 ? ( +
+ +
+ ) : null} + + ) +} diff --git a/web/src/components/AssistantChat/codexBudgetAdapter.test.ts b/web/src/components/AssistantChat/codexBudgetAdapter.test.ts new file mode 100644 index 0000000000..df6f7a7b99 --- /dev/null +++ b/web/src/components/AssistantChat/codexBudgetAdapter.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import type { CodexUsage } from '@hapi/protocol/types' +import { toCodexBudgetState } from './codexBudgetAdapter' + +describe('toCodexBudgetState', () => { + it('returns null when usage is empty', () => { + expect(toCodexBudgetState(null)).toBeNull() + expect(toCodexBudgetState(undefined)).toBeNull() + expect(toCodexBudgetState({ rateLimits: {} })).toBeNull() + }) + + it('builds a healthy state from a fresh Plus account', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 20_000, limitTokens: 100_000, percent: 20, updatedAt: 1 }, + rateLimits: { + fiveHour: { usedPercent: 30, windowMinutes: 300 }, + weekly: { usedPercent: 10, windowMinutes: 10080 } + } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('green') + expect(state?.operationalAxisId).toBe('context') + expect(state?.dominantAxisId).toBe('fiveHour') + expect(state?.axes.map((axis) => axis.id)).toEqual(['context', 'fiveHour', 'weekly']) + }) + + it('flags amber when context fills past 60% with no rate-limit pressure', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 70_000, limitTokens: 100_000, percent: 70, updatedAt: 1 }, + rateLimits: {} + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('amber') + expect(state?.dominantAxisId).toBe('context') + }) + + it('flags red when any axis crosses 95% (subscription cap with no credits to cover)', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 20_000, limitTokens: 100_000, percent: 20, updatedAt: 1 }, + rateLimits: { + fiveHour: { usedPercent: 5, windowMinutes: 300 }, + weekly: { usedPercent: 96, windowMinutes: 10080 } + } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('red') + expect(state?.dominantAxisId).toBe('weekly') + expect(state?.effectiveReason).toContain('1 Week Usage') + }) + + it('flags amber covering when weekly is at 100% but credits remain (Pro account)', () => { + // This is the operator's actual state on 2026-06-09: weekly=100% + // (subscription window exhausted) but credits=246 available, so + // codex falls back to credit-billing. Previous design rendered + // red 100, which was technically true (weekly is capped) but + // operationally misleading (user can still send). + const usage: CodexUsage = { + contextWindow: { usedTokens: 207_000, limitTokens: 258_400, percent: 80, updatedAt: 1 }, + rateLimits: { + fiveHour: { usedPercent: 1, windowMinutes: 300 }, + weekly: { usedPercent: 100, windowMinutes: 10080 } + }, + credits: { hasCredits: true, unlimited: false, balance: '246.0000000000' }, + limitId: 'premium' + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('amber') + expect(state?.effectiveReason).toContain('credits covering') + expect(state?.operationalAxisId).toBe('context') + // Dominant should still be weekly (highest-pressure non-credits axis) + expect(state?.dominantAxisId).toBe('weekly') + const creditsAxis = state?.axes.find((axis) => axis.id === 'credits') + expect(creditsAxis?.covering).toBe(true) + expect(creditsAxis?.valueText).toBe('246') + }) + + it('flags blocked when subscription + credits both exhausted', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 207_000, limitTokens: 258_400, percent: 80, updatedAt: 1 }, + rateLimits: {}, + credits: { hasCredits: false, unlimited: false, balance: '0' }, + limitId: 'premium' + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('blocked') + // Dominant: credits (pressure 100, critical) + expect(state?.dominantAxisId).toBe('credits') + const creditsAxis = state?.axes.find((axis) => axis.id === 'credits') + expect(creditsAxis?.critical).toBe(true) + expect(creditsAxis?.valueText).toBe('0') + // Operational axis stays as context (centre number = how-much-room-for-task) + expect(state?.operationalAxisId).toBe('context') + }) + + it('flags blocked during the transition shape (both windows at 100, credits 0, windows still present)', () => { + // Cold-review finding 2026-06-09: before codex nulls out the + // exhausted windows it briefly emits both primary AND secondary + // with usedPercent=100 alongside credits.has_credits=false. + // Earlier logic only treated 'windows absent' as blocked, missing + // this transition shape - effective state landed as 'red' with + // generic 'Credits 100%' reason instead of the explicit 'Blocked'. + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 }, + weekly: { usedPercent: 100, windowMinutes: 10080 } + }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('blocked') + expect(state?.effectiveReason).toContain('Blocked') + }) + + it('does not flag blocked when only one window is capped (the other still has room)', () => { + // 5h at cap during weekly reset window - user can still send via + // the weekly bucket. Should be red (5h at cap) not blocked. + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 }, + weekly: { usedPercent: 30, windowMinutes: 10080 } + }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('red') + }) + + it('flags blocked with the codex reached-type code when codex sets one', () => { + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 }, + weekly: { usedPercent: 100, windowMinutes: 10080 } + }, + rateLimitReachedType: 'weekly' + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('blocked') + expect(state?.effectiveReason).toContain('Weekly') + expect(state?.metadata?.[0]).toEqual({ label: 'Limit Reached', value: 'Weekly' }) + }) + + it('appends token breakdown metadata when reported', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 20_000, limitTokens: 100_000, percent: 20, updatedAt: 1 }, + rateLimits: {}, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + const state = toCodexBudgetState(usage) + const tokenRow = state?.metadata?.find((row) => row.label === 'Token Breakdown') + expect(tokenRow?.value).toBe('2k') + expect(tokenRow?.detail).toContain('input 1k') + }) + + it('does not flag blocked when credits.unlimited even with balance reading 0', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 10_000, limitTokens: 100_000, percent: 10, updatedAt: 1 }, + rateLimits: {}, + credits: { hasCredits: true, unlimited: true, balance: '0' } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('green') + const creditsAxis = state?.axes.find((axis) => axis.id === 'credits') + expect(creditsAxis?.valueText).toBe('Unlimited') + expect(creditsAxis?.critical).toBeFalsy() + }) + + it('handles a fresh-session payload (token_count before context-window stats arrive)', () => { + // Early in a session, codex sometimes emits rate_limits without a + // contextWindow. The adapter must still produce a usable state + // with an operational axis fallback. + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 12, windowMinutes: 300 } + } + } + const state = toCodexBudgetState(usage) + expect(state?.operationalAxisId).toBe('fiveHour') + expect(state?.effective).toBe('green') + }) + + it('chooses an operational axis fallback when context is absent', () => { + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 30, windowMinutes: 300 }, + weekly: { usedPercent: 60, windowMinutes: 10080 } + } + } + const state = toCodexBudgetState(usage) + // No context -> pick the highest-pressure axis as operational so + // the ring centre still shows the worst non-credits pressure. + expect(state?.operationalAxisId).toBe('weekly') + }) +}) diff --git a/web/src/components/AssistantChat/codexBudgetAdapter.ts b/web/src/components/AssistantChat/codexBudgetAdapter.ts new file mode 100644 index 0000000000..93734d7a27 --- /dev/null +++ b/web/src/components/AssistantChat/codexBudgetAdapter.ts @@ -0,0 +1,294 @@ +import type { + AgentBudgetAxis, + AgentBudgetEffectiveState, + AgentBudgetMetadataRow, + AgentBudgetState, + CodexTokenUsage, + CodexUsage, + CodexUsageRateLimit +} from '@hapi/protocol/types' + +// Codex-specific adapter that maps a CodexUsage payload into the +// flavor-agnostic AgentBudgetState the indicator consumes. All +// Codex-specific terminology (5h / weekly / credits / plan_type / etc) +// lives here, not in the renderer. + +// Codex sends balance as a precision-preserving string ('250.0000000000', +// '0', '0.0000000000'). Number() handles all of those uniformly without +// risking a literal-match miss on a new trailing-zero variant. +export function parseCreditsBalance(raw: string | undefined): number | null { + if (raw === undefined) return null + const trimmed = raw.trim() + if (trimmed.length === 0) return null + const n = Number(trimmed) + return Number.isFinite(n) ? n : null +} + +// Subscription-and-credits exhausted. Codex has two payload shapes here: +// (a) post-exhaustion: rate_limits.primary=null + secondary=null + +// credits.has_credits=false (steady state once windows have fully +// fallen back to credit billing). +// (b) transition: both rate_limits.primary and .secondary present with +// usedPercent=100 alongside credits.has_credits=false. Brief window +// before codex nulls the rate limits out, but the user IS blocked +// during it. +// Either shape, or an explicit rate_limit_reached_type, should land in +// the 'blocked' effective state so the indicator surfaces the hard cap +// with consistent messaging instead of a less-specific 'red 100%'. +export function isCodexUsageBlocked(usage: CodexUsage | null | undefined): boolean { + if (!usage) return false + if (usage.credits?.unlimited) return false + const reachedType = typeof usage.rateLimitReachedType === 'string' && usage.rateLimitReachedType.length > 0 + if (reachedType) return true + + const hasCreditsExplicitlyFalse = usage.credits?.hasCredits === false + const parsedBalance = parseCreditsBalance(usage.credits?.balance) + const balanceZero = parsedBalance !== null && parsedBalance === 0 + const creditsExhausted = hasCreditsExplicitlyFalse || balanceZero + + const fiveHour = usage.rateLimits?.fiveHour + const weekly = usage.rateLimits?.weekly + const noTimeWindows = !fiveHour && !weekly + // Shape (b): both windows present but at the cap. Either-or doesn't + // trigger blocked - one window might cap while the other has room + // (e.g. 5h hit during weekly's reset period). + const bothWindowsCapped = (fiveHour?.usedPercent ?? 0) >= 100 && (weekly?.usedPercent ?? 0) >= 100 + return creditsExhausted && (noTimeWindows || bothWindowsCapped) +} + +export function formatRateLimitReachedType(value: string): string { + return value + .replace(/_/g, ' ') + .replace(/\b\w/g, (ch) => ch.toUpperCase()) +} + +export function formatCodexUsageReset(resetAt: number | undefined, locale?: string): string | null { + if (!resetAt || resetAt <= 0) return null + return new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }).format(new Date(resetAt)) +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(100, Math.max(0, value)) +} + +function formatPercent(value: number): string { + const clamped = clampPercent(value) + return `${clamped >= 10 ? Math.round(clamped) : Math.round(clamped * 10) / 10}%` +} + +function formatTokens(value: number): string { + if (Math.abs(value) >= 1000) { + return `${Math.round(value / 1000)}k` + } + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) +} + +function formatRateLimit(rateLimit: CodexUsageRateLimit): string { + return formatPercent(rateLimit.usedPercent) +} + +function formatTokenBreakdown(usage: CodexTokenUsage): string { + return [ + `input ${formatTokens(usage.inputTokens)}`, + `cached ${formatTokens(usage.cachedInputTokens)}`, + `output ${formatTokens(usage.outputTokens)}`, + `reasoning ${formatTokens(usage.reasoningOutputTokens)}` + ].join(' · ') +} + +// Codex's protocol field is 'balance' with no declared unit. Credits +// are an internal billing token consumed at token-mix-dependent rates +// (per the OpenAI Codex rate card, GPT-5.5 burns 125 credits per 1M +// input tokens / 750 per 1M output, and a $5 top-up grants 125 credits +// ~ $0.04/credit). Render as a bare count; the row label 'Credits' +// carries the unit and any USD conversion belongs in chatgpt.com's +// billing UI, not the indicator. See +// https://help.openai.com/en/articles/20001106-codex-rate-card +function formatCreditsBalance(raw: string): string { + const n = parseCreditsBalance(raw) + if (n === null) return raw + if (n === 0) return '0' + const decimals = Math.abs(n) >= 1 ? 2 : 4 + return n.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals + }) +} + +function formatCreditsValue(credits: NonNullable): string { + if (credits.unlimited) return 'Unlimited' + if (credits.balance !== undefined) return formatCreditsBalance(credits.balance) + if (credits.hasCredits === false) return 'Out' + if (credits.hasCredits === true) return 'Available' + return '-' +} + +// Codex-specific effective-state logic. Honours the Pro-tier billing +// rule that exhausted subscription windows fall back to credit-billing, +// so weekly=100% with credits>0 is amber (covering scenario) rather than +// red (genuinely about to fail). +function deriveEffectiveState( + usage: CodexUsage, + axes: AgentBudgetAxis[] +): { effective: AgentBudgetEffectiveState; reason: string } { + if (isCodexUsageBlocked(usage)) { + const reason = usage.rateLimitReachedType + ? `Blocked: ${formatRateLimitReachedType(usage.rateLimitReachedType)} limit reached` + : 'Blocked: subscription window and credits both exhausted' + return { effective: 'blocked', reason } + } + + const subscriptionCapped = axes.some( + (axis) => (axis.id === 'fiveHour' || axis.id === 'weekly') && axis.pressure >= 100 + ) + const creditsCovering = axes.some((axis) => axis.id === 'credits' && axis.covering === true) + if (subscriptionCapped && creditsCovering) { + const cappedAxis = axes.find( + (axis) => (axis.id === 'fiveHour' || axis.id === 'weekly') && axis.pressure >= 100 + ) + const label = cappedAxis?.label ?? 'Subscription window' + return { + effective: 'amber', + reason: `${label} at cap; credits covering overage` + } + } + + // Credits axis pressure==0 means 'available and not covering' - it + // should not push the gauge into amber/red by itself. + const pressureCandidates = axes.filter((axis) => axis.id !== 'credits' || axis.pressure > 0) + const maxPressure = pressureCandidates.length > 0 + ? Math.max(...pressureCandidates.map((axis) => axis.pressure)) + : 0 + if (maxPressure >= 95) { + const dominantAxis = pressureCandidates.find((axis) => axis.pressure === maxPressure) + return { + effective: 'red', + reason: dominantAxis ? `${dominantAxis.label} ${Math.round(maxPressure)}%` : 'Near cap' + } + } + if (maxPressure >= 60) { + const dominantAxis = pressureCandidates.find((axis) => axis.pressure === maxPressure) + return { + effective: 'amber', + reason: dominantAxis ? `${dominantAxis.label} ${Math.round(maxPressure)}%` : 'Approaching cap' + } + } + return { effective: 'green', reason: 'All budgets well below caps' } +} + +export function toCodexBudgetState(usage: CodexUsage | null | undefined): AgentBudgetState | null { + if (!usage) return null + + const axes: AgentBudgetAxis[] = [] + + if (usage.contextWindow && Number.isFinite(usage.contextWindow.percent)) { + axes.push({ + id: 'context', + label: 'Context Window', + pressure: clampPercent(usage.contextWindow.percent), + valueText: formatPercent(usage.contextWindow.percent), + detail: `${formatTokens(usage.contextWindow.usedTokens)} / ${formatTokens(usage.contextWindow.limitTokens)} tokens` + }) + } + if (usage.rateLimits?.fiveHour) { + const reset = formatCodexUsageReset(usage.rateLimits.fiveHour.resetAt) + axes.push({ + id: 'fiveHour', + label: '5h Usage', + pressure: clampPercent(usage.rateLimits.fiveHour.usedPercent), + valueText: formatRateLimit(usage.rateLimits.fiveHour), + ...(reset ? { detail: `resets ${reset}` } : {}) + }) + } + if (usage.rateLimits?.weekly) { + const reset = formatCodexUsageReset(usage.rateLimits.weekly.resetAt) + axes.push({ + id: 'weekly', + label: '1 Week Usage', + pressure: clampPercent(usage.rateLimits.weekly.usedPercent), + valueText: formatRateLimit(usage.rateLimits.weekly), + ...(reset ? { detail: `resets ${reset}` } : {}) + }) + } + if (usage.credits) { + const parsedBalance = parseCreditsBalance(usage.credits.balance) + const balanceZero = parsedBalance !== null && parsedBalance === 0 + const exhausted = !usage.credits.unlimited && (usage.credits.hasCredits === false || balanceZero) + const subscriptionExists = axes.some((axis) => axis.id === 'fiveHour' || axis.id === 'weekly') + const subscriptionCapped = axes.some( + (axis) => (axis.id === 'fiveHour' || axis.id === 'weekly') && axis.pressure >= 100 + ) + // Credits axis pressure: codex's protocol doesn't expose a + // 'capacity' to derive a true percent against, so the adapter + // picks a pragmatic mapping. When exhausted -> 100 so the row + // participates in dominant-axis selection (rendered critical). + // When credits remain, axis is 'covering' iff at least one + // subscription window is already at cap - that signals 'this + // axis is keeping you going' rather than 'this axis is a + // constraint'. + const pressure = exhausted ? 100 : 0 + const covering = !exhausted && subscriptionExists && subscriptionCapped + axes.push({ + id: 'credits', + label: 'Credits', + pressure, + valueText: formatCreditsValue(usage.credits), + ...(usage.credits.unlimited + ? { detail: 'unlimited' } + : exhausted + ? { detail: 'subscription / top-up exhausted', critical: true } + : covering + ? { detail: 'covering exhausted subscription window', covering: true } + : {}) + }) + } + + if (axes.length === 0) return null + + const metadata: AgentBudgetMetadataRow[] = [] + if (usage.rateLimitReachedType) { + metadata.push({ + label: 'Limit Reached', + value: formatRateLimitReachedType(usage.rateLimitReachedType) + }) + } + if (usage.totalTokenUsage) { + metadata.push({ + label: 'Token Breakdown', + value: formatTokens(usage.totalTokenUsage.totalTokens), + detail: formatTokenBreakdown(usage.totalTokenUsage) + }) + } else if (usage.lastTokenUsage) { + metadata.push({ + label: 'Last Turn Tokens', + value: formatTokens(usage.lastTokenUsage.totalTokens), + detail: formatTokenBreakdown(usage.lastTokenUsage) + }) + } + + const { effective, reason } = deriveEffectiveState(usage, axes) + + const operationalAxisId = axes.find((axis) => axis.id === 'context') + ? 'context' + : axes.reduce((best, axis) => (axis.pressure > best.pressure ? axis : best), axes[0]).id + + const dominantCandidates = axes.filter((axis) => !(axis.id === 'credits' && axis.pressure === 0)) + const dominant = dominantCandidates.length > 0 + ? dominantCandidates.reduce((best, axis) => (axis.pressure > best.pressure ? axis : best), dominantCandidates[0]) + : undefined + + return { + operationalAxisId, + axes, + ...(metadata.length > 0 ? { metadata } : {}), + effective, + effectiveReason: reason, + ...(dominant ? { dominantAxisId: dominant.id } : {}) + } +} diff --git a/web/src/components/ClaudeSessionSyncDialog.test.tsx b/web/src/components/ClaudeSessionSyncDialog.test.tsx new file mode 100644 index 0000000000..0b9381f6a0 --- /dev/null +++ b/web/src/components/ClaudeSessionSyncDialog.test.tsx @@ -0,0 +1,127 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { ClaudeSessionSyncDialog } from './ClaudeSessionSyncDialog' +import type { ClaudeLocalSessionSummary } from '@/types/api' + +function renderDialog( + sessions: ClaudeLocalSessionSummary[], + onConfirm = vi.fn(async () => {}), + currentClaudeSessionId: string | null = null +) { + const view = render( + + + + ) + return { ...view, onConfirm } +} + +describe('ClaudeSessionSyncDialog', () => { + afterEach(() => { + cleanup() + }) + + it('shows the working directory for local Claude sessions', () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Session title', + lastUserMessage: 'Last prompt', + cwd: '/home/user/project', + file: '/home/user/.claude/projects/-home-user-project/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'claude_code', + cliVersion: '1.0.0' + } + ]) + + expect(screen.getByText('Working directory')).toBeInTheDocument() + expect(screen.getAllByText('/home/user/project')).toHaveLength(2) + }) + + it('filters sessions by working directory', () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'claude-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.claude/projects/-home-user-project-two/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ]) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + + expect(screen.queryByText('Project one')).not.toBeInTheDocument() + expect(screen.getByText('Project two')).toBeInTheDocument() + }) + + it('selects only filtered sessions when selecting all and confirms them', async () => { + const onConfirm = vi.fn(async () => {}) + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'claude-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.claude/projects/-home-user-project-two/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ], onConfirm) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Select all' })) + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + expect(onConfirm).toHaveBeenCalledWith(['claude-session-2']) + }) + + it('defaults selection to the linked current Claude session', async () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + } + ], vi.fn(async () => {}), 'claude-session-1') + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByText('Linked')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/ClaudeSessionSyncDialog.tsx b/web/src/components/ClaudeSessionSyncDialog.tsx new file mode 100644 index 0000000000..70ad55e833 --- /dev/null +++ b/web/src/components/ClaudeSessionSyncDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react' +import type { ClaudeLocalSessionSummary } from '@/types/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SessionImportPicker } from '@/components/SessionImportPicker' +import { useTranslation } from '@/lib/use-translation' + +const CLAUDE_IMPORT_PICKER_LABELS = { + selectedCount: 'claudeSync.confirm.selectedCount', + selectAll: 'claudeSync.confirm.selectAll', + clearAll: 'claudeSync.confirm.clearAll', + cwdFilter: 'claudeSync.confirm.cwdFilter', + cwdFilterAll: 'claudeSync.confirm.cwdFilterAll', + cwd: 'claudeSync.confirm.cwd', + current: 'claudeSync.confirm.current', + loading: 'claudeSync.confirm.loading', + empty: 'claudeSync.confirm.empty', + emptyForWorkdir: 'claudeSync.confirm.emptyForWorkdir' +} as const + +export function ClaudeSessionSyncDialog(props: { + isOpen: boolean + onClose: () => void + sessions: ClaudeLocalSessionSummary[] + currentClaudeSessionId: string | null + onConfirm: (sessionIds: string[]) => Promise + isPending: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const { + isOpen, + sessions, + currentClaudeSessionId, + onConfirm, + isPending, + isLoading, + onClose + } = props + const [selectedSessionIds, setSelectedSessionIds] = useState([]) + + useEffect(() => { + if (!isOpen) { + setSelectedSessionIds([]) + } + }, [isOpen]) + + const handleConfirm = async () => { + if (selectedSessionIds.length === 0 || isPending || isLoading) return + + // 中文注释:确认按钮只提交用户勾选的 Claude session,落库由父组件统一处理并给出 toast 提示。 + await onConfirm(selectedSessionIds) + } + + return ( + !open && onClose()}> + + + {t('claudeSync.confirm.title')} + + {t('claudeSync.confirm.description')} + + + + + +
+ + +
+
+
+ ) +} diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx index 3e7b2cd3fa..aedaa80f4d 100644 --- a/web/src/components/CodexSessionSyncDialog.tsx +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import type { CodexLocalSessionSummary } from '@/types/api' import { Dialog, @@ -8,28 +8,21 @@ import { DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { SessionImportPicker } from '@/components/SessionImportPicker' import { useTranslation } from '@/lib/use-translation' -const ALL_WORKDIR_FILTER = '__all__' - -function formatCodexSessionTime(value: number): string | null { - if (!Number.isFinite(value)) return null - return new Date(value).toLocaleString() -} - -function getCodexSessionPreview(session: CodexLocalSessionSummary): string { - if (session.lastUserMessage?.trim()) { - return session.lastUserMessage.trim() - } - - const parts = [session.originator, session.cliVersion].filter(Boolean) - return parts.join(' · ') -} - -function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null { - const cwd = session.cwd?.trim() - return cwd ? cwd : null -} +const CODEX_IMPORT_PICKER_LABELS = { + selectedCount: 'codexSync.confirm.selectedCount', + selectAll: 'codexSync.confirm.selectAll', + clearAll: 'codexSync.confirm.clearAll', + cwdFilter: 'codexSync.confirm.cwdFilter', + cwdFilterAll: 'codexSync.confirm.cwdFilterAll', + cwd: 'codexSync.confirm.cwd', + current: 'codexSync.confirm.current', + loading: 'codexSync.confirm.loading', + empty: 'codexSync.confirm.empty', + emptyForWorkdir: 'codexSync.confirm.emptyForWorkdir' +} as const export function CodexSessionSyncDialog(props: { isOpen: boolean @@ -55,83 +48,13 @@ export function CodexSessionSyncDialog(props: { onClose } = props const [selectedSessionIds, setSelectedSessionIds] = useState([]) - const [hasInitializedSelection, setHasInitializedSelection] = useState(false) - const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER) - const wasOpenRef = useRef(false) - - const sessionIdSet = useMemo( - () => new Set(sessions.map((session) => session.id)), - [sessions] - ) - const selectedSessionIdSet = useMemo( - () => new Set(selectedSessionIds), - [selectedSessionIds] - ) - const workdirOptions = useMemo(() => { - const directories = new Set() - for (const session of sessions) { - const cwd = getCodexSessionCwd(session) - if (cwd) directories.add(cwd) - } - return Array.from(directories).sort((a, b) => a.localeCompare(b)) - }, [sessions]) - const filteredSessions = useMemo(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return sessions - return sessions.filter((session) => getCodexSessionCwd(session) === workdirFilter) - }, [sessions, workdirFilter]) useEffect(() => { - if (isOpen && !wasOpenRef.current) { - wasOpenRef.current = true - setSelectedSessionIds([]) - setHasInitializedSelection(false) - setWorkdirFilter(ALL_WORKDIR_FILTER) - return - } - - if (!isOpen && wasOpenRef.current) { - wasOpenRef.current = false + if (!isOpen) { setSelectedSessionIds([]) - setHasInitializedSelection(false) - setWorkdirFilter(ALL_WORKDIR_FILTER) } }, [isOpen]) - useEffect(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return - if (workdirOptions.includes(workdirFilter)) return - setWorkdirFilter(ALL_WORKDIR_FILTER) - }, [workdirFilter, workdirOptions]) - - useEffect(() => { - if (!isOpen || isLoading || hasInitializedSelection) return - - // 中文注释:弹窗打开后等本地 Codex 会话列表加载完成,再尝试默认勾选当前 Hapi 会话关联的 Codex thread,避免异步加载时默认值丢失。 - const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId) - ? [currentCodexSessionId] - : [] - setSelectedSessionIds(defaultSelected) - setHasInitializedSelection(true) - }, [currentCodexSessionId, hasInitializedSelection, isLoading, isOpen, sessionIdSet]) - - const toggleSession = (sessionId: string) => { - if (isPending || isLoading) return - - // 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。 - setSelectedSessionIds((current) => current.includes(sessionId) - ? current.filter((id) => id !== sessionId) - : [...current, sessionId]) - } - - const selectAll = () => { - setSelectedSessionIds(filteredSessions.map((session) => session.id)) - } - - const clearAll = () => { - // 中文注释:全取消放在左侧,和底部“取消 / 导入”的左右语义保持一致。 - setSelectedSessionIds([]) - } - const handleConfirm = async () => { if (selectedSessionIds.length === 0 || isPending || isLoading) return @@ -164,121 +87,16 @@ export function CodexSessionSyncDialog(props: { -
-
-
- {t('codexSync.confirm.selectedCount', { n: selectedSessionIds.length })} -
-
- - -
-
- - {sessions.length > 0 ? ( - - ) : null} - -
- {isLoading ? ( -
- {t('codexSync.confirm.loading')} -
- ) : sessions.length === 0 ? ( -
- {t('codexSync.confirm.empty')} -
- ) : filteredSessions.length === 0 ? ( -
- {t('codexSync.confirm.emptyForWorkdir')} -
- ) : ( -
- {filteredSessions.map((session) => { - const checked = selectedSessionIdSet.has(session.id) - const time = formatCodexSessionTime(session.modifiedAt) - const preview = getCodexSessionPreview(session) - const cwd = getCodexSessionCwd(session) - return ( - - ) - })} -
- )} -
-
+
+ + {onExport ? (
) : null} + {/* + * tiann/hapi#893: one-time banner shown on first + * v2-load when localStorage entries got migrated to + * the hub. Sits above the drawer so the operator + * sees it whether or not the drawer is open. + * Auto-renders nothing unless `migrationStatus === + * 'completed'`. + */} + +
{/* * Scratchlist drawer - composer-controlled. Only @@ -1255,6 +1311,7 @@ function SessionChatInner(props: SessionChatProps) { contextSize={reduced.latestUsage?.contextSize} contextCacheRead={reduced.latestUsage?.cacheRead} contextWindow={reduced.latestUsage?.contextWindow} + codexUsage={agentFlavor === 'codex' ? props.session.metadata?.codexUsage : undefined} controlledByUser={controlledByUser} onCollaborationModeChange={ codexCollaborationModeSupported && props.session.active && !controlledByUser @@ -1263,7 +1320,7 @@ function SessionChatInner(props: SessionChatProps) { } onPermissionModeChange={handlePermissionModeChange} selectedModelBase={ - agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' + agentFlavor === 'cursor' && cursorPicker ? cursorSelectedBaseValue : undefined } diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index c80ac68a4e..342b4b64d6 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -7,10 +7,12 @@ import { SessionActionMenu } from '@/components/SessionActionMenu' import { SessionExportDialog } from '@/components/SessionExportDialog' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { useScratchlistCount } from '@/lib/use-scratchlist-count' import { formatReopenError } from '@/lib/reopenError' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' +import { GardenXrEntryChip } from '@/garden/components/GardenXrEntryChip' function getSessionTitle(session: Session): string { if (session.metadata?.name) { @@ -70,6 +72,29 @@ function OutlineIcon(props: { className?: string }) { ) } +/** Scroll/log glyph — distinct from Outline's TOC list. */ +function SessionLogIcon(props: { className?: string }) { + return ( + + + + + + + ) +} + function headerToggleClass(active: boolean): string { return `flex h-8 w-8 items-center justify-center rounded-full transition-colors ${ active @@ -102,6 +127,8 @@ export function SessionHeader(props: { filesActive?: boolean onToggleOutline?: () => void outlineActive?: boolean + onToggleSessionLog?: () => void + sessionLogActive?: boolean api: ApiClient | null onSessionDeleted?: () => void onSessionReopened?: (newSessionId: string) => void @@ -127,6 +154,11 @@ export function SessionHeader(props: { session.metadata?.flavor ?? null ) const [reopenError, setReopenError] = useState(null) + // tiann/hapi#893: surface the scratchlist entry count in the + // delete-confirm copy so the operator knows what cascades when they + // confirm. Read-only hook reuses the cache filled by SessionChat - + // no extra network when both components are mounted. + const scratchlistCount = useScratchlistCount(session.id, api) const handleDelete = async () => { await deleteSession() @@ -183,6 +215,8 @@ export function SessionHeader(props: { + + {/* Session info - two lines: title and path */}
@@ -230,6 +264,19 @@ export function SessionHeader(props: { ) : null} + {props.onToggleSessionLog ? ( + + ) : null} + + +
+
+ + {sessions.length > 0 ? ( + + ) : null} + +
+ {isLoading ? ( +
+ {t(labels.loading)} +
+ ) : sessions.length === 0 ? ( +
+ {t(labels.empty)} +
+ ) : filteredSessions.length === 0 ? ( +
+ {t(labels.emptyForWorkdir)} +
+ ) : ( +
+ {filteredSessions.map((session) => { + const disabled = isSessionDisabled?.(session) ?? false + const checked = !disabled && selectedSessionIdSet.has(session.id) + const time = formatSessionTime(session.modifiedAt) + const preview = getSessionPreview(session) + const cwd = getSessionCwd(session) + return ( + + ) + })} +
+ )} +
+
+ ) +} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 8443b88279..47ba85f2bb 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -18,7 +18,7 @@ import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip' -import { formatRelativeTime } from '@/lib/relativeTime' +import { formatRelativeTime } from '@/lib/relative-time' import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' @@ -735,6 +735,8 @@ function SessionItem(props: { setMenuOpen(false)} + sessionId={s.id} + sessionTitle={sessionName} sessionActive={s.active} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} diff --git a/web/src/components/ZoomableLightbox.test.ts b/web/src/components/ZoomableLightbox.test.ts new file mode 100644 index 0000000000..c8cd542bf0 --- /dev/null +++ b/web/src/components/ZoomableLightbox.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { getScreenFitSize, measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox' + +type Rect = { width: number; height: number } + +function makeSvg(opts: { + viewBox?: { width: number; height: number } + widthAttr?: string | null + heightAttr?: string | null + rect?: Rect | null +}): SVGSVGElement { + const svg = { + viewBox: opts.viewBox + ? { baseVal: { width: opts.viewBox.width, height: opts.viewBox.height } } + : { baseVal: { width: 0, height: 0 } }, + getAttribute(name: string) { + if (name === 'width') return opts.widthAttr ?? null + if (name === 'height') return opts.heightAttr ?? null + return null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return svg as unknown as SVGSVGElement +} + +function makeContent(opts: { + img?: { naturalWidth: number; naturalHeight: number; rect?: Rect } | null + svg?: SVGSVGElement | null + rect?: Rect | null +}): HTMLElement { + const queryResults = new Map() + if (opts.img) { + queryResults.set('img', { + naturalWidth: opts.img.naturalWidth, + naturalHeight: opts.img.naturalHeight, + getBoundingClientRect: () => opts.img?.rect ?? { width: 0, height: 0 }, + }) + } + if (opts.svg) queryResults.set('svg', opts.svg) + const content = { + querySelector(selector: string) { + return queryResults.get(selector) ?? null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return content as unknown as HTMLElement +} + +describe('measureSvgIntrinsicSize', () => { + it('prefers viewBox over the (possibly transformed) bounding rect', () => { + const svg = makeSvg({ + viewBox: { width: 1200, height: 900 }, + rect: { width: 60, height: 45 }, + }) + expect(measureSvgIntrinsicSize(svg, 0.05)).toEqual({ width: 1200, height: 900 }) + }) + + it('falls back to width/height attributes when viewBox is missing', () => { + const svg = makeSvg({ widthAttr: '640', heightAttr: '480' }) + expect(measureSvgIntrinsicSize(svg)).toEqual({ width: 640, height: 480 }) + }) + + it('divides bounding rect by the current scale to undo wrapper transform', () => { + const svg = makeSvg({ rect: { width: 200, height: 100 } }) + expect(measureSvgIntrinsicSize(svg, 0.5)).toEqual({ width: 400, height: 200 }) + }) + + it('returns null when no source is usable', () => { + const svg = makeSvg({}) + expect(measureSvgIntrinsicSize(svg)).toBeNull() + }) +}) + +describe('getScreenFitSize', () => { + const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport') + const originalInnerWidth = window.innerWidth + const originalInnerHeight = window.innerHeight + + function setViewport(width: number, height: number) { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { width, height }, + }) + } + + function clearViewport() { + Object.defineProperty(window, 'visualViewport', { configurable: true, value: null }) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + } + + function restore() { + if (originalVisualViewport) { + Object.defineProperty(window, 'visualViewport', originalVisualViewport) + } + } + + it('subtracts the reserved top region (toolbar) from height', () => { + setViewport(1000, 800) + try { + expect(getScreenFitSize(40)).toEqual({ width: 1000, height: 760 }) + } finally { + restore() + } + }) + + it('clamps reserved height at zero (no negative regions)', () => { + setViewport(800, 100) + try { + expect(getScreenFitSize(200)).toEqual({ width: 800, height: 0 }) + } finally { + restore() + } + }) + + it('falls back to window inner size when visualViewport is unavailable', () => { + clearViewport() + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 }) + try { + expect(getScreenFitSize(60)).toEqual({ width: 1280, height: 840 }) + } finally { + restore() + } + }) +}) + +describe('measureContentSize', () => { + it('prefers img.naturalSize over its bounding rect', () => { + const content = makeContent({ + img: { naturalWidth: 800, naturalHeight: 600, rect: { width: 80, height: 60 } }, + }) + expect(measureContentSize(content, 0.1)).toEqual({ width: 800, height: 600 }) + }) + + it('uses svg intrinsic size when no img is present', () => { + const svg = makeSvg({ viewBox: { width: 500, height: 250 } }) + const content = makeContent({ svg }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 500, height: 250 }) + }) + + it('divides the host rect by current scale as the last fallback', () => { + const content = makeContent({ rect: { width: 100, height: 50 } }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 200, height: 100 }) + }) + + it('treats non-positive scale as identity to avoid divide-by-zero', () => { + const content = makeContent({ rect: { width: 100, height: 100 } }) + expect(measureContentSize(content, 0)).toEqual({ width: 100, height: 100 }) + }) +}) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx new file mode 100644 index 0000000000..503a8e17ef --- /dev/null +++ b/web/src/components/ZoomableLightbox.tsx @@ -0,0 +1,480 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' +import { CloseIcon } from '@/components/icons' + +const MIN_SCALE = 0.25 +/** Floor for fit-to-screen only; dense diagrams can need under 25% to fit. */ +const MIN_FIT_SCALE = 0.01 +const MAX_SCALE = 8 +const SCALE_STEP = 0.25 +const BACKDROP_CLICK_MAX_MOVEMENT = 4 +/** Edge margin when fitting to the device screen (not the inner panel only). */ +const SCREEN_FIT_PADDING_PX = 12 + +export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } { + const viewport = window.visualViewport + const width = viewport ? viewport.width : window.innerWidth + const fullHeight = viewport ? viewport.height : window.innerHeight + const reserved = Math.max(0, reservedTopPx) + return { width, height: Math.max(0, fullHeight - reserved) } +} + +type Point = { x: number; y: number } + +function clampScale(value: number, minScale = MIN_SCALE): number { + return Math.min(MAX_SCALE, Math.max(minScale, value)) +} + +function getPointDistance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function getPointCenter(a: Point, b: Point): Point { + return { + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + } +} + +/** + * Measure SVG intrinsic size, ignoring the wrapper's `scale(...)` transform. + * Order: viewBox -> width/height attrs -> bounding rect divided by current scale. + */ +export function measureSvgIntrinsicSize( + svg: SVGSVGElement, + currentScale = 1, +): { width: number; height: number } | null { + const viewBox = svg.viewBox?.baseVal + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height } + } + + const widthAttr = svg.getAttribute('width') ?? '' + const heightAttr = svg.getAttribute('height') ?? '' + const parsedWidth = Number.parseFloat(widthAttr) + const parsedHeight = Number.parseFloat(heightAttr) + if (parsedWidth > 0 && parsedHeight > 0) { + return { width: parsedWidth, height: parsedHeight } + } + + const safeScale = currentScale > 0 ? currentScale : 1 + const box = svg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + + return null +} + +/** + * Measure rendered content size, ignoring any ancestor `scale(...)` transform. + * Prefer intrinsic dimensions (img.naturalSize, SVG viewBox/attrs) before the + * bounding rect, which otherwise compounds with `scaleRef.current` on retry. + */ +export function measureContentSize( + content: HTMLElement, + currentScale = 1, +): { width: number; height: number } | null { + const safeScale = currentScale > 0 ? currentScale : 1 + + const img = content.querySelector('img') + if (img) { + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + return { width: img.naturalWidth, height: img.naturalHeight } + } + const box = img.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + } + + const svg = content.querySelector('svg') + if (svg) { + const intrinsic = measureSvgIntrinsicSize(svg, safeScale) + if (intrinsic) return intrinsic + } + + const rect = content.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) { + return { width: rect.width / safeScale, height: rect.height / safeScale } + } + + return null +} + +export type ZoomableLightboxProps = { + open: boolean + onClose: () => void + title?: string + ariaLabel: string + children: ReactNode + /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ + fitContentKey?: string | number | null + /** Intrinsic content size for fit (e.g. mermaid viewBox) when layout is not measurable yet. */ + fitContentSize?: { width: number; height: number } | null + /** Compute initial scale to fill the device screen (default true). */ + fitOnOpen?: boolean +} + +export function ZoomableLightbox(props: ZoomableLightboxProps) { + const { + open, + onClose, + title, + ariaLabel, + children, + fitContentKey = null, + fitContentSize = null, + fitOnOpen = true, + } = props + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [toolbarHeight, setToolbarHeight] = useState(0) + const scaleRef = useRef(scale) + const offsetRef = useRef(offset) + const baseScaleRef = useRef(1) + const viewportRef = useRef(null) + const contentRef = useRef(null) + const toolbarRef = useRef(null) + const activePointersRef = useRef(new Map()) + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) + const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) + const backdropPressRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) + + const updateScale = useCallback((next: number | ((current: number) => number)) => { + setScale((current) => { + const value = typeof next === 'function' ? next(current) : next + scaleRef.current = value + return value + }) + }, []) + + const updateOffset = useCallback((next: Point) => { + offsetRef.current = next + setOffset(next) + }, []) + + const applyFitScale = useCallback(() => { + if (!fitOnOpen) { + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + return + } + + const content = contentRef.current + if (!content) return + + const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current) + if (!contentSize) return + + const screen = getScreenFitSize(toolbarHeight) + const pad = SCREEN_FIT_PADDING_PX * 2 + const fitWidth = (screen.width - pad) / contentSize.width + const fitHeight = (screen.height - pad) / contentSize.height + const fitScale = clampScale(Math.min(fitWidth, fitHeight), MIN_FIT_SCALE) + + baseScaleRef.current = fitScale + updateScale(fitScale) + updateOffset({ x: 0, y: 0 }) + }, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale]) + + const resetView = useCallback(() => { + updateScale(baseScaleRef.current) + updateOffset({ x: 0, y: 0 }) + }, [updateOffset, updateScale]) + + const closeViewer = useCallback(() => { + onClose() + activePointersRef.current.clear() + dragRef.current = null + pinchRef.current = null + backdropPressRef.current = null + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + }, [onClose, updateOffset, updateScale]) + + /** + * Lower bound for interactive zoom. Carries the fit floor when the diagram + * was opened below MIN_SCALE (e.g. 0.05); otherwise sticks at MIN_SCALE. + */ + const getMinInteractiveScale = useCallback(() => { + return Math.min(MIN_SCALE, baseScaleRef.current) + }, []) + + const zoomBy = useCallback((delta: number) => { + updateScale((current) => clampScale(current + delta, getMinInteractiveScale())) + }, [getMinInteractiveScale, updateScale]) + + const handleWheel = useCallback((event: WheelEvent) => { + event.preventDefault() + const delta = event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP + zoomBy(delta) + }, [zoomBy]) + + const beginPinch = useCallback(() => { + const pointers = Array.from(activePointersRef.current.values()) + if (pointers.length < 2) return + + const [first, second] = pointers + pinchRef.current = { + startDistance: getPointDistance(first, second), + startScale: scaleRef.current, + startCenter: getPointCenter(first, second), + origin: offsetRef.current, + } + dragRef.current = null + }, []) + + const handlePointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + backdropPressRef.current = event.target === event.currentTarget + ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY } + : null + + if (activePointersRef.current.size >= 2) { + backdropPressRef.current = null + beginPinch() + return + } + + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + }, [beginPinch]) + + const handlePointerMove = useCallback((event: PointerEvent) => { + if (!activePointersRef.current.has(event.pointerId)) return + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + + if (activePointersRef.current.size >= 2 && pinchRef.current) { + const pointers = Array.from(activePointersRef.current.values()) + const [first, second] = pointers + const distance = getPointDistance(first, second) + const center = getPointCenter(first, second) + const pinch = pinchRef.current + const nextScale = pinch.startDistance > 0 + ? clampScale( + pinch.startScale * (distance / pinch.startDistance), + getMinInteractiveScale(), + ) + : pinch.startScale + + updateScale(nextScale) + updateOffset({ + x: pinch.origin.x + center.x - pinch.startCenter.x, + y: pinch.origin.y + center.y - pinch.startCenter.y, + }) + return + } + + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + updateOffset({ + x: drag.originX + event.clientX - drag.startX, + y: drag.originY + event.clientY - drag.startY, + }) + }, [getMinInteractiveScale, updateOffset, updateScale]) + + const handlePointerUp = useCallback((event: PointerEvent) => { + const backdropPress = backdropPressRef.current + const moved = backdropPress + ? Math.hypot(event.clientX - backdropPress.x, event.clientY - backdropPress.y) + : Number.POSITIVE_INFINITY + const shouldCloseFromBackdrop = event.type === 'pointerup' + && backdropPress?.pointerId === event.pointerId + && event.target === event.currentTarget + && activePointersRef.current.size === 1 + && moved <= BACKDROP_CLICK_MAX_MOVEMENT + + activePointersRef.current.delete(event.pointerId) + if (backdropPress?.pointerId === event.pointerId) { + backdropPressRef.current = null + } + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + pinchRef.current = null + + const remainingPointer = activePointersRef.current.entries().next().value as [number, Point] | undefined + if (remainingPointer) { + dragRef.current = { + pointerId: remainingPointer[0], + startX: remainingPointer[1].x, + startY: remainingPointer[1].y, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + } + if (shouldCloseFromBackdrop) { + closeViewer() + } + }, [closeViewer]) + + useLayoutEffect(() => { + if (!open) return + if (fitOnOpen && !fitContentKey) return + + let frame = 0 + let attempt = 0 + const maxAttempts = 16 + + const scheduleFit = () => { + frame = requestAnimationFrame(() => { + const content = contentRef.current + const hadSize = fitContentSize ?? (content ? measureContentSize(content) : null) + applyFitScale() + attempt += 1 + if (!hadSize && attempt < maxAttempts) { + scheduleFit() + } + }) + } + + scheduleFit() + const retry = window.setTimeout(scheduleFit, 50) + const lateRetry = window.setTimeout(scheduleFit, 200) + + return () => { + cancelAnimationFrame(frame) + window.clearTimeout(retry) + window.clearTimeout(lateRetry) + } + }, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale]) + + useLayoutEffect(() => { + if (!open) return undefined + const toolbar = toolbarRef.current + if (!toolbar) return undefined + + const apply = () => { + const next = toolbar.getBoundingClientRect().height + setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next)) + } + + apply() + window.addEventListener('resize', apply) + + if (typeof ResizeObserver === 'undefined') { + return () => window.removeEventListener('resize', apply) + } + + const resize = new ResizeObserver(apply) + resize.observe(toolbar) + return () => { + resize.disconnect() + window.removeEventListener('resize', apply) + } + }, [open]) + + useEffect(() => { + if (!open) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeViewer() + } + if (event.key === '0') { + resetView() + } + if (event.key === '+' || event.key === '=') { + zoomBy(SCALE_STEP) + } + if (event.key === '-') { + zoomBy(-SCALE_STEP) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeViewer, open, resetView, zoomBy]) + + if (!open) return null + + const baseScale = baseScaleRef.current + const zoomLabel = baseScale > 0 + ? `${Math.round((scale / baseScale) * 100)}%` + : `${Math.round(scale * 100)}%` + const minInteractiveScale = Math.min(MIN_SCALE, baseScale) + + return ( +
+
+
+ {children} +
+
+
event.stopPropagation()} + > +
+
{title ?? ariaLabel}
+ + + + +
+
+
+ ) +} diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx new file mode 100644 index 0000000000..e026389b11 --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx @@ -0,0 +1,101 @@ +import type React from 'react' +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { I18nProvider } from '@/lib/i18n-context' + +function installSvgBBoxPolyfill() { + const bbox = () => ({ + x: 0, + y: 0, + width: 120, + height: 24, + top: 0, + left: 0, + right: 120, + bottom: 24, + toJSON() { + return {} + }, + }) + + for (const proto of [Element.prototype, HTMLElement.prototype, SVGElement.prototype]) { + if (proto && !('getBBox' in proto)) { + Object.defineProperty(proto, 'getBBox', { + configurable: true, + value: bbox, + }) + } + } +} + +const sequenceDiagram = `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + U->>M: Click diagram + M-->>U: Lightbox + zoom` + +const defaultComponents = { + Pre: (props: React.ComponentProps<'pre'>) =>
,
+    Code: (props: React.ComponentProps<'code'>) => ,
+}
+
+async function expectLightboxShowsDiagram(code: string) {
+    installSvgBBoxPolyfill()
+
+    render(
+        
+            
+        ,
+    )
+
+    await waitFor(
+        () => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')).toBeTruthy()
+        },
+        { timeout: 10000 },
+    )
+
+    fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+    await waitFor(
+        () => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const host = dialog.querySelector('[data-mermaid-lightbox]')
+            expect(host).toBeTruthy()
+            const lightboxSvg = host?.shadowRoot?.querySelector('svg')
+            expect(lightboxSvg).toBeTruthy()
+            expect(lightboxSvg?.querySelector('path, line, rect')).toBeTruthy()
+            if (code.includes('sequenceDiagram')) {
+                const actorRects = lightboxSvg?.querySelectorAll('rect.actor, g.actor rect, rect').length ?? 0
+                expect(actorRects).toBeGreaterThanOrEqual(3)
+            }
+        },
+        { timeout: 10000 },
+    )
+}
+
+describe('MermaidDiagram live render', () => {
+    it(
+        'renders real mermaid source to svg in jsdom',
+        async () => {
+            await expectLightboxShowsDiagram('flowchart LR\n  Hub --> WebUI\n  WebUI --> SVG')
+        },
+        20_000,
+    )
+
+    it(
+        'renders sequence diagrams in the lightbox',
+        async () => {
+            await expectLightboxShowsDiagram(sequenceDiagram)
+        },
+        20_000,
+    )
+})
diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
index 463d44d853..89c0e050cc 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
@@ -1,5 +1,7 @@
+import type { ComponentProps } from 'react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { I18nProvider } from '@/lib/i18n-context'
 
 const mermaidMocks = vi.hoisted(() => ({
     initializeMock: vi.fn(),
@@ -20,16 +22,16 @@ vi.mock('mermaid', () => ({
 import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
 import { MARKDOWN_COMPONENTS_BY_LANGUAGE } from '@/components/assistant-ui/markdown-text'
 
-function renderMermaid(code: string) {
+const defaultComponents = {
+    Pre: (props: ComponentProps<'pre'>) => 
,
+    Code: (props: ComponentProps<'code'>) => ,
+}
+
+function renderDiagram(props: ComponentProps) {
     return render(
-         
,
-                Code: (props) => ,
-            }}
-        />
+        
+            
+        ,
     )
 }
 
@@ -41,7 +43,7 @@ describe('MermaidDiagram', () => {
         mermaidMocks.parseMock.mockResolvedValue({ diagramType: 'flowchart-v2' })
         mermaidMocks.renderMock.mockReset()
         mermaidMocks.renderMock.mockResolvedValue({
-            svg: ''
+            svg: '',
         })
     })
 
@@ -51,7 +53,11 @@ describe('MermaidDiagram', () => {
     })
 
     it('is wired into the shared markdown language overrides and renders svg output', async () => {
-        renderMermaid('graph TD\nA --> B')
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
@@ -73,7 +79,11 @@ describe('MermaidDiagram', () => {
         document.documentElement.dataset.theme = 'dark'
         mermaidMocks.parseMock.mockResolvedValueOnce(false)
 
-        renderMermaid('graph TD\nA --')
+        renderDiagram({
+            code: 'graph TD\nA --',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -90,7 +100,11 @@ describe('MermaidDiagram', () => {
         mermaidMocks.renderMock.mockRejectedValueOnce(new Error('render failed'))
         const code = 'gantt\ndateFormat YYYY-MM-DD\nsection A\nTask :a, 2024-01-01'
 
-        renderMermaid(code)
+        renderDiagram({
+            code,
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -104,4 +118,46 @@ describe('MermaidDiagram', () => {
         }))
     })
 
+    it('opens a zoomable lightbox when the rendered diagram is clicked', async () => {
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"]')).toBeTruthy()
+        })
+
+        fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+        await waitFor(() => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const host = dialog.querySelector('[data-mermaid-lightbox]')
+            expect(host?.shadowRoot?.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy()
+        })
+
+        expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1)
+        expect(mermaidMocks.renderMock).toHaveBeenCalledWith(
+            expect.stringContaining('mermaid-'),
+            'graph TD\nA --> B',
+        )
+        expect(document.querySelector('[data-mermaid-lightbox]')).toBeTruthy()
+    })
+
+    it('does not expose a lightbox trigger when rendering fails', async () => {
+        mermaidMocks.renderMock.mockRejectedValue(new Error('syntax'))
+
+        renderDiagram({
+            code: 'not valid mermaid',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="false"]')).toBeTruthy()
+        })
+
+        expect(screen.queryByRole('button', { name: 'Open diagram full screen' })).toBeNull()
+    })
 })
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index 35a77e17b2..36f0f6775a 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -1,6 +1,16 @@
 import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
-import { useEffect, useId, useState, type ComponentPropsWithoutRef } from 'react'
+import {
+    useEffect,
+    useId,
+    useRef,
+    useState,
+    type ComponentPropsWithoutRef,
+    type Ref,
+    type SyntheticEvent,
+} from 'react'
+import { ZoomableLightbox } from '@/components/ZoomableLightbox'
 import { cn } from '@/lib/utils'
+import { useTranslation } from '@/lib/use-translation'
 
 let initializedTheme: 'light' | 'dark' | null = null
 let mermaidPromise: Promise | null = null
@@ -43,6 +53,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#2d3440',
                 clusterBorder: '#6d8fd6',
                 edgeLabelBackground: '#2a2f35',
+                actorBkg: '#323843',
+                actorBorder: '#6d8fd6',
+                actorTextColor: '#edf1f5',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#323843',
+                labelTextColor: '#edf1f5',
+                loopTextColor: '#edf1f5',
+                noteBkgColor: '#2d3440',
+                noteTextColor: '#edf1f5',
             }
             : {
                 primaryColor: '#f8fbff',
@@ -57,6 +76,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#eef4ff',
                 clusterBorder: '#b8cdfd',
                 edgeLabelBackground: '#f5f6f7',
+                actorBkg: '#f8fbff',
+                actorBorder: '#b8cdfd',
+                actorTextColor: '#2d333b',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#f8fbff',
+                labelTextColor: '#2d333b',
+                loopTextColor: '#2d333b',
+                noteBkgColor: '#eef4ff',
+                noteTextColor: '#2d333b',
             },
     })
 
@@ -64,24 +92,176 @@ async function ensureMermaid(theme: 'light' | 'dark') {
     return mermaid
 }
 
+export async function renderMermaidSvg(
+    code: string,
+    elementId: string,
+    theme: 'light' | 'dark',
+): Promise {
+    const mermaid = await ensureMermaid(theme)
+    const isValid = await mermaid.parse(code, { suppressErrors: true })
+    if (!isValid) return null
+    const result = await mermaid.render(elementId, code)
+    return result.svg
+}
+
+export function getMermaidSvgLayoutSize(svg: string): { width: number; height: number } | null {
+    const viewBoxMatch = svg.match(/\bviewBox=(['"])([^'"]+)\1/i)
+    if (!viewBoxMatch) return null
+    const parts = viewBoxMatch[2].trim().split(/[\s,]+/).map(Number)
+    if (parts.length < 4 || parts.some(Number.isNaN) || parts[2] <= 0 || parts[3] <= 0) return null
+    return { width: parts[2], height: parts[3] }
+}
+
+/** Mermaid often emits width="100%"; normalize before rasterizing for the lightbox. */
+export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string {
+    let result = svg
+    const viewBoxSize = getMermaidSvgLayoutSize(result)
+    if (!viewBoxSize) return result
+
+    const { width, height } = viewBoxSize
+    result = result.replace(/\swidth="100%"/gi, '')
+    result = result.replace(/\sheight="100%"/gi, '')
+
+    if (/\sstyle="/i.test(result)) {
+        result = result.replace(
+            /(]*?\sstyle=")([^"]*)(")/i,
+            (_full, prefix: string, style: string, suffix: string) => {
+                const cleaned = style
+                    .replace(/(?:^|;)\s*max-width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*height:\s*[^;]+/gi, '')
+                    .replace(/^;+|;+$/g, '')
+                    .replace(/;\s*;/g, ';')
+                    .trim()
+                const nextStyle = cleaned
+                    ? `${cleaned};width:${width}px;height:${height}px`
+                    : `width:${width}px;height:${height}px`
+                return `${prefix}${nextStyle}${suffix}`
+            },
+        )
+    } else {
+        result = result.replace(
+            / & { code: string }) {
+    const { code, className, ...rest } = props
     return (
         
-            {props.code}
+            {code}
         
) } +function MermaidSvgContent(props: { svg: string; className?: string; hostRef?: Ref }) { + return ( +
+ ) +} + +/** Prefer viewBox layout; use getBBox when Mermaid pads the viewBox (e.g. gitGraph). */ +export function resolveMermaidLightboxFitSize( + svgElement: SVGSVGElement | null, + svgString: string, +): { width: number; height: number } | null { + const fromViewBox = getMermaidSvgLayoutSize(svgString) + if (!svgElement) return fromViewBox + + try { + const bbox = svgElement.getBBox() + if (bbox.width <= 0 || bbox.height <= 0) return fromViewBox + if (!fromViewBox) return { width: bbox.width, height: bbox.height } + + const viewBoxArea = fromViewBox.width * fromViewBox.height + const bboxArea = bbox.width * bbox.height + if (viewBoxArea > bboxArea * 2) { + return { width: bbox.width, height: bbox.height } + } + } catch { + // getBBox unavailable (some test environments) + } + + return fromViewBox +} + +/** + * Shadow root isolates duplicate mermaid ids from the inline diagram in the page. + * + * Mermaid emits `width="100%"` on every diagram. Inside a shadow root whose host + * has no explicit size, that collapses to zero in Chromium for most diagram types + * (only ones that ship pixel attrs - e.g. `journey` - happen to render). Strip + * the relative size and bake explicit pixels from the viewBox before injecting, + * and give the host an inline-block layout so it sizes to the SVG. + */ +function MermaidLightboxSvg(props: { svg: string }) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + + const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) + const normalized = normalizeMermaidSvgForStandaloneDisplay(props.svg) + root.innerHTML = [ + '', + normalized, + ].join('') + }, [props.svg]) + + return
+} + +function readMermaidE2eCaseId(code: string): string | undefined { + return code.match(//i)?.[1] +} + export function MermaidDiagram(props: SyntaxHighlighterProps) { + const { t } = useTranslation() + const e2eCaseId = readMermaidE2eCaseId(props.code) const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxFitSize, setLightboxFitSize] = useState<{ width: number; height: number } | null>(null) + const inlineHostRef = useRef(null) const id = useId().replace(/:/g, '-') + const openLabel = t('mermaid.openFullscreen') + const viewerLabel = t('mermaid.viewerTitle') + + const stopEvent = (event: SyntheticEvent) => { + event.stopPropagation() + } + + const openLightbox = (event: SyntheticEvent) => { + event.preventDefault() + event.stopPropagation() + if (!svg) return + const inlineSvg = inlineHostRef.current?.querySelector('svg') ?? null + setLightboxFitSize(resolveMermaidLightboxFitSize(inlineSvg, svg)) + setLightboxOpen(true) + } useEffect(() => { if (typeof document === 'undefined') return undefined @@ -104,18 +284,14 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const render = async () => { try { - const mermaid = await ensureMermaid(theme) - const isValid = await mermaid.parse(props.code, { suppressErrors: true }) + const nextSvg = await renderMermaidSvg(props.code, `mermaid-${id}`, theme) if (cancelled) return - if (!isValid) { + if (!nextSvg) { setSvg(null) setRenderError(true) return } - - const result = await mermaid.render(`mermaid-${id}`, props.code) - if (cancelled) return - setSvg(result.svg) + setSvg(nextSvg) setRenderError(false) } catch { if (cancelled) return @@ -131,20 +307,43 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) + const lightboxLayoutSize = lightboxFitSize ?? (svg ? getMermaidSvgLayoutSize(svg) : null) + if (renderError || !svg) { return } return ( -
-
-
+ <> + + + setLightboxOpen(false)} + title={viewerLabel} + ariaLabel={viewerLabel} + fitContentKey={lightboxOpen ? svg : null} + fitContentSize={lightboxLayoutSize} + > +
+ +
+
+ ) } diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts new file mode 100644 index 0000000000..5a7d3787d3 --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + getMermaidSvgLayoutSize, + normalizeMermaidSvgForStandaloneDisplay, +} from '@/components/assistant-ui/mermaid-diagram' + +describe('getMermaidSvgLayoutSize', () => { + it('reads simple unsigned viewBox', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 200, height: 80 }) + }) + + it('accepts signed origin values (negative offsets)', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 640, height: 480 }) + }) + + it('accepts single-quoted attribute and comma separators', () => { + expect(getMermaidSvgLayoutSize("")).toEqual({ width: 300, height: 150 }) + }) + + it('rejects malformed viewBox (NaN, missing dim, zero size)', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) + + it('returns null when no viewBox attribute exists', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) +}) + +describe('normalizeMermaidSvgForStandaloneDisplay', () => { + it('replaces width="100%" with explicit viewBox dimensions', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width:200px') + expect(prepared).toContain('height:80px') + }) + + it('normalizes width/height for SVGs with negative viewBox origins', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width="800"') + expect(prepared).toContain('height="600"') + }) +}) diff --git a/web/src/components/settings/CompanionPairing.tsx b/web/src/components/settings/CompanionPairing.tsx new file mode 100644 index 0000000000..1c1db9c66a --- /dev/null +++ b/web/src/components/settings/CompanionPairing.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import QRCode from 'qrcode' +import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' + +type CompanionPairingProps = { + baseUrl: string +} + +const COMPANION_DEEPLINK_SCHEME = 'hapicompanion://bind' +const ACCESS_TOKEN_PREFIX = 'hapi_access_token::' + +function readAccessToken(baseUrl: string): string { + if (typeof window === 'undefined') return '' + try { + return window.localStorage.getItem(`${ACCESS_TOKEN_PREFIX}${baseUrl}`) ?? '' + } catch { + return '' + } +} + +function buildDeeplink(hub: string, code: string): string { + const params = new URLSearchParams({ hub, code }) + return `${COMPANION_DEEPLINK_SCHEME}?${params.toString()}` +} + +export function CompanionPairing({ baseUrl }: CompanionPairingProps) { + const { t } = useTranslation() + const [revealed, setRevealed] = useState(false) + const [copied, setCopied] = useState(false) + const canvasRef = useRef(null) + + const accessToken = useMemo(() => readAccessToken(baseUrl), [baseUrl]) + + const deeplink = useMemo(() => { + const hub = (baseUrl || '').trim() + const code = (accessToken || '').trim() + if (!hub || !code) return '' + return buildDeeplink(hub, code) + }, [baseUrl, accessToken]) + + useEffect(() => { + if (!revealed || !deeplink || !canvasRef.current) return + let cancelled = false + QRCode.toCanvas(canvasRef.current, deeplink, { + errorCorrectionLevel: 'M', + margin: 2, + scale: 6, + color: { + dark: '#000000', + light: '#ffffff' + } + }).catch(() => { + // QR rendering failures are non-fatal; the textual link below still works. + }) + return () => { + cancelled = true + void cancelled + } + }, [deeplink, revealed]) + + const handleCopy = async () => { + if (!deeplink) return + try { + await navigator.clipboard.writeText(deeplink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard may be unavailable (e.g. insecure context); user can long-press the link instead. + } + } + + if (!deeplink) { + return ( +

+ {t('settings.companion.noToken')} +

+ ) + } + + return ( +
+

+ {t('settings.companion.description')} +

+ + {!revealed ? ( + + ) : ( +
+ +
+ + +
+

+ {deeplink} +

+
+ )} +
+ ) +} diff --git a/web/src/components/settings/EventsDebugControls.tsx b/web/src/components/settings/EventsDebugControls.tsx new file mode 100644 index 0000000000..f042f2bc96 --- /dev/null +++ b/web/src/components/settings/EventsDebugControls.tsx @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppContext } from '@/lib/app-context' + +export type SystemEventRow = { + id: number + ts: number + sourceKind: string + sourceRef: string | null + eventType: string + attentionCandidate: number + summary: string + provenance: string | null + relatedSessionId: string | null + payloadJson: string | null + severity: number | null + artifactRefs?: string | null +} + +type SystemEventsResponse = { + total: number + events: SystemEventRow[] +} + +function formatTs(ts: number): string { + return new Date(ts).toLocaleString() +} + +export function EventsDebugControls() { + const { api } = useAppContext() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [total, setTotal] = useState(0) + const [events, setEvents] = useState([]) + + const refresh = useCallback(async () => { + if (!api) { + setError('Not authenticated') + return + } + setLoading(true) + setError(null) + try { + const data = await api.fetchSystemEvents({ limit: 80 }) as SystemEventsResponse + // Hub-inferred silence is no longer written; hide historical `stale` + // rows from this operator-facing debug feed (same rule as Session Log All). + const visible = data.events.filter((event) => event.eventType !== 'stale') + setTotal(visible.length) + setEvents(visible) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load events') + } finally { + setLoading(false) + } + }, [api]) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + return ( +
+ + {open && ( +
+
+

+ Read-only substrate feed (#22). Populated from AGENT_NOTIFY_SUMMARY + hub fallback. +

+ +
+ {error ? ( +

{error}

+ ) : null} +
+ {events.length === 0 ? ( +

No events yet.

+ ) : ( +
    + {events.map((event) => ( +
  • +
    + #{event.id} + + {event.eventType} + + {event.attentionCandidate ? ( + + attention + + ) : null} + {formatTs(event.ts)} +
    +

    {event.summary}

    +

    + {event.sourceKind} + {event.sourceRef ? ` · ${event.sourceRef}` : ''} + {event.relatedSessionId ? ` · session ${event.relatedSessionId.slice(0, 8)}…` : ''} + {event.provenance ? ` · ${event.provenance}` : ''} +

    +
  • + ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/web/src/components/settings/InboxDebugControls.tsx b/web/src/components/settings/InboxDebugControls.tsx new file mode 100644 index 0000000000..d4ddc15891 --- /dev/null +++ b/web/src/components/settings/InboxDebugControls.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAppContext } from '@/lib/app-context' +import type { InboxOperatorAction } from '@hapi/protocol' + +export type InboxItemRow = { + id: number + status: string + priority: number + basePriority: number + title: string + category: string + summary: string + suggestedAction: string | null + reasonForPriority: string | null + sourceEventIds: number[] + relatedSessionId: string | null + createdAt: number + updatedAt: number +} + +type InboxItemsResponse = { + total: number + items: InboxItemRow[] +} + +function formatTs(ts: number): string { + return new Date(ts).toLocaleString() +} + +export function InboxDebugControls() { + const { api } = useAppContext() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [total, setTotal] = useState(0) + const [items, setItems] = useState([]) + + const refresh = useCallback(async () => { + if (!api) { + setError('Not authenticated') + return + } + setLoading(true) + setError(null) + try { + const data = await api.fetchInboxItems({ limit: 80, activeOnly: true }) as InboxItemsResponse + setTotal(data.total) + setItems(data.items) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load inbox items') + } finally { + setLoading(false) + } + }, [api]) + + const runAction = useCallback(async (itemId: number, action: InboxOperatorAction) => { + if (!api) return + setLoading(true) + setError(null) + try { + await api.recordInboxOperatorAction(itemId, action) + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Action failed') + } finally { + setLoading(false) + } + }, [api, refresh]) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + return ( +
+ + {open && ( +
+
+

+ Per-session promotion (#23). Coarse rank, oldest within tier. Actions log training labels. +

+ +
+ {error ? ( +

{error}

+ ) : null} +
+ {items.length === 0 ? ( +

No inbox items yet.

+ ) : ( +
    + {items.map((item) => ( +
  • +
    + #{item.id} + + {item.category} + + {formatTs(item.createdAt)} +
    +

    {item.title}

    +

    {item.summary}

    + {item.reasonForPriority ? ( +

    {item.reasonForPriority}

    + ) : null} + {item.suggestedAction ? ( +

    Next: {item.suggestedAction}

    + ) : null} +
    + {(['open', 'snooze', 'done', 'dismiss'] as const).map((action) => ( + + ))} +
    +
  • + ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx new file mode 100644 index 0000000000..3abb26b359 --- /dev/null +++ b/web/src/components/settings/SettingsNav.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from '@/lib/use-translation' +import { useAppearance } from '@/hooks/useTheme' +import { useFontScale } from '@/hooks/useFontScale' +import { useComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' +import { settingsCategories } from '@/routes/settings/categories' +import { ChevronRightIcon } from './SettingsPrimitives' + +export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { + const navigate = useNavigate() + const { t, locale } = useTranslation() + const { appearance } = useAppearance() + const { fontScale } = useFontScale() + const { composerEnterBehavior } = useComposerEnterBehavior() + + const summaries: Record = { + general: locale === 'zh-CN' ? '简体中文' : 'English', + display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, + chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), + voice: t('settings.hub.voice.summary'), + about: `v${__APP_VERSION__}`, + } + + return ( + + ) +} diff --git a/web/src/components/settings/SettingsPrimitives.tsx b/web/src/components/settings/SettingsPrimitives.tsx new file mode 100644 index 0000000000..fd13116bb6 --- /dev/null +++ b/web/src/components/settings/SettingsPrimitives.tsx @@ -0,0 +1,114 @@ +import type { ReactNode } from 'react' + +export function ChevronRightIcon(props: { className?: string }) { + return ( + + ) +} + +export function CheckIcon(props: { className?: string }) { + return ( + + ) +} + +export function SettingsPageContent(props: { title: string; description?: string; children: ReactNode }) { + return ( +
+
+

{props.title}

+ {props.description ?

{props.description}

: null} +
+ {props.children} +
+ ) +} + +export function SettingsSection(props: { title?: string; description?: string; children: ReactNode }) { + return ( +
+ {props.title ?

{props.title}

: null} + {props.description ?

{props.description}

: null} +
+ {props.children} +
+
+ ) +} + +export function SettingsRow(props: { label: string; description?: string; trailing?: ReactNode; children?: ReactNode }) { + return ( +
+
+
{props.label}
+ {props.description ?
{props.description}
: null} + {props.children} +
+ {props.trailing ?
{props.trailing}
: null} +
+ ) +} + +export function SettingsSwitch(props: { label: string; description?: string; checked: boolean; onChange: (checked: boolean) => void }) { + return ( + + props.onChange(event.target.checked)} className="peer sr-only" aria-label={props.label} /> + + + + } /> + ) +} + +export function SettingsChoiceGroup(props: { + label: string + value: T + options: ReadonlyArray<{ value: T; label: string; description?: string }> + onChange: (value: T) => void + columns?: 2 | 4 | 5 +}) { + const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' + return ( +
+ {props.label} +
+ {props.options.map((option) => { + const selected = props.value === option.value + return ( + + ) + })} +
+
+ ) +} + +export function SettingsLinkRow(props: { label: string; value?: string; description?: string; onClick: () => void }) { + return ( + + ) +} diff --git a/web/src/dev/mermaid-lightbox-cases.ts b/web/src/dev/mermaid-lightbox-cases.ts new file mode 100644 index 0000000000..c926802151 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-cases.ts @@ -0,0 +1,96 @@ +/** Minimal valid sources for Playwright lightbox coverage (one case per diagram kind). */ +export const MERMAID_LIGHTBOX_CASES = { + flowchart: `flowchart LR + Hub --> WebUI + WebUI --> Lightbox`, + + sequence: `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + M-->>U: Lightbox`, + + class: `classDiagram + Animal <|-- Duck + Animal : +int age + Duck : +swim()`, + + state: `stateDiagram-v2 + [*] --> Still + Still --> Moving + Moving --> Still`, + + er: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains`, + + journey: `journey + title Checkout + section Browse + Open site: 5: User + Add item: 3: User`, + + gantt: `gantt + title Plan + dateFormat YYYY-MM-DD + section Build + Ship feature :2024-06-01, 3d`, + + pie: `pie title Pets + "Dogs" : 386 + "Cats" : 214`, + + quadrant: `quadrantChart + title Reach and engagement + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + Product A: [0.3, 0.6] + Product B: [0.45, 0.23]`, + + requirement: `requirementDiagram + requirement test_req { + id: 1 + text: the tested requirement. + risk: high + verifymethod: test + }`, + + gitGraph: `gitGraph + commit + branch develop + checkout develop + commit + checkout main + merge develop`, + + c4: `C4Context + title System + Person(user, "User") + System(app, "Application") + Rel(user, app, "Uses")`, + + mindmap: `mindmap + root((HAPI)) + Chat + Hub + Web`, + + timeline: `timeline + title History + 2024 : Alpha + 2025 : Beta`, + + kanban: `kanban + title Board + column Todo + task1[Task 1] + column Done + task2[Task 2]`, +} as const + +export type MermaidLightboxCaseId = keyof typeof MERMAID_LIGHTBOX_CASES + +export const MERMAID_LIGHTBOX_CASE_IDS = Object.keys(MERMAID_LIGHTBOX_CASES) as MermaidLightboxCaseId[] diff --git a/web/src/dev/mermaid-lightbox-e2e.tsx b/web/src/dev/mermaid-lightbox-e2e.tsx new file mode 100644 index 0000000000..e38b007a17 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-e2e.tsx @@ -0,0 +1,39 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, + type MermaidLightboxCaseId, +} from '@/dev/mermaid-lightbox-cases' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +const params = new URLSearchParams(window.location.search) +const caseId = params.get('case') as MermaidLightboxCaseId | null +const code = caseId && caseId in MERMAID_LIGHTBOX_CASES + ? MERMAID_LIGHTBOX_CASES[caseId] + : MERMAID_LIGHTBOX_CASES.flowchart + +document.title = `Mermaid lightbox e2e: ${caseId ?? 'flowchart'}` + +createRoot(root).render( + +
+
,
+                    Code: (props) => ,
+                }}
+            />
+        
+
, +) + +// Expose case list for Playwright discovery without importing TS in Node. +;(window as Window & { __MERMAID_E2E_CASES__?: string[] }).__MERMAID_E2E_CASES__ = [...MERMAID_LIGHTBOX_CASE_IDS] diff --git a/web/src/dev/mermaid-lightbox-smoke.tsx b/web/src/dev/mermaid-lightbox-smoke.tsx new file mode 100644 index 0000000000..80ff954632 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-smoke.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +createRoot(root).render( + + Lightbox\n Lightbox --> Visible'} + language="mermaid" + components={{ + Pre: (props) =>
,
+                Code: (props) => ,
+            }}
+        />
+    ,
+)
diff --git a/web/src/garden/GardenPage.tsx b/web/src/garden/GardenPage.tsx
new file mode 100644
index 0000000000..7843a15ccd
--- /dev/null
+++ b/web/src/garden/GardenPage.tsx
@@ -0,0 +1,21 @@
+import { useEffect } from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { useGardenXrLauncher } from '@/garden/GardenXrLauncher'
+
+/** Deep link: open Garden overlay on Quest, land on flat sessions list. */
+export function GardenPage() {
+    const navigate = useNavigate()
+    const { openOverlay, webXRSupported } = useGardenXrLauncher()
+
+    useEffect(() => {
+        if (webXRSupported === null) {
+            return
+        }
+        if (webXRSupported) {
+            openOverlay()
+        }
+        navigate({ to: '/sessions', replace: true })
+    }, [openOverlay, navigate, webXRSupported])
+
+    return null
+}
diff --git a/web/src/garden/GardenScene.tsx b/web/src/garden/GardenScene.tsx
new file mode 100644
index 0000000000..472890a597
--- /dev/null
+++ b/web/src/garden/GardenScene.tsx
@@ -0,0 +1,118 @@
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Canvas, useThree } from '@react-three/fiber'
+import { Stars } from '@react-three/drei'
+import { XROrigin, XR, createXRStore, useXR, type XRStore } from '@react-three/xr'
+import * as THREE from 'three'
+import { useAppContext } from '@/lib/app-context'
+import { useSessions } from '@/hooks/queries/useSessions'
+import { AgentOrb } from '@/garden/components/AgentOrb'
+import { ExitPad } from '@/garden/components/ExitPad'
+import { GardenHeadRaycaster, type GardenGazeTarget } from '@/garden/components/GardenHeadRaycaster'
+import { GardenXrPresentingSync } from '@/garden/components/GardenXrPresentingSync'
+import { useGardenFocus } from '@/garden/context/GardenRuntimeContext'
+import { useGardenAttention } from '@/garden/hooks/useGardenAttention'
+import { useGardenSpatialAudio } from '@/garden/hooks/useGardenSpatialAudio'
+import { filterGardenSessions, GARDEN_BUILD, layoutPosition } from '@/garden/utils/sessionVisuals'
+
+export const xrStore: XRStore = createXRStore({
+    gaze: false,
+    hand: false,
+    controller: false,
+    screenInput: true,
+})
+
+function GardenWorld() {
+    const { api } = useAppContext()
+    const { camera } = useThree()
+    const isPresenting = useXR((state) => state.session !== undefined)
+    const { sessions } = useSessions(api)
+    const visible = useMemo(() => filterGardenSessions(sessions), [sessions])
+    const { focusedId, setFocusedId } = useGardenFocus()
+    const { attentionIds, attentionCueTokens } = useGardenAttention(visible, focusedId)
+    const [gazeTarget, setGazeTarget] = useState(null)
+
+    const orbHitTargets = useRef(new Map())
+    const exitHitTarget = useRef(null)
+
+    const playSpatialCue = useGardenSpatialAudio(isPresenting)
+
+    const playCueSound = useCallback((worldPosition: THREE.Vector3) => {
+        playSpatialCue(worldPosition, camera)
+    }, [camera, playSpatialCue])
+
+    const registerOrbHitTarget = useCallback((sessionId: string, mesh: THREE.Mesh | null) => {
+        if (mesh) {
+            orbHitTargets.current.set(sessionId, mesh)
+            return
+        }
+        orbHitTargets.current.delete(sessionId)
+    }, [])
+
+    const registerExitHitTarget = useCallback((mesh: THREE.Mesh | null) => {
+        exitHitTarget.current = mesh
+    }, [])
+
+    const gazeSessionId = gazeTarget?.kind === 'orb' ? gazeTarget.sessionId : null
+    const gazeExitPad = gazeTarget?.kind === 'exit'
+
+    useEffect(() => {
+        if (focusedId && !visible.some((session) => session.id === focusedId)) {
+            setFocusedId(null)
+        }
+    }, [focusedId, setFocusedId, visible])
+
+    return (
+        <>
+            
+            
+            
+            
+
+            
+
+            
+
+            
+                
+            
+
+            
+                {visible.map((session, index) => (
+                    
+                        
+                    
+                ))}
+            
+        
+    )
+}
+
+export function GardenScene() {
+    return (
+        
+            
+                
+                    
+                
+            
+        
+    )
+}
+
+export { GARDEN_BUILD }
diff --git a/web/src/garden/GardenVoiceBridge.tsx b/web/src/garden/GardenVoiceBridge.tsx
new file mode 100644
index 0000000000..b0b46b0bdc
--- /dev/null
+++ b/web/src/garden/GardenVoiceBridge.tsx
@@ -0,0 +1,222 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useAppContext } from '@/lib/app-context'
+import { useVoiceOptional } from '@/lib/voice-context'
+import { makeClientSideId } from '@/lib/messages'
+import { useSessions } from '@/hooks/queries/useSessions'
+import { useSession } from '@/hooks/queries/useSession'
+import { useMessages } from '@/hooks/queries/useMessages'
+import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
+import { useGardenRuntime } from '@/garden/context/GardenRuntimeContext'
+import {
+    notifyGardenSessionFocus,
+    prefetchGardenVoiceContext,
+    primeVoiceHooksForGarden,
+} from '@/garden/utils/gardenVoiceContext'
+import { filterGardenSessions } from '@/garden/utils/sessionVisuals'
+import { applyPersonaOverride } from '@/garden/utils/voicePersona'
+import { fetchVoiceBackend } from '@/api/voice'
+import type { VoiceBackendType } from '@hapi/protocol/voice'
+import type { DecryptedMessage } from '@/types/api'
+
+export function GardenVoiceBridge() {
+    const { api } = useAppContext()
+    const voice = useVoiceOptional()
+    const queryClient = useQueryClient()
+    const { focusedId, isPresenting } = useGardenRuntime()
+    const { sessions } = useSessions(api)
+    const visible = filterGardenSessions(sessions)
+    const { session: focusedSession, refetch: refetchSession } = useSession(api, focusedId)
+    const { messages: focusedMessages } = useMessages(api, focusedId)
+    const [backend, setBackend] = useState(null)
+
+    useEffect(() => {
+        if (!api) {
+            return
+        }
+        let cancelled = false
+        fetchVoiceBackend(api)
+            .then((resp) => {
+                if (!cancelled) {
+                    setBackend(resp.backend)
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setBackend(null)
+                }
+            })
+        return () => {
+            cancelled = true
+        }
+    }, [api])
+
+    const startingRef = useRef(false)
+    const autoVoiceRef = useRef(false)
+    const prevMessagesRef = useRef([])
+    const prevThinkingRef = useRef(undefined)
+    const prevRequestIdsRef = useRef>(new Set())
+
+    const refreshSession = useCallback(async () => {
+        await refetchSession()
+        if (focusedId) {
+            await queryClient.invalidateQueries({ queryKey: ['sessions'] })
+        }
+    }, [focusedId, queryClient, refetchSession])
+
+    useEffect(() => {
+        if (!api || !focusedId || !focusedSession) {
+            registerSessionStore(null)
+            return
+        }
+
+        registerSessionStore({
+            getSession: (sessionId: string) => (
+                sessionId === focusedId && focusedSession
+                    ? focusedSession as { agentState?: { requests?: Record } }
+                    : null
+            ),
+            sendMessage: (_sessionId: string, message: string) => {
+                const localId = makeClientSideId('local')
+                void api.sendMessage(focusedId, message, localId)
+            },
+            approvePermission: async (_sessionId: string, requestId: string) => {
+                await api.approvePermission(focusedId, requestId)
+                await refreshSession()
+            },
+            denyPermission: async (_sessionId: string, requestId: string) => {
+                await api.denyPermission(focusedId, requestId)
+                await refreshSession()
+            },
+        })
+    }, [api, focusedId, focusedSession, refreshSession])
+
+    useEffect(() => {
+        registerVoiceHooksStore(
+            (sessionId) => (sessionId === focusedId && focusedSession ? focusedSession : null),
+            (sessionId) => (sessionId === focusedId ? focusedMessages : []),
+        )
+    }, [focusedId, focusedSession, focusedMessages])
+
+    useEffect(() => {
+        if (!focusedId || !focusedSession) {
+            prevMessagesRef.current = []
+            return
+        }
+
+        const prevIds = new Set(prevMessagesRef.current.map((message) => message.id))
+        const newMessages = focusedMessages.filter((message) => !prevIds.has(message.id))
+
+        if (newMessages.length > 0) {
+            voiceHooks.onMessages(focusedId, newMessages)
+        }
+
+        prevMessagesRef.current = focusedMessages
+    }, [focusedId, focusedMessages, focusedSession])
+
+    useEffect(() => {
+        if (!focusedId || !focusedSession) {
+            prevThinkingRef.current = undefined
+            return
+        }
+
+        if (prevThinkingRef.current === true && !focusedSession.thinking) {
+            voiceHooks.onReady(focusedId)
+        }
+
+        prevThinkingRef.current = focusedSession.thinking
+    }, [focusedId, focusedSession])
+
+    useEffect(() => {
+        if (!focusedId || !focusedSession) {
+            prevRequestIdsRef.current = new Set()
+            return
+        }
+
+        const requests = focusedSession.agentState?.requests ?? {}
+        const currentIds = new Set(Object.keys(requests))
+
+        for (const [requestId, request] of Object.entries(requests)) {
+            if (!prevRequestIdsRef.current.has(requestId)) {
+                voiceHooks.onPermissionRequested(
+                    focusedId,
+                    requestId,
+                    (request as { tool?: string }).tool ?? 'unknown',
+                    (request as { arguments?: unknown }).arguments,
+                )
+            }
+        }
+
+        prevRequestIdsRef.current = currentIds
+    }, [focusedId, focusedSession])
+
+    const startVoiceForFocus = useCallback(async (sessionId: string) => {
+        if (!voice || !api || startingRef.current) {
+            return
+        }
+
+        startingRef.current = true
+        const personaOverride = backend
+            ? applyPersonaOverride(sessionId, backend)
+            : { persona: null, restore: () => {} }
+        try {
+            const prefetch = await prefetchGardenVoiceContext(api, sessionId)
+            if (!prefetch) {
+                console.warn('[Garden Voice] Session prefetch failed:', sessionId)
+                return
+            }
+
+            primeVoiceHooksForGarden(prefetch)
+            notifyGardenSessionFocus(sessionId, prefetch.session.metadata)
+
+            if (voice.currentSessionId && voice.currentSessionId !== sessionId) {
+                await voice.stopVoice()
+            }
+            if (voice.currentSessionId !== sessionId) {
+                await voice.startVoice(sessionId, { proactiveSummary: true })
+            }
+        } catch (error) {
+            console.error('[Garden Voice] Failed to start voice:', error)
+        } finally {
+            personaOverride.restore()
+            startingRef.current = false
+        }
+    }, [voice, api, backend])
+
+    useEffect(() => {
+        if (!voice) {
+            return
+        }
+
+        if (!focusedId) {
+            if (autoVoiceRef.current && voice.currentSessionId) {
+                autoVoiceRef.current = false
+                void voice.stopVoice()
+            }
+            return
+        }
+
+        if (isPresenting) {
+            autoVoiceRef.current = true
+            void startVoiceForFocus(focusedId)
+            return
+        }
+
+        if (autoVoiceRef.current && voice.currentSessionId) {
+            autoVoiceRef.current = false
+            void voice.stopVoice()
+        }
+    }, [focusedId, isPresenting, voice, startVoiceForFocus])
+
+    if (!voice) {
+        return null
+    }
+
+    return (
+        
+    )
+}
diff --git a/web/src/garden/GardenXrLauncher.tsx b/web/src/garden/GardenXrLauncher.tsx
new file mode 100644
index 0000000000..9ec2790e85
--- /dev/null
+++ b/web/src/garden/GardenXrLauncher.tsx
@@ -0,0 +1,36 @@
+import { lazy, Suspense, type ReactNode } from 'react'
+import { GardenXrLauncherProvider, useGardenXrLauncher } from '@/garden/context/GardenXrLauncherContext'
+
+const GardenXrOverlayLazy = lazy(() => import('@/garden/GardenXrOverlay'))
+
+export function GardenXrLauncher(props: { children: ReactNode }) {
+    return (
+        
+            {props.children}
+            
+        
+    )
+}
+
+/** Lazy-loads the Garden overlay; entry buttons live in page headers (not over the composer). */
+function GardenXrOverlayHost() {
+    const { overlayOpen, closeOverlay } = useGardenXrLauncher()
+
+    if (!overlayOpen) {
+        return null
+    }
+
+    return (
+        
+                    Loading Garden…
+                
+ )} + > + + + ) +} + +export { useGardenXrLauncher } from '@/garden/context/GardenXrLauncherContext' diff --git a/web/src/garden/GardenXrOverlay.tsx b/web/src/garden/GardenXrOverlay.tsx new file mode 100644 index 0000000000..2470640836 --- /dev/null +++ b/web/src/garden/GardenXrOverlay.tsx @@ -0,0 +1,78 @@ +import { useAppContext } from '@/lib/app-context' +import { useSessions } from '@/hooks/queries/useSessions' +import { GardenScene, GARDEN_BUILD, xrStore } from '@/garden/GardenScene' +import { GardenVoiceBridge } from '@/garden/GardenVoiceBridge' +import { GardenVoiceHud } from '@/garden/components/GardenVoiceHud' +import { GardenRuntimeProvider } from '@/garden/context/GardenRuntimeContext' +import { filterGardenSessions } from '@/garden/utils/sessionVisuals' + +type GardenXrOverlayProps = { + onClose: () => void +} + +export default function GardenXrOverlay(props: GardenXrOverlayProps) { + return ( + + + + ) +} + +function GardenXrOverlayContent(props: GardenXrOverlayProps) { + const { api } = useAppContext() + const { sessions, isLoading, error } = useSessions(api) + const visible = filterGardenSessions(sessions) + + return ( +
+ +
+
+
Garden · build {GARDEN_BUILD}
+
+ {isLoading + ? 'Loading sessions…' + : error + ? `Sessions error: ${error}` + : `${visible.length} orb${visible.length === 1 ? '' : 's'} · ${sessions.filter((s) => s.active).length} active`} +
+ {!isLoading && !error && visible.length === 0 && ( +
+ No sessions in hub — spawn an agent in HAPI first. +
+ )} + {!isLoading && !error && visible.length > 0 && ( +
+ VR: dwell to focus · voice locks until next orb +
+ )} + +
+
+ + +
+
+ + +
+ ) +} diff --git a/web/src/garden/components/AgentOrb.tsx b/web/src/garden/components/AgentOrb.tsx new file mode 100644 index 0000000000..164f43e48b --- /dev/null +++ b/web/src/garden/components/AgentOrb.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { Text } from '@react-three/drei' +import { useXR } from '@react-three/xr' +import * as THREE from 'three' +import { useQuery } from '@tanstack/react-query' +import { useAppContext } from '@/lib/app-context' +import { useVoiceOptional } from '@/lib/voice-context' +import { extractLastMessageText } from '@/garden/utils/messageText' +import { GardenLiveWebPanel } from '@/garden/components/GardenLiveWebPanel' +import { GardenYawBillboard } from '@/garden/components/GardenYawBillboard' +import { OrbPlatonicBody } from '@/garden/components/OrbPlatonicBody' +import { VoiceReactiveRings } from '@/garden/components/VoiceReactiveRings' +import { + ATTENTION_COLOR, + DWELL_SECONDS, + ORB_HIT_RADIUS, + ORB_LABEL_POSITION, + SNIPPET_COMPACT_POSITION, + SNIPPET_FOCUS_POSITION, + sessionColor, + sessionLabel, +} from '@/garden/utils/sessionVisuals' +import { resolveOrbShapeKind } from '@/garden/utils/orbShapes' +import type { SessionSummary } from '@/types/api' + +type AgentOrbProps = { + session: SessionSummary + attention: boolean + attentionCueToken: number + focused: boolean + gazeTargeted: boolean + onFocus: (sessionId: string) => void + onCueSound: (worldPosition: THREE.Vector3) => void + onHitTarget: (sessionId: string, mesh: THREE.Mesh | null) => void +} + +export function AgentOrb(props: AgentOrbProps) { + const { + session, + attention, + attentionCueToken, + focused, + gazeTargeted, + onFocus, + onCueSound, + onHitTarget, + } = props + const voice = useVoiceOptional() + const isPresenting = useXR((state) => state.session !== undefined) + const voiceLinked = voice?.currentSessionId === session.id && voice.status === 'connected' + const shapeKind = resolveOrbShapeKind(session, attention) + const groupRef = useRef(null) + const hitRef = useRef(null) + const [pointerHovered, setPointerHovered] = useState(false) + const [dwell, setDwell] = useState(0) + const targeted = pointerHovered || gazeTargeted + const baseColor = sessionColor(session) + const displayColor = useRef(new THREE.Color(baseColor)) + const targetColor = useRef(new THREE.Color(attention ? ATTENTION_COLOR : baseColor)) + const lastCueTokenRef = useRef(0) + + useEffect(() => { + onHitTarget(session.id, hitRef.current) + return () => onHitTarget(session.id, null) + }, [onHitTarget, session.id]) + + useEffect(() => { + targetColor.current.set(attention ? ATTENTION_COLOR : baseColor) + }, [attention, baseColor]) + + useEffect(() => { + if (!attention || attentionCueToken <= lastCueTokenRef.current || !groupRef.current) { + return + } + lastCueTokenRef.current = attentionCueToken + const world = new THREE.Vector3() + groupRef.current.getWorldPosition(world) + onCueSound(world) + }, [attention, attentionCueToken, onCueSound]) + + useFrame((_, delta) => { + if (targeted && !focused) { + setDwell((value) => Math.min(1, value + delta / DWELL_SECONDS)) + } else if (!focused) { + setDwell(0) + } + + displayColor.current.lerp(targetColor.current, Math.min(1, delta * 3)) + }) + + useEffect(() => { + if (dwell >= 1 && targeted) { + onFocus(session.id) + } + }, [dwell, targeted, onFocus, session.id]) + + const showLiveWeb = focused && !isPresenting + + return ( + + { + e.stopPropagation() + setPointerHovered(true) + }} + onPointerOut={(e) => { + e.stopPropagation() + setPointerHovered(false) + }} + > + + + + {voiceLinked && } + + {attention && ( + + + + + )} + + {targeted && !focused && ( + + + + + )} + + + + {!focused && ( + + + {sessionLabel(session)} + + + )} + + {!showLiveWeb && } + + {showLiveWeb && } + + ) +} + +function sessionStatus(session: SessionSummary): string { + if (session.pendingRequestKinds.length > 0) { + return 'needs you' + } + if (session.thinking) { + return 'working' + } + if (session.active) { + return 'live' + } + return 'idle' +} + +function useOrbPreview(sessionId: string) { + const { api } = useAppContext() + return useQuery({ + queryKey: ['garden', 'preview', sessionId], + queryFn: async () => { + const res = await api.getMessages(sessionId, { limit: 24 }) + return extractLastMessageText(res.messages) + }, + staleTime: 4000, + refetchInterval: 8000, + }) +} + +function OrbSnippet(props: { session: SessionSummary; compact: boolean }) { + const { data: preview, isLoading, isError } = useOrbPreview(props.session.id) + const status = sessionStatus(props.session) + const body = isError + ? '(could not load messages)' + : isLoading + ? '…' + : preview ?? '(no speakable messages yet)' + + const position = props.compact ? SNIPPET_COMPACT_POSITION : SNIPPET_FOCUS_POSITION + const fontSize = props.compact ? 0.08 : 0.11 + const maxWidth = props.compact ? 1.8 : 2.5 + const limit = props.compact ? 96 : 420 + + return ( + + {!props.compact && ( + + + + + )} + + {props.compact + ? `[${status}] ${body.slice(0, limit)}` + : `${sessionLabel(props.session)}\nstatus: ${status}\n---\n${body.slice(0, limit)}`} + + + ) +} diff --git a/web/src/garden/components/ExitPad.tsx b/web/src/garden/components/ExitPad.tsx new file mode 100644 index 0000000000..c77392a156 --- /dev/null +++ b/web/src/garden/components/ExitPad.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { Text } from '@react-three/drei' +import * as THREE from 'three' +import { useXRStore } from '@react-three/xr' + +const EXIT_HOLD_SECONDS = 2.5 + +type ExitPadProps = { + gazeTargeted: boolean + onHitTarget: (mesh: THREE.Mesh | null) => void +} + +export function ExitPad(props: ExitPadProps) { + const { gazeTargeted, onHitTarget } = props + const store = useXRStore() + const hitRef = useRef(null) + const [pointerHovered, setPointerHovered] = useState(false) + const [progress, setProgress] = useState(0) + const progressRef = useRef(0) + const triggeredRef = useRef(false) + const targeted = pointerHovered || gazeTargeted + + useEffect(() => { + onHitTarget(hitRef.current) + return () => onHitTarget(null) + }, [onHitTarget]) + + useFrame((_, delta) => { + if (targeted && !triggeredRef.current) { + const next = Math.min(1, progressRef.current + delta / EXIT_HOLD_SECONDS) + progressRef.current = next + setProgress(next) + if (next >= 1) { + triggeredRef.current = true + void store.getState().session?.end() + } + } else if (!targeted) { + progressRef.current = 0 + setProgress(0) + triggeredRef.current = false + } + }) + + return ( + + { + e.stopPropagation() + setPointerHovered(true) + }} + onPointerOut={() => setPointerHovered(false)} + > + + + + + + + + + + {progress > 0 && ( + + + + + )} + + + {progress > 0 ? `Exit VR ${Math.ceil((1 - progress) * EXIT_HOLD_SECONDS)}s` : 'Look down to exit VR'} + + + ) +} diff --git a/web/src/garden/components/GardenHeadRaycaster.tsx b/web/src/garden/components/GardenHeadRaycaster.tsx new file mode 100644 index 0000000000..15991ddb8a --- /dev/null +++ b/web/src/garden/components/GardenHeadRaycaster.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, type RefObject } from 'react' +import { useFrame, useThree } from '@react-three/fiber' +import { useXR } from '@react-three/xr' +import * as THREE from 'three' + +export type GardenGazeTarget = + | { kind: 'orb'; sessionId: string } + | { kind: 'exit' } + | null + +type GardenHeadRaycasterProps = { + orbHitTargetsRef: React.RefObject> + exitHitTargetRef: React.RefObject + onGazeTarget: (target: GardenGazeTarget) => void +} + +function hasActiveControllers(inputSourceStates: ReadonlyArray<{ type: string }>): boolean { + return inputSourceStates.some((state) => state.type === 'controller') +} + +/** + * 3DOF head-forward gaze ray. Disabled when XR controllers are active (future: pointer fallback). + */ +export function GardenHeadRaycaster(props: GardenHeadRaycasterProps) { + const { orbHitTargetsRef, exitHitTargetRef, onGazeTarget } = props + const { camera } = useThree() + const isPresenting = useXR((state) => state.session !== undefined) + const inputSourceStates = useXR((state) => state.inputSourceStates) + const raycaster = useRef(new THREE.Raycaster()) + const direction = useRef(new THREE.Vector3()) + const lastTarget = useRef(undefined) + + useEffect(() => { + if (!isPresenting) { + lastTarget.current = null + onGazeTarget(null) + } + }, [isPresenting, onGazeTarget]) + + useFrame(() => { + if (!isPresenting || hasActiveControllers(inputSourceStates)) { + if (lastTarget.current !== null) { + lastTarget.current = null + onGazeTarget(null) + } + return + } + + camera.getWorldDirection(direction.current) + raycaster.current.set(camera.position, direction.current) + + const orbHitTargets = orbHitTargetsRef.current + const exitHitTarget = exitHitTargetRef.current + const orbMeshes = orbHitTargets ? [...orbHitTargets.values()].filter(Boolean) : [] + const candidates: THREE.Object3D[] = [...orbMeshes] + if (exitHitTarget) { + candidates.push(exitHitTarget) + } + + if (candidates.length === 0) { + if (lastTarget.current !== null) { + lastTarget.current = null + onGazeTarget(null) + } + return + } + + const hits = raycaster.current.intersectObjects(candidates, false) + const hit = hits[0]?.object ?? null + + let next: GardenGazeTarget = null + if (hit) { + const sessionId = hit.userData.gardenSessionId as string | undefined + if (sessionId) { + next = { kind: 'orb', sessionId } + } else if (hit.userData.gardenExitPad) { + next = { kind: 'exit' } + } + } + + const prev = lastTarget.current + const changed = + prev?.kind !== next?.kind + || (prev?.kind === 'orb' && next?.kind === 'orb' && prev.sessionId !== next.sessionId) + + if (changed) { + lastTarget.current = next + onGazeTarget(next) + } + }) + + return null +} diff --git a/web/src/garden/components/GardenLiveWebPanel.tsx b/web/src/garden/components/GardenLiveWebPanel.tsx new file mode 100644 index 0000000000..db77f76473 --- /dev/null +++ b/web/src/garden/components/GardenLiveWebPanel.tsx @@ -0,0 +1,43 @@ +import { Html } from '@react-three/drei' +import { useXR } from '@react-three/xr' +import { LIVE_WEB_PANEL_POSITION } from '@/garden/utils/sessionVisuals' + +type GardenLiveWebPanelProps = { + sessionId: string +} + +/** + * Real HAPI session chat in 3D space (flat browser only). + * Quest immersive mode cannot host interactive DOM/iframes — VR falls back to text panels. + */ +export function GardenLiveWebPanel(props: GardenLiveWebPanelProps) { + const isPresenting = useXR((state) => state.session !== undefined) + + if (isPresenting) { + return null + } + + const src = `/sessions/${encodeURIComponent(props.sessionId)}` + + return ( + +
+