diff --git a/spikes/package-lock.json b/spikes/package-lock.json new file mode 100644 index 0000000..62ed0c8 --- /dev/null +++ b/spikes/package-lock.json @@ -0,0 +1,459 @@ +{ + "name": "spikes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spikes", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1" + } + }, + "node_modules/@homebridge/node-pty-prebuilt-multiarch": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", + "integrity": "sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "prebuild-install": "^7.1.2" + }, + "engines": { + "node": ">=18.0.0 <25.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/spikes/package.json b/spikes/package.json new file mode 100644 index 0000000..2517ef9 --- /dev/null +++ b/spikes/package.json @@ -0,0 +1,15 @@ +{ + "name": "spikes", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1" + } +} diff --git a/spikes/s1-resume/VERDICT.md b/spikes/s1-resume/VERDICT.md new file mode 100644 index 0000000..1d5c630 --- /dev/null +++ b/spikes/s1-resume/VERDICT.md @@ -0,0 +1,29 @@ +# Spike S1 — resume / fork / vivacité — VERDICT : ✅ GO + +Exécuté le 2026-06-11 (CLI claude 2.1.173, repos jetables /tmp/spike-s1-*). Scripts : `harness.mjs` + `run.mjs`, captures brutes et `results.json` dans `captures/` (non versionnés). + +## Faits démontrés + +| # | Scénario | Résultat | +|---|---|---| +| 1 | `claude --resume ` pendant que la session est **vivante** | **Aucun verrou, aucun avertissement.** B s'ouvre sur le même sessionId, les deux process s'inscrivent au registre avec le même sessionId, et les messages des deux TUI **s'entrelacent dans le même transcript** (ALPHA/BRAVO/CHARLIE dans un seul fichier). Corruption logique confirmée → le daemon DOIT détecter la vivacité avant tout resume. | +| 2 | `--resume --fork-session` sur session vivante | **Sûr.** Nouveau sessionId, nouveau fichier contenant la copie de l'historique + ses propres messages ; l'original n'est **pas** pollué. C'est l'opération à proposer pour une session vivante (« Dupliquer »). | +| 3 | kill -9 puis détection | Le fichier registre **reste** (stale) immédiatement après ; `pid` mort + `procStart` discordant le détectent à coup sûr. `--resume` après mort : même sessionId, continuation propre dans le même fichier, 0 parentUuid cassé. | +| 4 | resume depuis un **autre cwd** | **Échec explicite** : « No conversation found with session ID » — le CLI cherche la session dans le dossier projet dérivé du cwd **courant**. → Le daemon doit toujours lancer `--resume` dans le cwd d'origine de la session (champ `cwd` du JSONL), comme prévu au design. | +| 5 | sortie gracieuse (`/exit`) | Exit code 0, fichier registre **nettoyé**. | +| 6 | SIGTERM | Fichier registre **nettoyé** aussi (handler du CLI). | +| 7 | GC des stale | Le fichier stale du kill -9 a été nettoyé plus tard par une autre instance du CLI → **ne jamais raisonner sur la présence/absence du fichier**, toujours valider pid+procStart à la lecture. | + +## Enseignements supplémentaires pour le claude-adapter + +- **Le strip ANSI naïf mange les espaces** : le TUI peint le texte par déplacements de curseur (« Isthisaprojectyoucreated… »). Toute détection sur texte brut doit matcher sur une version sans espaces ; la reconstruction d'écran fiable passe par `@xterm/headless` (confirme la reco du design moteur). +- **Trust dialog** (nouveau dossier) : précède l'inscription au registre, texte « Quick safety check: Is this a project you created or one you trust? », options `❯ 1. Yes, I trust this folder / 2. No, exit`, « Enter to confirm · Esc to cancel ». **Entrée** valide l'option par défaut → géré. +- Le registre s'écrit ~immédiatement après le trust ; `waitReady` = poll du registre par pid est une primitive fiable de readiness. +- Les réponses des sessions `-n`/spawnées apparaissent bien dans `~/.claude/projects//.jsonl` en continu (flush par tour). + +## Décisions actées pour P1/P3 + +1. Politique de reprise (inchangée vs design) : vivante → Observer/Dupliquer uniquement ; morte → resume direct ; incertain → traiter comme vivante. +2. `--resume` toujours exécuté dans le cwd d'origine lu dans le JSONL. +3. `isLive()` = pid+procStart à chaque lecture du registre, jamais de cache de présence de fichier. +4. La machine à états peut compter sur le nettoyage du registre aux sorties propres (exit//exit/SIGTERM) et sur la détection procStart pour les morts brutales. diff --git a/spikes/s1-resume/harness.mjs b/spikes/s1-resume/harness.mjs new file mode 100644 index 0000000..d4e49be --- /dev/null +++ b/spikes/s1-resume/harness.mjs @@ -0,0 +1,135 @@ +// Spike S1 — harnais commun : spawn de claude dans un PTY + observation registre/JSONL +import ptyMod from '@homebridge/node-pty-prebuilt-multiarch'; +import { readdirSync, readFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export const SESSIONS_DIR = join(homedir(), '.claude', 'sessions'); +export const PROJECTS_DIR = join(homedir(), '.claude', 'projects'); +export const munge = (p) => p.replace(/[^A-Za-z0-9]/g, '-'); +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][AB0-2]|\x1b[=>]|[\x00-\x08\x0b-\x1f]/g; +export const stripAnsi = (s) => s.replace(ANSI, ''); + +export function log(step, data = {}) { + const line = JSON.stringify({ t: new Date().toISOString(), step, ...data }); + console.log(line); + appendFileSync(LOG_FILE, line + '\n'); +} +let LOG_FILE = '/tmp/s1.log'; +export function setLogFile(p) { LOG_FILE = p; } + +export class ClaudeSession { + constructor(args, cwd, label, capturesDir) { + mkdirSync(capturesDir, { recursive: true }); + this.label = label; + this.rawPath = join(capturesDir, `${label}.raw.log`); + this.buf = ''; + this.pty = ptyMod.spawn('claude', args, { + name: 'xterm-256color', cols: 120, rows: 32, cwd, + env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + this.pid = this.pty.pid; + this.exited = null; + this.pty.onData((d) => { this.buf += d; appendFileSync(this.rawPath, d); }); + this.pty.onExit((e) => { this.exited = e; }); + } + write(s) { this.pty.write(s); } + // Tape un prompt puis Entrée (séparés : le CLI traite les écritures multi-caractères comme un collage) + async type(text) { this.write(text); await sleep(400); this.write('\r'); } + plain() { return stripAnsi(this.buf); } + tail(n = 1200) { return this.plain().slice(-n); } + registry() { + if (!existsSync(SESSIONS_DIR)) return null; + for (const f of readdirSync(SESSIONS_DIR)) { + if (!f.endsWith('.json')) continue; + try { + const r = JSON.parse(readFileSync(join(SESSIONS_DIR, f), 'utf8')); + if (r.pid === this.pid) return { file: f, ...r }; + } catch { /* fichier en cours d'écriture */ } + } + return null; + } + async until(desc, fn, timeoutMs = 60000, everyMs = 400) { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + const v = await fn(); + if (v) { log(`${this.label}: ${desc}`, { afterMs: Date.now() - t0 }); return v; } + await sleep(everyMs); + } + log(`${this.label}: TIMEOUT — ${desc}`, { timeoutMs, screenTail: this.tail(600) }); + throw new Error(`timeout: ${this.label} ${desc}`); + } + // Démarrage : gère le trust dialog (précède l'inscription au registre) puis attend le registre. + // NB : le TUI peint avec des déplacements de curseur, pas des espaces → matcher sur texte SANS espaces. + async waitReady(timeoutMs = 90000) { + const t0 = Date.now(); + let trustHandled = false; + while (Date.now() - t0 < timeoutMs) { + const reg = this.registry(); + if (reg) { log(`${this.label}: prêt (registre)`, { sessionId: reg.sessionId, status: reg.status, trustHandled }); return reg; } + const flat = this.tail(2500).replace(/\s+/g, ''); + if (!trustHandled && /trust/i.test(flat) && /(❯|Entertoconfirm)/i.test(flat)) { + log(`${this.label}: trust dialog détecté → Entrée`, { screenTail: this.tail(500) }); + this.write('\r'); + trustHandled = true; + } + await sleep(400); + } + log(`${this.label}: TIMEOUT waitReady`, { screenTail: this.tail(1500) }); + throw new Error(`timeout: ${this.label} waitReady`); + } + // Attend la fin d'un tour : busy observé (ou déjà passé) puis retour à idle/waiting + async waitTurnDone(timeoutMs = 150000) { + let sawBusy = false; + await this.until('tour terminé', () => { + const r = this.registry(); + if (!r) return false; + if (r.status === 'busy') { sawBusy = true; return false; } + return sawBusy && r.status !== 'busy' ? r : false; + }, timeoutMs, 500); + return this.registry(); + } + kill(signal = 'SIGTERM') { try { process.kill(this.pid, signal); } catch {} } +} + +export function registrySnapshot() { + if (!existsSync(SESSIONS_DIR)) return []; + return readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json')).map((f) => { + try { + const r = JSON.parse(readFileSync(join(SESSIONS_DIR, f), 'utf8')); + let alive = false, procStartOk = null; + try { + const stat = readFileSync(`/proc/${r.pid}/stat`, 'utf8'); + const starttime = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]; + alive = true; procStartOk = String(r.procStart) === String(starttime); + } catch {} + return { file: f, pid: r.pid, sessionId: r.sessionId, status: r.status, waitingFor: r.waitingFor ?? null, alive, procStartOk }; + } catch (e) { return { file: f, error: e.message }; } + }); +} + +export function jsonlInfo(cwd, sessionId) { + const p = join(PROJECTS_DIR, munge(cwd), `${sessionId}.jsonl`); + if (!existsSync(p)) return { path: p, exists: false }; + const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim()); + const objs = []; + for (const l of lines) { try { objs.push(JSON.parse(l)); } catch {} } + const users = objs.filter((o) => o.type === 'user' && typeof o.message?.content === 'string'); + const userTexts = users.map((o) => o.message.content.slice(0, 80)); + const uuids = new Map(objs.filter((o) => o.uuid).map((o) => [o.uuid, o])); + let brokenParents = 0; + for (const o of objs) if (o.parentUuid && !uuids.has(o.parentUuid)) brokenParents++; + return { + path: p, exists: true, mtimeMs: statSync(p).mtimeMs, lines: lines.length, + userTexts, brokenParents, + sessionIds: [...new Set(objs.map((o) => o.sessionId).filter(Boolean))], + }; +} + +export function listJsonl(cwd) { + const dir = join(PROJECTS_DIR, munge(cwd)); + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((f) => f.endsWith('.jsonl')); +} diff --git a/spikes/s1-resume/run.mjs b/spikes/s1-resume/run.mjs new file mode 100644 index 0000000..b1ee080 --- /dev/null +++ b/spikes/s1-resume/run.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// Spike S1 — resume/fork/vivacité. Scénarios : +// 1. session A vivante → claude --resume en B : comportement (interleave ?) +// 2. --fork-session sur session vivante : nouveau fichier, original intact +// 3. kill -9 : fichier registre stale détectable (pid/procStart), resume sûr ensuite +// 4. resume depuis un autre cwd : cwd effectif +// 5. sortie gracieuse : nettoyage du fichier registre ? +import { execSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { + ClaudeSession, registrySnapshot, jsonlInfo, listJsonl, sleep, setLogFile, log, munge, +} from './harness.mjs'; + +const CAPTURES = new URL('./captures/', import.meta.url).pathname; +mkdirSync(CAPTURES, { recursive: true }); +setLogFile(join(CAPTURES, 'steps.jsonl')); + +const REPO = '/tmp/spike-s1-repo'; +const OTHER = '/tmp/spike-s1-other'; +for (const d of [REPO, OTHER]) { + rmSync(d, { recursive: true, force: true }); + mkdirSync(d, { recursive: true }); + execSync(`git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init`, { cwd: d, shell: '/bin/bash' }); +} +const results = {}; +const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1)); + +try { + // ---------- Scénario 1 : resume d'une session VIVANTE ---------- + log('=== S1.1 spawn session A ==='); + const A = new ClaudeSession([], REPO, 'A', CAPTURES); + const regA = await A.waitReady(); + const sid = regA.sessionId; + log('A: sessionId', { sid, pid: A.pid }); + + await A.type('Reply with exactly: PONG-ALPHA'); + await A.waitTurnDone(); + results.s1_initial = { sid, jsonl: jsonlInfo(REPO, sid) }; + save(); + + log('=== S1.1 spawn B = claude --resume pendant que A est vivante ==='); + const B = new ClaudeSession(['--resume', sid], REPO, 'B', CAPTURES); + let regB = null; + try { regB = await B.waitReady(30000); } catch { /* peut refuser/ne pas s'inscrire */ } + await sleep(3000); + results.s1_resumeLive = { + bStarted: !!regB, + bSessionId: regB?.sessionId ?? null, + bSameId: regB?.sessionId === sid, + bExit: B.exited, + bScreenTail: B.tail(900), + registryAfterB: registrySnapshot().filter((r) => [A.pid, B.pid].includes(r.pid)), + }; + save(); + + if (regB && !B.exited) { + await B.type('Reply with exactly: PONG-BRAVO'); + await B.waitTurnDone(); + await A.type('Reply with exactly: PONG-CHARLIE'); + await A.waitTurnDone(); + await sleep(2000); + const fileSid = jsonlInfo(REPO, sid); + const fileB = regB.sessionId !== sid ? jsonlInfo(REPO, regB.sessionId) : null; + results.s1_interleave = { + originalFile: fileSid, + bFile: fileB, + interleaved: fileSid.userTexts.some((t) => t.includes('PONG-BRAVO')) && fileSid.userTexts.some((t) => t.includes('PONG-CHARLIE')), + aScreenTail: A.tail(600), + }; + save(); + } + + // ---------- Scénario 2 : fork d'une session vivante ---------- + log('=== S1.2 fork-session pendant que A est vivante ==='); + if (regB && !B.exited) { B.kill('SIGTERM'); await sleep(1500); } + const beforeFork = jsonlInfo(REPO, sid); + const C = new ClaudeSession(['--resume', sid, '--fork-session'], REPO, 'C', CAPTURES); + const regC = await C.waitReady(); + await C.type('Reply with exactly: PONG-DELTA'); + await C.waitTurnDone(); + await sleep(2000); + const afterFork = jsonlInfo(REPO, sid); + results.s2_fork = { + cSessionId: regC.sessionId, + forked: regC.sessionId !== sid, + cFile: jsonlInfo(REPO, regC.sessionId), + originalUserTexts: afterFork.userTexts, + originalGotDelta: afterFork.userTexts.some((t) => t.includes('PONG-DELTA')), + allJsonl: listJsonl(REPO), + }; + save(); + + // ---------- Scénario 3 : kill -9 → registre stale → resume sûr ---------- + log('=== S1.3 kill -9 de C, détection stale, resume après mort ==='); + const cPid = C.pid, cSid = regC.sessionId; + C.kill('SIGKILL'); + await sleep(2500); + const staleSnap = registrySnapshot().filter((r) => r.pid === cPid); + results.s3_kill9 = { registryFileRemains: staleSnap.length > 0, entry: staleSnap[0] ?? null }; + const D = new ClaudeSession(['--resume', cSid], REPO, 'D', CAPTURES); + const regD = await D.waitReady(); + await D.type('Reply with exactly: PONG-ECHO'); + await D.waitTurnDone(); + results.s3_resumeAfterDeath = { + dSessionId: regD.sessionId, sameAsC: regD.sessionId === cSid, + dFile: jsonlInfo(REPO, regD.sessionId), + }; + save(); + + // ---------- Scénario 5 : sortie gracieuse — nettoyage du registre ? ---------- + log('=== S1.5 sortie gracieuse de D (/exit) ==='); + await D.type('/exit'); + await sleep(4000); + if (!D.exited) { D.write('\r'); await sleep(4000); } + results.s5_gracefulExit = { + exited: D.exited, + registryCleaned: registrySnapshot().filter((r) => r.pid === D.pid).length === 0, + }; + save(); + + // ---------- Scénario 4 : resume depuis un AUTRE cwd ---------- + log('=== S1.4 resume de la session de A depuis un autre cwd ==='); + A.kill('SIGTERM'); + await sleep(2000); + const E = new ClaudeSession(['--resume', sid], OTHER, 'E', CAPTURES); + let regE = null; + try { regE = await E.waitReady(30000); } catch {} + if (regE) { + await E.type('Without using any tool, what is the current working directory given in your environment context? Reply with the path only.'); + await E.waitTurnDone(); + await sleep(1500); + } + results.s4_otherCwd = { + started: !!regE, + eSessionId: regE?.sessionId ?? null, + registryCwd: regE?.cwd ?? null, + eScreenTail: E.tail(900), + jsonlInRepoDir: listJsonl(REPO), + jsonlInOtherDir: listJsonl(OTHER), + }; + E.kill('SIGTERM'); + save(); + + log('=== S1 terminé ==='); +} catch (err) { + results.fatalError = String(err); + save(); + log('ERREUR FATALE', { err: String(err) }); +} finally { + // Nettoyage : tuer tout claude restant lancé par ce script (pas ceux de l'utilisateur !) + await sleep(1000); + save(); + process.exit(0); +} diff --git a/spikes/s3-tui/flood.mjs b/spikes/s3-tui/flood.mjs new file mode 100644 index 0000000..8afa49a --- /dev/null +++ b/spikes/s3-tui/flood.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Spike S3 (volet flood) — pause()/resume() node-pty sous 20 Mo de débit, sans claude (zéro quota). +// Critères : aucune perte (marqueur final reçu, volume >= attendu), 0 octet pendant pause, +// process bash survit à 10 s de pause, écho < 100 ms après le flood. +import ptyMod from '@homebridge/node-pty-prebuilt-multiarch'; +import { execSync } from 'node:child_process'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const CAPTURES = new URL('./captures/', import.meta.url).pathname; +mkdirSync(CAPTURES, { recursive: true }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// 20 Mo de base64 (lignes de 76 chars) — la conversion LF→CRLF du TTY est prise en compte +execSync('head -c 15728640 /dev/urandom | base64 > /tmp/spike-flood.txt && wc -c < /tmp/spike-flood.txt'); +const fileBytes = parseInt(execSync('wc -c < /tmp/spike-flood.txt').toString().trim(), 10); +const fileLines = parseInt(execSync('wc -l < /tmp/spike-flood.txt').toString().trim(), 10); +const expectedMin = fileBytes; // reçu = fileBytes + fileLines (CR ajoutés) ; on vérifie >= fileBytes + +const p = ptyMod.spawn('bash', ['--norc'], { name: 'xterm-256color', cols: 120, rows: 32, cwd: '/tmp', env: process.env }); +let received = 0; +let buf = ''; +let pausedBytes = 0; +let paused = false; +p.onData((d) => { + received += Buffer.byteLength(d); + if (paused) pausedBytes += Buffer.byteLength(d); + buf = (buf + d).slice(-4000); +}); + +const results = {}; +const t0 = Date.now(); +await sleep(500); +p.write('cat /tmp/spike-flood.txt; echo FLOOD-DONE-MARKER\r'); + +// Après ~2 Mo reçus : pause 10 s +while (received < 2 * 1024 * 1024 && Date.now() - t0 < 60000) await sleep(50); +const beforePause = received; +p.pause(); paused = true; +await sleep(10000); +p.resume(); paused = false; +results.pause = { beforePause, bytesDuringPause: pausedBytes, pauseHeld: pausedBytes < 65536 }; + +// Attendre la fin du flood +const tFlood = Date.now(); +while (!buf.includes('FLOOD-DONE-MARKER') && Date.now() - tFlood < 120000) await sleep(100); +results.flood = { + fileBytes, fileLines, received, receivedAtLeastFile: received >= expectedMin, + markerReceived: buf.includes('FLOOD-DONE-MARKER'), + totalMs: Date.now() - t0, +}; + +// Écho après flood : write d'un marqueur, mesure du round-trip +await sleep(500); +const echoT0 = process.hrtime.bigint(); +p.write('echo ECHO-RT-TEST'); +let echoMs = null; +const tEcho = Date.now(); +while (Date.now() - tEcho < 5000) { + if (buf.includes('ECHO-RT-TEST')) { echoMs = Number(process.hrtime.bigint() - echoT0) / 1e6; break; } + await sleep(5); +} +results.echo = { echoMs, under100ms: echoMs !== null && echoMs < 100 }; + +p.kill(); +writeFileSync(join(CAPTURES, 'flood-results.json'), JSON.stringify(results, null, 1)); +console.log(JSON.stringify(results, null, 1)); diff --git a/spikes/s3-tui/run.mjs b/spikes/s3-tui/run.mjs new file mode 100644 index 0000000..59eff12 --- /dev/null +++ b/spikes/s3-tui/run.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Spike S3 — dialogues TUI : détection (registre + écran), extraction des options, frappes de réponse. +// Types couverts : trust dialog (nouveau dossier), permission Bash, permission Write (édition), +// AskUserQuestion, refus par Esc. (Approbation de plan : suivi manuel, voir VERDICT.) +import { execSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { ClaudeSession, sleep, setLogFile, log } from '../s1-resume/harness.mjs'; + +const CAPTURES = new URL('./captures/', import.meta.url).pathname; +mkdirSync(CAPTURES, { recursive: true }); +setLogFile(join(CAPTURES, 'steps.jsonl')); + +const results = {}; +const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1)); + +function freshRepo(name) { + const d = `/tmp/spike-s3-${name}`; + rmSync(d, { recursive: true, force: true }); + mkdirSync(d, { recursive: true }); + execSync('git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init', { cwd: d, shell: '/bin/bash' }); + return d; +} + +// Extraction des options d'un dialogue depuis l'écran (regex du design moteur). +// NB : le strip ANSI mange les espaces peints par déplacements de curseur → \s* après le point, +// et les labels peuvent être concaténés (« Yes,Itrustthisfolder »). +function extractOptions(screenTail) { + const opts = []; + for (const m of screenTail.matchAll(/^\s*(❯\s*)?(\d+)\.\s*(.+)$/gm)) { + opts.push({ selected: !!m[1], n: m[2], label: m[3].trim().slice(0, 90) }); + } + return opts; +} + +async function waitWaiting(S, timeoutMs = 120000) { + return S.until('registre → waiting', () => { + const r = S.registry(); + return r && r.status === 'waiting' ? r : false; + }, timeoutMs, 400); +} + +// Répond au dialogue : essaie la touche chiffre seule, puis Entrée, puis flèches+Entrée. +async function respond(S, digit) { + const tries = []; + S.write(digit); + await sleep(2500); + let r = S.registry(); + tries.push({ method: `digit ${digit}`, statusAfter: r?.status }); + if (r?.status !== 'waiting') return { ok: true, tries }; + S.write('\r'); + await sleep(2500); + r = S.registry(); + tries.push({ method: 'enter', statusAfter: r?.status }); + if (r?.status !== 'waiting') return { ok: true, tries }; + const downs = Math.max(0, parseInt(digit, 10) - 1); + for (let i = 0; i < downs; i++) { S.write('\x1b[B'); await sleep(250); } + S.write('\r'); + await sleep(2500); + r = S.registry(); + tries.push({ method: `arrows x${downs} + enter`, statusAfter: r?.status }); + return { ok: r?.status !== 'waiting', tries }; +} + +try { + // ---------- D1 : trust dialog sur dossier neuf + état ~/.claude.json ---------- + log('=== D1 trust dialog ==='); + const d1 = freshRepo('trust'); + const claudeJsonBefore = existsSync(join(homedir(), '.claude.json')) + ? JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')) : null; + const trustBefore = claudeJsonBefore?.projects?.[d1] ?? null; + const T = new ClaudeSession([], d1, 'trust', CAPTURES); + // waitReady gère le trust par Entrée et logge l'écran du dialogue + const regT = await T.waitReady(60000); + await sleep(1500); + const claudeJsonAfter = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')); + results.d1_trust = { + sessionStarted: !!regT, + projectEntryBefore: trustBefore, + projectEntryAfter: claudeJsonAfter?.projects?.[d1] ?? null, + note: 'écran du dialogue dans trust.raw.log + steps.jsonl', + }; + T.kill('SIGTERM'); + save(); + + // ---------- D2 : permission Bash → réponse "1" (Yes) ---------- + log('=== D2 permission Bash, approbation ==='); + const d2 = freshRepo('bash'); + const P = new ClaudeSession([], d2, 'perm-bash', CAPTURES); + await P.waitReady(60000); + await P.type('Run the bash command `echo spike-s3-marker-xyz` and show me its output. Do not do anything else.'); + const regP = await waitWaiting(P); + await sleep(1200); // laisse Ink finir de peindre + const screenP = P.tail(2500); + const optsP = extractOptions(screenP); + const respP = await respond(P, '1'); + let completed = false; + try { await P.waitTurnDone(120000); completed = true; } catch {} + results.d2_permBash = { + waitingFor: regP.waitingFor ?? null, + options: optsP, + screenExcerpt: screenP.slice(-1100), + respond: respP, + turnCompleted: completed, + markerInOutput: P.plain().includes('spike-s3-marker-xyz'), + }; + P.kill('SIGTERM'); + save(); + + // ---------- D3 : permission Write (édition) → réponse "1" ---------- + log('=== D3 permission Write ==='); + const d3 = freshRepo('write'); + const W = new ClaudeSession([], d3, 'perm-write', CAPTURES); + await W.waitReady(60000); + await W.type('Create a file named s3-edit.txt containing exactly the word hello. Do not do anything else.'); + const regW = await waitWaiting(W); + await sleep(1200); + const screenW = W.tail(2500); + const respW = await respond(W, '1'); + let completedW = false; + try { await W.waitTurnDone(120000); completedW = true; } catch {} + results.d3_permWrite = { + waitingFor: regW.waitingFor ?? null, + options: extractOptions(screenW), + screenExcerpt: screenW.slice(-1100), + respond: respW, + turnCompleted: completedW, + fileCreated: existsSync(join(d3, 's3-edit.txt')), + }; + W.kill('SIGTERM'); + save(); + + // ---------- D4 : refus par Esc ---------- + log('=== D4 refus par Esc ==='); + const d4 = freshRepo('deny'); + const X = new ClaudeSession([], d4, 'deny-esc', CAPTURES); + await X.waitReady(60000); + await X.type('Run the bash command `echo should-be-denied` and show me its output. Do not do anything else.'); + await waitWaiting(X); + await sleep(1200); + X.write('\x1b'); // Esc + await sleep(3500); + const regX = X.registry(); + results.d4_denyEsc = { + statusAfterEsc: regX?.status, + denied: !X.plain().includes('should-be-denied\r\nshould-be-denied') /* marqueur non exécuté */, + screenTail: X.tail(900), + }; + X.kill('SIGTERM'); + save(); + + // ---------- D5 : AskUserQuestion ---------- + log('=== D5 AskUserQuestion ==='); + const d5 = freshRepo('ask'); + const Q = new ClaudeSession([], d5, 'ask-question', CAPTURES); + await Q.waitReady(60000); + await Q.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.'); + const regQ = await waitWaiting(Q); + await sleep(1200); + const screenQ = Q.tail(2500); + // Sur AskUserQuestion : essaie flèche bas + Entrée (sélection de la 2e option) + Q.write('\x1b[B'); await sleep(400); Q.write('\r'); + await sleep(3000); + let regQ2 = Q.registry(); + const arrowsWorked = regQ2?.status !== 'waiting'; + let digitTried = null; + if (!arrowsWorked) { Q.write('2'); await sleep(2500); regQ2 = Q.registry(); digitTried = regQ2?.status; } + let completedQ = false; + try { await Q.waitTurnDone(90000); completedQ = true; } catch {} + results.d5_askUserQuestion = { + waitingFor: regQ.waitingFor ?? null, + options: extractOptions(screenQ), + screenExcerpt: screenQ.slice(-1100), + arrowsEnterWorked: arrowsWorked, + digitFallbackStatus: digitTried, + turnCompleted: completedQ, + }; + Q.kill('SIGTERM'); + save(); + + log('=== S3 dialogues terminé ==='); +} catch (err) { + results.fatalError = String(err); + save(); + log('ERREUR FATALE', { err: String(err) }); +} finally { + save(); + process.exit(0); +}