Spike S1: resume/fork/liveness — GO; flood: pause/resume — GO

Demonstrated on CLI 2.1.173: resuming a live session interleaves both
TUIs into one transcript (no lock, no warning) — liveness detection via
registry pid+procStart is mandatory; --fork-session is safe on live
sessions; --resume must run in the session's original cwd ("No
conversation found" otherwise); registry files are cleaned on graceful
exit AND SIGTERM, may be GC'd later after SIGKILL — never reason on
file presence. ANSI-stripped TUI text loses spaces (cursor-positioned
painting) — confirms @xterm/headless for screen parsing.
Flood: 21 MB through node-pty with 10s pause => 0 bytes leaked, no
loss, 4.7 ms echo after flood.
This commit is contained in:
2026-06-11 17:47:05 +02:00
parent d9b21a2613
commit 3d2f7a2041
7 changed files with 1050 additions and 0 deletions

459
spikes/package-lock.json generated Normal file
View File

@@ -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"
}
}
}

15
spikes/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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 <id>` 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 <id> --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/<munge(cwd)>/<sid>.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.

View File

@@ -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'));
}

155
spikes/s1-resume/run.mjs Normal file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Spike S1 — resume/fork/vivacité. Scénarios :
// 1. session A vivante → claude --resume <id> 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 <sid> 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);
}

67
spikes/s3-tui/flood.mjs Normal file
View File

@@ -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));

190
spikes/s3-tui/run.mjs Normal file
View File

@@ -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);
}