2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
304
bun.lock
Normal file
304
bun.lock
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "tmux-utils",
|
||||||
|
"dependencies": {
|
||||||
|
"@ast-grep/cli": "^0.41.1",
|
||||||
|
"@ast-grep/napi": "^0.41.1",
|
||||||
|
"@clack/prompts": "^0.11.0",
|
||||||
|
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
|
"@opencode-ai/plugin": "^1.2.24",
|
||||||
|
"@opencode-ai/sdk": "^1.2.24",
|
||||||
|
"commander": "^14.0.2",
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
|
"zod": "^4.1.8",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/picomatch": "^3.0.2",
|
||||||
|
"bun-types": "1.3.10",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@ast-grep/cli",
|
||||||
|
"@ast-grep/napi",
|
||||||
|
"@code-yeongyu/comment-checker",
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"@opencode-ai/sdk": "^1.2.24",
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-30lrXtyDB+16WS89Bk8sufA5TVUczyQye4PoIYLxZr+PRbPW7thpxHwBwGWL6QvPvUtlElrCe4seA1CEwFxeFA=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jRft57aWRgqYgLXooWxS9Nx5mb5JJ/KQIwEqacWkcmDZEdEui7oG50//6y4/vU5WRcS1n6oB2Vs7WBvTh3/Ypg=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1XUL+8u+Xs1FoM2W6F4v8pRa2aQQcp5CZXBG8uy9n8FhwsQtrhBclJ2Vr9g/zzswHQT1293mnP5TOk1wlYZq6w=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oSsbXzbcl4hnRAw7b1bTFZapx9s+O8ToJJKI44oJAb7xKIG3Rubn2IMBOFvMvjjWEEax8PpS2IocgdB8nUAcbA=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-jTMNqjXnQUhInMB1X06sxWZJv/6pd4/iYSyk8RR5kdulnuNzoGEB9KYbm6ojxktPtMfZpb+7eShQLqqy/dG6Ag=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-mCTyr6/KQneKk0iYaWup4ywW5buNcFqL6TrJVfU0tkd38fu/RtJ5zywr978vVvFxsY+urRU0qkrmtQqXQNwDFA=="],
|
||||||
|
|
||||||
|
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-AUbR67UKWsfgyy3SWQq258ZB0xSlaAe15Gl5hPu5tbUu4HTt6rKrUCTEEubYgbNdPPZWtxjobjFjMsDTWfnrug=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi": ["@ast-grep/napi@0.41.1", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.41.1", "@ast-grep/napi-darwin-x64": "0.41.1", "@ast-grep/napi-linux-arm64-gnu": "0.41.1", "@ast-grep/napi-linux-arm64-musl": "0.41.1", "@ast-grep/napi-linux-x64-gnu": "0.41.1", "@ast-grep/napi-linux-x64-musl": "0.41.1", "@ast-grep/napi-win32-arm64-msvc": "0.41.1", "@ast-grep/napi-win32-ia32-msvc": "0.41.1", "@ast-grep/napi-win32-x64-msvc": "0.41.1" } }, "sha512-OYQVWBbb43af2lTSCayMS7wsZ20nl+fw6LGVl/5zSuHTZRNfANknKLk3wMA4y7RIaAiIwrldAmI6GNZeIDRTkQ=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.41.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sZHwg/oD6YB2y4VD8ZMeMHBq/ONil+mx+bB61YAiGQB+8UCMSFxJupvtNICB/BnIFqcPCVz/jCaSdbASLrbXQQ=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.41.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-SL9hGB8sKvPnLUcigiDQrhohL7N4ujy1+t885kGcBkMXR73JT05OpPmvw0AWmg8l2iH1e5uNK/ZjnV/lSkynxQ=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mkNQpkm1jvnIdeRMnEWZ4Q0gNGApoNTMAoJRVmY11CkA4C/vIdNIjxj7UB61xV42Ng/A7Fw8mQUQuFos0lAKPQ=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.41.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-0G3cHyc+8A945aLie55bLZ+oaEBer0EFlyP/GlwRAx4nn5vGBct1hVTxSexWJ6AxnnRNPlN0mvswVwXiE7H7gA=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+aNiCik3iTMtUrMp1k2yIMjby1U64EydTH1qotlx+fh8YvwrwwxZWct7NlurY3MILgT/WONSxhHKmL5NsbB4dw=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.41.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rBrZSx5za3OliYcJcUrbLct+1+8oxh8ZEjYPiLCybe4FhspNKGM952g8a4sjgRuwbKS9BstYO9Fz+wthFnaFUQ=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.41.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-uNRHM3a1qFN0SECJDCEDVy1b0N75JNhJE2O/2BhDkDo0qM8kEewf9jRtG1fwpgZbMK2KoKvMHU/KQ73fWN44Zw=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.41.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-uNPQwGUBGIbCX+WhEIfYJf/VrS7o5+vJvT4MVEHI8aVJnpjcFsLrFI0hIv044OXxnleOo2HUvEmjOrub//at/Q=="],
|
||||||
|
|
||||||
|
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xFp68OCUEmWYcqoreZFaf2xwMhm/22Qf6bR2Qyn8WNVY9RF4m4+k5K+7Wn+n9xy0vHUPhtFd1So/SvuaqLHEoA=="],
|
||||||
|
|
||||||
|
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
|
||||||
|
|
||||||
|
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
|
||||||
|
|
||||||
|
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.7.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-AOic1jPHY3CpNraOuO87YZHO3uRzm9eLd0wyYYN89/76Ugk2TfdUYJ6El/Oe8fzOnHKiOF0IfBeWRo0IUjrHHg=="],
|
||||||
|
|
||||||
|
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "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.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "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.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.27", "", { "dependencies": { "@opencode-ai/sdk": "1.2.27", "zod": "4.1.8" } }, "sha512-h+8Bw9v9nghMg7T+SUCTzxlIhOrsTqXW7U0HVLGQST5DjbN7uyCUM51roZWZ8LRjGxzbzFhvPnY1bj8i+ioZyw=="],
|
||||||
|
|
||||||
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="],
|
||||||
|
|
||||||
|
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
|
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||||
|
|
||||||
|
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||||
|
|
||||||
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||||
|
|
||||||
|
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
|
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||||
|
|
||||||
|
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
|
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||||
|
|
||||||
|
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||||
|
|
||||||
|
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||||
|
|
||||||
|
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||||
|
|
||||||
|
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
||||||
|
|
||||||
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||||
|
|
||||||
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
|
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
|
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||||
|
|
||||||
|
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
|
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||||
|
|
||||||
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
|
|
||||||
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||||
|
|
||||||
|
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||||
|
|
||||||
|
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||||
|
|
||||||
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||||
|
|
||||||
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
|
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|
||||||
|
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
|
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./test-setup.ts"]
|
||||||
66
package.json
Normal file
66
package.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "tmux-utils",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Standalone OpenCode plugin for tmux subagent panes and interactive tmux tool",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"prepare": "bun run build",
|
||||||
|
"prepublishOnly": "bun run clean && bun run build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"opencode",
|
||||||
|
"plugin",
|
||||||
|
"tmux",
|
||||||
|
"subagent",
|
||||||
|
"pane"
|
||||||
|
],
|
||||||
|
"author": "YeonGyu-Kim",
|
||||||
|
"license": "SUL-1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ast-grep/cli": "^0.41.1",
|
||||||
|
"@ast-grep/napi": "^0.41.1",
|
||||||
|
"@clack/prompts": "^0.11.0",
|
||||||
|
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
|
"@opencode-ai/plugin": "^1.2.24",
|
||||||
|
"@opencode-ai/sdk": "^1.2.24",
|
||||||
|
"commander": "^14.0.2",
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
|
"zod": "^4.1.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/picomatch": "^3.0.2",
|
||||||
|
"bun-types": "1.3.10",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@opencode-ai/sdk": "^1.2.24"
|
||||||
|
},
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@ast-grep/cli",
|
||||||
|
"@ast-grep/napi",
|
||||||
|
"@code-yeongyu/comment-checker"
|
||||||
|
]
|
||||||
|
}
|
||||||
26
src/config/schema.ts
Normal file
26
src/config/schema.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const TmuxLayoutSchema = z.enum([
|
||||||
|
"main-horizontal",
|
||||||
|
"main-vertical",
|
||||||
|
"tiled",
|
||||||
|
"even-horizontal",
|
||||||
|
"even-vertical",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const TmuxConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
layout: TmuxLayoutSchema.default("main-vertical"),
|
||||||
|
main_pane_size: z.number().min(20).max(80).default(60),
|
||||||
|
main_pane_min_width: z.number().min(40).default(120),
|
||||||
|
agent_pane_min_width: z.number().min(20).default(40),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||||
|
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||||
|
|
||||||
|
export const PluginConfigSchema = z.object({
|
||||||
|
tmux: TmuxConfigSchema.partial().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type TmuxUtilsConfig = z.infer<typeof PluginConfigSchema>
|
||||||
82
src/features/tmux-subagent/action-executor-core.ts
Normal file
82
src/features/tmux-subagent/action-executor-core.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import type { applyLayout, closeTmuxPane, enforceMainPaneWidth, replaceTmuxPane, spawnTmuxPane } from "../../shared/tmux"
|
||||||
|
import type { PaneAction, WindowState } from "./types"
|
||||||
|
|
||||||
|
export interface ActionResult {
|
||||||
|
success: boolean
|
||||||
|
paneId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteContext {
|
||||||
|
config: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
windowState: WindowState
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionExecutorDeps {
|
||||||
|
spawnTmuxPane: typeof spawnTmuxPane
|
||||||
|
closeTmuxPane: typeof closeTmuxPane
|
||||||
|
replaceTmuxPane: typeof replaceTmuxPane
|
||||||
|
applyLayout: typeof applyLayout
|
||||||
|
enforceMainPaneWidth: typeof enforceMainPaneWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceMainPane(
|
||||||
|
windowState: WindowState,
|
||||||
|
config: TmuxConfig,
|
||||||
|
deps: ActionExecutorDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!windowState.mainPane) return
|
||||||
|
await deps.enforceMainPaneWidth(
|
||||||
|
windowState.mainPane.paneId,
|
||||||
|
windowState.windowWidth,
|
||||||
|
config.main_pane_size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeActionWithDeps(
|
||||||
|
action: PaneAction,
|
||||||
|
ctx: ExecuteContext,
|
||||||
|
deps: ActionExecutorDeps,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
if (action.type === "close") {
|
||||||
|
const success = await deps.closeTmuxPane(action.paneId)
|
||||||
|
if (success) {
|
||||||
|
await enforceMainPane(ctx.windowState, ctx.config, deps)
|
||||||
|
}
|
||||||
|
return { success }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "replace") {
|
||||||
|
const result = await deps.replaceTmuxPane(
|
||||||
|
action.paneId,
|
||||||
|
action.newSessionId,
|
||||||
|
action.description,
|
||||||
|
ctx.config,
|
||||||
|
ctx.serverUrl,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
paneId: result.paneId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deps.spawnTmuxPane(
|
||||||
|
action.sessionId,
|
||||||
|
action.description,
|
||||||
|
ctx.config,
|
||||||
|
ctx.serverUrl,
|
||||||
|
action.targetPaneId,
|
||||||
|
action.splitDirection,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await enforceMainPane(ctx.windowState, ctx.config, deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
paneId: result.paneId,
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/features/tmux-subagent/action-executor.ts
Normal file
137
src/features/tmux-subagent/action-executor.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import type { PaneAction, WindowState } from "./types"
|
||||||
|
import {
|
||||||
|
applyLayout,
|
||||||
|
spawnTmuxPane,
|
||||||
|
closeTmuxPane,
|
||||||
|
enforceMainPaneWidth,
|
||||||
|
replaceTmuxPane,
|
||||||
|
} from "../../shared/tmux"
|
||||||
|
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import type {
|
||||||
|
ActionResult,
|
||||||
|
ActionExecutorDeps,
|
||||||
|
} from "./action-executor-core"
|
||||||
|
|
||||||
|
export type { ActionExecutorDeps, ActionResult } from "./action-executor-core"
|
||||||
|
|
||||||
|
export interface ExecuteActionsResult {
|
||||||
|
success: boolean
|
||||||
|
spawnedPaneId?: string
|
||||||
|
results: Array<{ action: PaneAction; result: ActionResult }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteContext {
|
||||||
|
config: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
windowState: WindowState
|
||||||
|
sourcePaneId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceMainPane(
|
||||||
|
windowState: WindowState,
|
||||||
|
config: TmuxConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!windowState.mainPane) return
|
||||||
|
await enforceMainPaneWidth(windowState.mainPane.paneId, windowState.windowWidth, {
|
||||||
|
mainPaneSize: config.main_pane_size,
|
||||||
|
mainPaneMinWidth: config.main_pane_min_width,
|
||||||
|
agentPaneMinWidth: config.agent_pane_min_width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceLayoutAndMainPane(ctx: ExecuteContext): Promise<void> {
|
||||||
|
const sourcePaneId = ctx.sourcePaneId
|
||||||
|
if (!sourcePaneId) {
|
||||||
|
await enforceMainPane(ctx.windowState, ctx.config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestState = await queryWindowState(sourcePaneId)
|
||||||
|
if (!latestState?.mainPane) {
|
||||||
|
await enforceMainPane(ctx.windowState, ctx.config)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (tmux) {
|
||||||
|
await applyLayout(tmux, ctx.config.layout, ctx.config.main_pane_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
await enforceMainPane(latestState, ctx.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeAction(
|
||||||
|
action: PaneAction,
|
||||||
|
ctx: ExecuteContext
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
if (action.type === "close") {
|
||||||
|
const success = await closeTmuxPane(action.paneId)
|
||||||
|
if (success) {
|
||||||
|
await enforceLayoutAndMainPane(ctx)
|
||||||
|
}
|
||||||
|
return { success }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "replace") {
|
||||||
|
const result = await replaceTmuxPane(
|
||||||
|
action.paneId,
|
||||||
|
action.newSessionId,
|
||||||
|
action.description,
|
||||||
|
ctx.config,
|
||||||
|
ctx.serverUrl
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
await enforceLayoutAndMainPane(ctx)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
paneId: result.paneId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await spawnTmuxPane(
|
||||||
|
action.sessionId,
|
||||||
|
action.description,
|
||||||
|
ctx.config,
|
||||||
|
ctx.serverUrl,
|
||||||
|
action.targetPaneId,
|
||||||
|
action.splitDirection
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await enforceLayoutAndMainPane(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
paneId: result.paneId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeActions(
|
||||||
|
actions: PaneAction[],
|
||||||
|
ctx: ExecuteContext
|
||||||
|
): Promise<ExecuteActionsResult> {
|
||||||
|
const results: Array<{ action: PaneAction; result: ActionResult }> = []
|
||||||
|
let spawnedPaneId: string | undefined
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
log("[action-executor] executing", { type: action.type })
|
||||||
|
const result = await executeAction(action, ctx)
|
||||||
|
results.push({ action, result })
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
log("[action-executor] action failed", { type: action.type, error: result.error })
|
||||||
|
return { success: false, results }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((action.type === "spawn" || action.type === "replace") && result.paneId) {
|
||||||
|
spawnedPaneId = result.paneId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, spawnedPaneId, results }
|
||||||
|
}
|
||||||
42
src/features/tmux-subagent/cleanup.ts
Normal file
42
src/features/tmux-subagent/cleanup.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import type { TrackedSession } from "./types"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
|
import { executeAction } from "./action-executor"
|
||||||
|
|
||||||
|
export async function cleanupTmuxSessions(params: {
|
||||||
|
tmuxConfig: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
sourcePaneId: string | undefined
|
||||||
|
sessions: Map<string, TrackedSession>
|
||||||
|
stopPolling: () => void
|
||||||
|
}): Promise<void> {
|
||||||
|
params.stopPolling()
|
||||||
|
|
||||||
|
if (params.sessions.size === 0) {
|
||||||
|
log("[tmux-session-manager] cleanup complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] closing all panes", { count: params.sessions.size })
|
||||||
|
const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
const closePromises = Array.from(params.sessions.values()).map((tracked) =>
|
||||||
|
executeAction(
|
||||||
|
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
|
||||||
|
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
|
||||||
|
).catch((error) =>
|
||||||
|
log("[tmux-session-manager] cleanup error for pane", {
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
error: String(error),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(closePromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.sessions.clear()
|
||||||
|
log("[tmux-session-manager] cleanup complete")
|
||||||
|
}
|
||||||
22
src/features/tmux-subagent/decision-engine.ts
Normal file
22
src/features/tmux-subagent/decision-engine.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export type { SessionMapping } from "./oldest-agent-pane"
|
||||||
|
export type { GridCapacity, GridPlan, GridSlot } from "./grid-planning"
|
||||||
|
export type { SpawnTarget } from "./spawn-target-finder"
|
||||||
|
|
||||||
|
export {
|
||||||
|
calculateCapacity,
|
||||||
|
computeGridPlan,
|
||||||
|
mapPaneToSlot,
|
||||||
|
} from "./grid-planning"
|
||||||
|
|
||||||
|
export {
|
||||||
|
canSplitPane,
|
||||||
|
canSplitPaneAnyDirection,
|
||||||
|
findMinimalEvictions,
|
||||||
|
getBestSplitDirection,
|
||||||
|
getColumnCount,
|
||||||
|
getColumnWidth,
|
||||||
|
isSplittableAtCount,
|
||||||
|
} from "./pane-split-availability"
|
||||||
|
|
||||||
|
export { findSpawnTarget } from "./spawn-target-finder"
|
||||||
|
export { decideCloseAction, decideSpawnActions } from "./spawn-action-decider"
|
||||||
6
src/features/tmux-subagent/event-handlers.ts
Normal file
6
src/features/tmux-subagent/event-handlers.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { coerceSessionCreatedEvent } from "./session-created-event"
|
||||||
|
export type { SessionCreatedEvent } from "./session-created-event"
|
||||||
|
export { handleSessionCreated } from "./session-created-handler"
|
||||||
|
export type { SessionCreatedHandlerDeps } from "./session-created-handler"
|
||||||
|
export { handleSessionDeleted } from "./session-deleted-handler"
|
||||||
|
export type { SessionDeletedHandlerDeps } from "./session-deleted-handler"
|
||||||
137
src/features/tmux-subagent/grid-planning.ts
Normal file
137
src/features/tmux-subagent/grid-planning.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||||
|
import type { CapacityConfig, TmuxPaneInfo } from "./types"
|
||||||
|
import {
|
||||||
|
DIVIDER_SIZE,
|
||||||
|
MAX_GRID_SIZE,
|
||||||
|
computeAgentAreaWidth,
|
||||||
|
} from "./tmux-grid-constants"
|
||||||
|
|
||||||
|
export interface GridCapacity {
|
||||||
|
cols: number
|
||||||
|
rows: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSlot {
|
||||||
|
row: number
|
||||||
|
col: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridPlan {
|
||||||
|
cols: number
|
||||||
|
rows: number
|
||||||
|
slotWidth: number
|
||||||
|
slotHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CapacityOptions = CapacityConfig | number | undefined
|
||||||
|
|
||||||
|
function resolveMinPaneWidth(options?: CapacityOptions): number {
|
||||||
|
if (typeof options === "number") {
|
||||||
|
return Math.max(1, options)
|
||||||
|
}
|
||||||
|
if (options && typeof options.agentPaneWidth === "number") {
|
||||||
|
return Math.max(1, options.agentPaneWidth)
|
||||||
|
}
|
||||||
|
return MIN_PANE_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentAreaWidth(windowWidth: number, options?: CapacityOptions): number {
|
||||||
|
if (typeof options === "number") {
|
||||||
|
return computeAgentAreaWidth(windowWidth)
|
||||||
|
}
|
||||||
|
return computeAgentAreaWidth(windowWidth, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateCapacity(
|
||||||
|
windowWidth: number,
|
||||||
|
windowHeight: number,
|
||||||
|
options?: CapacityOptions,
|
||||||
|
mainPaneWidth?: number,
|
||||||
|
): GridCapacity {
|
||||||
|
const availableWidth =
|
||||||
|
typeof mainPaneWidth === "number"
|
||||||
|
? Math.max(0, windowWidth - mainPaneWidth - DIVIDER_SIZE)
|
||||||
|
: resolveAgentAreaWidth(windowWidth, options)
|
||||||
|
const minPaneWidth = resolveMinPaneWidth(options)
|
||||||
|
const cols = Math.min(
|
||||||
|
MAX_GRID_SIZE,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
(availableWidth + DIVIDER_SIZE) / (minPaneWidth + DIVIDER_SIZE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const rows = Math.min(
|
||||||
|
MAX_GRID_SIZE,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
(windowHeight + DIVIDER_SIZE) / (MIN_PANE_HEIGHT + DIVIDER_SIZE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return { cols, rows, total: cols * rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGridPlan(
|
||||||
|
windowWidth: number,
|
||||||
|
windowHeight: number,
|
||||||
|
paneCount: number,
|
||||||
|
options?: CapacityOptions,
|
||||||
|
mainPaneWidth?: number,
|
||||||
|
): GridPlan {
|
||||||
|
const capacity = calculateCapacity(windowWidth, windowHeight, options, mainPaneWidth)
|
||||||
|
const { cols: maxCols, rows: maxRows } = capacity
|
||||||
|
|
||||||
|
if (maxCols === 0 || maxRows === 0 || paneCount === 0) {
|
||||||
|
return { cols: 1, rows: 1, slotWidth: 0, slotHeight: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestCols = 1
|
||||||
|
let bestRows = 1
|
||||||
|
let bestArea = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
|
for (let rows = 1; rows <= maxRows; rows++) {
|
||||||
|
for (let cols = 1; cols <= maxCols; cols++) {
|
||||||
|
if (cols * rows < paneCount) continue
|
||||||
|
const area = cols * rows
|
||||||
|
if (area < bestArea || (area === bestArea && rows < bestRows)) {
|
||||||
|
bestCols = cols
|
||||||
|
bestRows = rows
|
||||||
|
bestArea = area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableWidth =
|
||||||
|
typeof mainPaneWidth === "number"
|
||||||
|
? Math.max(0, windowWidth - mainPaneWidth - DIVIDER_SIZE)
|
||||||
|
: resolveAgentAreaWidth(windowWidth, options)
|
||||||
|
const slotWidth = Math.floor(availableWidth / bestCols)
|
||||||
|
const slotHeight = Math.floor(windowHeight / bestRows)
|
||||||
|
|
||||||
|
return { cols: bestCols, rows: bestRows, slotWidth, slotHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPaneToSlot(
|
||||||
|
pane: TmuxPaneInfo,
|
||||||
|
plan: GridPlan,
|
||||||
|
mainPaneWidth: number,
|
||||||
|
): GridSlot {
|
||||||
|
const rightAreaX = mainPaneWidth
|
||||||
|
const relativeX = Math.max(0, pane.left - rightAreaX)
|
||||||
|
const relativeY = pane.top
|
||||||
|
|
||||||
|
const col =
|
||||||
|
plan.slotWidth > 0
|
||||||
|
? Math.min(plan.cols - 1, Math.floor(relativeX / plan.slotWidth))
|
||||||
|
: 0
|
||||||
|
const row =
|
||||||
|
plan.slotHeight > 0
|
||||||
|
? Math.min(plan.rows - 1, Math.floor(relativeY / plan.slotHeight))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return { row, col }
|
||||||
|
}
|
||||||
16
src/features/tmux-subagent/index.ts
Normal file
16
src/features/tmux-subagent/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export * from "./manager"
|
||||||
|
export * from "./event-handlers"
|
||||||
|
export * from "./polling"
|
||||||
|
export * from "./cleanup"
|
||||||
|
export * from "./session-created-event"
|
||||||
|
export * from "./session-created-handler"
|
||||||
|
export * from "./session-deleted-handler"
|
||||||
|
export * from "./polling-constants"
|
||||||
|
export * from "./session-status-parser"
|
||||||
|
export * from "./session-message-count"
|
||||||
|
export * from "./session-ready-waiter"
|
||||||
|
export * from "./types"
|
||||||
|
export * from "./pane-state-parser"
|
||||||
|
export * from "./pane-state-querier"
|
||||||
|
export * from "./decision-engine"
|
||||||
|
export * from "./action-executor"
|
||||||
697
src/features/tmux-subagent/manager.ts
Normal file
697
src/features/tmux-subagent/manager.ts
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
||||||
|
import { log, normalizeSDKResponse } from "../../shared"
|
||||||
|
import {
|
||||||
|
isInsideTmux as defaultIsInsideTmux,
|
||||||
|
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||||
|
POLL_INTERVAL_BACKGROUND_MS,
|
||||||
|
SESSION_READY_POLL_INTERVAL_MS,
|
||||||
|
SESSION_READY_TIMEOUT_MS,
|
||||||
|
} from "../../shared/tmux"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
|
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
|
||||||
|
import { executeActions, executeAction } from "./action-executor"
|
||||||
|
import { TmuxPollingManager } from "./polling-manager"
|
||||||
|
import { createTrackedSession, markTrackedSessionClosePending } from "./tracked-session-state"
|
||||||
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
|
interface SessionCreatedEvent {
|
||||||
|
type: string
|
||||||
|
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeferredSession {
|
||||||
|
sessionId: string
|
||||||
|
title: string
|
||||||
|
queuedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TmuxUtilDeps {
|
||||||
|
isInsideTmux: () => boolean
|
||||||
|
getCurrentPaneId: () => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTmuxDeps: TmuxUtilDeps = {
|
||||||
|
isInsideTmux: defaultIsInsideTmux,
|
||||||
|
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFERRED_SESSION_TTL_MS = 5 * 60 * 1000
|
||||||
|
const MAX_DEFERRED_QUEUE_SIZE = 20
|
||||||
|
const MAX_CLOSE_RETRY_COUNT = 3
|
||||||
|
|
||||||
|
export class TmuxSessionManager {
|
||||||
|
private client: OpencodeClient
|
||||||
|
private tmuxConfig: TmuxConfig
|
||||||
|
private serverUrl: string
|
||||||
|
private sourcePaneId: string | undefined
|
||||||
|
private sessions = new Map<string, TrackedSession>()
|
||||||
|
private pendingSessions = new Set<string>()
|
||||||
|
private spawnQueue: Promise<void> = Promise.resolve()
|
||||||
|
private deferredSessions = new Map<string, DeferredSession>()
|
||||||
|
private deferredQueue: string[] = []
|
||||||
|
private deferredAttachInterval?: ReturnType<typeof setInterval>
|
||||||
|
private deferredAttachTickScheduled = false
|
||||||
|
private nullStateCount = 0
|
||||||
|
private deps: TmuxUtilDeps
|
||||||
|
private pollingManager: TmuxPollingManager
|
||||||
|
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||||
|
this.client = ctx.client
|
||||||
|
this.tmuxConfig = tmuxConfig
|
||||||
|
this.deps = deps
|
||||||
|
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
||||||
|
try {
|
||||||
|
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||||
|
} catch {
|
||||||
|
this.serverUrl = `http://localhost:${defaultPort}`
|
||||||
|
}
|
||||||
|
this.sourcePaneId = deps.getCurrentPaneId()
|
||||||
|
this.pollingManager = new TmuxPollingManager(
|
||||||
|
this.client,
|
||||||
|
this.sessions,
|
||||||
|
this.closeSessionById.bind(this)
|
||||||
|
)
|
||||||
|
log("[tmux-session-manager] initialized", {
|
||||||
|
configEnabled: this.tmuxConfig.enabled,
|
||||||
|
tmuxConfig: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
private isEnabled(): boolean {
|
||||||
|
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCapacityConfig(): CapacityConfig {
|
||||||
|
return {
|
||||||
|
layout: this.tmuxConfig.layout,
|
||||||
|
mainPaneSize: this.tmuxConfig.main_pane_size,
|
||||||
|
mainPaneMinWidth: this.tmuxConfig.main_pane_min_width,
|
||||||
|
agentPaneWidth: this.tmuxConfig.agent_pane_min_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionMappings(): SessionMapping[] {
|
||||||
|
return Array.from(this.sessions.values()).map((s) => ({
|
||||||
|
sessionId: s.sessionId,
|
||||||
|
paneId: s.paneId,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTrackedSession(sessionId: string): void {
|
||||||
|
this.sessions.delete(sessionId)
|
||||||
|
|
||||||
|
if (this.sessions.size === 0) {
|
||||||
|
this.pollingManager.stopPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markSessionClosePending(sessionId: string): void {
|
||||||
|
const tracked = this.sessions.get(sessionId)
|
||||||
|
if (!tracked) return
|
||||||
|
|
||||||
|
this.sessions.set(sessionId, markTrackedSessionClosePending(tracked))
|
||||||
|
log("[tmux-session-manager] marked session close pending", {
|
||||||
|
sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
closeRetryCount: tracked.closeRetryCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queryWindowStateSafely(): Promise<WindowState | null> {
|
||||||
|
if (!this.sourcePaneId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await queryWindowState(this.sourcePaneId)
|
||||||
|
} catch (error) {
|
||||||
|
log("[tmux-session-manager] failed to query window state for close", {
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryCloseTrackedSession(tracked: TrackedSession): Promise<boolean> {
|
||||||
|
const state = await this.queryWindowStateSafely()
|
||||||
|
if (!state) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeAction(
|
||||||
|
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
|
||||||
|
{
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.success
|
||||||
|
} catch (error) {
|
||||||
|
log("[tmux-session-manager] close session pane failed", {
|
||||||
|
sessionId: tracked.sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryPendingCloses(): Promise<void> {
|
||||||
|
const pendingSessions = Array.from(this.sessions.values()).filter(
|
||||||
|
(tracked) => tracked.closePending,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const tracked of pendingSessions) {
|
||||||
|
if (!this.sessions.has(tracked.sessionId)) continue
|
||||||
|
|
||||||
|
if (tracked.closeRetryCount >= MAX_CLOSE_RETRY_COUNT) {
|
||||||
|
log("[tmux-session-manager] force removing close-pending session after max retries", {
|
||||||
|
sessionId: tracked.sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
closeRetryCount: tracked.closeRetryCount,
|
||||||
|
})
|
||||||
|
this.removeTrackedSession(tracked.sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = await this.tryCloseTrackedSession(tracked)
|
||||||
|
if (closed) {
|
||||||
|
log("[tmux-session-manager] retried close succeeded", {
|
||||||
|
sessionId: tracked.sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
closeRetryCount: tracked.closeRetryCount,
|
||||||
|
})
|
||||||
|
this.removeTrackedSession(tracked.sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTracked = this.sessions.get(tracked.sessionId)
|
||||||
|
if (!currentTracked || !currentTracked.closePending) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRetryCount = currentTracked.closeRetryCount + 1
|
||||||
|
if (nextRetryCount >= MAX_CLOSE_RETRY_COUNT) {
|
||||||
|
log("[tmux-session-manager] force removing close-pending session after failed retry", {
|
||||||
|
sessionId: currentTracked.sessionId,
|
||||||
|
paneId: currentTracked.paneId,
|
||||||
|
closeRetryCount: nextRetryCount,
|
||||||
|
})
|
||||||
|
this.removeTrackedSession(currentTracked.sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.set(currentTracked.sessionId, {
|
||||||
|
...currentTracked,
|
||||||
|
closePending: true,
|
||||||
|
closeRetryCount: nextRetryCount,
|
||||||
|
})
|
||||||
|
log("[tmux-session-manager] retried close failed", {
|
||||||
|
sessionId: currentTracked.sessionId,
|
||||||
|
paneId: currentTracked.paneId,
|
||||||
|
closeRetryCount: nextRetryCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueDeferredSession(sessionId: string, title: string): void {
|
||||||
|
if (this.deferredSessions.has(sessionId)) return
|
||||||
|
if (this.deferredQueue.length >= MAX_DEFERRED_QUEUE_SIZE) {
|
||||||
|
log("[tmux-session-manager] deferred queue full, dropping session", {
|
||||||
|
sessionId,
|
||||||
|
queueLength: this.deferredQueue.length,
|
||||||
|
maxQueueSize: MAX_DEFERRED_QUEUE_SIZE,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.deferredSessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
title,
|
||||||
|
queuedAt: new Date(),
|
||||||
|
})
|
||||||
|
this.deferredQueue.push(sessionId)
|
||||||
|
log("[tmux-session-manager] deferred session queued", {
|
||||||
|
sessionId,
|
||||||
|
queueLength: this.deferredQueue.length,
|
||||||
|
})
|
||||||
|
this.startDeferredAttachLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeDeferredSession(sessionId: string): void {
|
||||||
|
if (!this.deferredSessions.delete(sessionId)) return
|
||||||
|
this.deferredQueue = this.deferredQueue.filter((id) => id !== sessionId)
|
||||||
|
log("[tmux-session-manager] deferred session removed", {
|
||||||
|
sessionId,
|
||||||
|
queueLength: this.deferredQueue.length,
|
||||||
|
})
|
||||||
|
if (this.deferredQueue.length === 0) {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startDeferredAttachLoop(): void {
|
||||||
|
if (this.deferredAttachInterval) return
|
||||||
|
this.nullStateCount = 0
|
||||||
|
this.deferredAttachInterval = setInterval(() => {
|
||||||
|
if (this.deferredAttachTickScheduled) return
|
||||||
|
this.deferredAttachTickScheduled = true
|
||||||
|
void this.enqueueSpawn(async () => {
|
||||||
|
try {
|
||||||
|
await this.tryAttachDeferredSession()
|
||||||
|
} finally {
|
||||||
|
this.deferredAttachTickScheduled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, POLL_INTERVAL_BACKGROUND_MS)
|
||||||
|
log("[tmux-session-manager] deferred attach polling started", {
|
||||||
|
intervalMs: POLL_INTERVAL_BACKGROUND_MS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopDeferredAttachLoop(): void {
|
||||||
|
if (!this.deferredAttachInterval) return
|
||||||
|
clearInterval(this.deferredAttachInterval)
|
||||||
|
this.deferredAttachInterval = undefined
|
||||||
|
this.deferredAttachTickScheduled = false
|
||||||
|
this.nullStateCount = 0
|
||||||
|
log("[tmux-session-manager] deferred attach polling stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryAttachDeferredSession(): Promise<void> {
|
||||||
|
if (!this.sourcePaneId) return
|
||||||
|
const sessionId = this.deferredQueue[0]
|
||||||
|
if (!sessionId) {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const deferred = this.deferredSessions.get(sessionId)
|
||||||
|
if (!deferred) {
|
||||||
|
this.deferredQueue.shift()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - deferred.queuedAt.getTime() > DEFERRED_SESSION_TTL_MS) {
|
||||||
|
this.deferredQueue.shift()
|
||||||
|
this.deferredSessions.delete(sessionId)
|
||||||
|
log("[tmux-session-manager] deferred session expired", {
|
||||||
|
sessionId,
|
||||||
|
queuedAt: deferred.queuedAt.toISOString(),
|
||||||
|
ttlMs: DEFERRED_SESSION_TTL_MS,
|
||||||
|
queueLength: this.deferredQueue.length,
|
||||||
|
})
|
||||||
|
if (this.deferredQueue.length === 0) {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await queryWindowState(this.sourcePaneId)
|
||||||
|
if (!state) {
|
||||||
|
this.nullStateCount += 1
|
||||||
|
log("[tmux-session-manager] deferred attach window state is null", {
|
||||||
|
nullStateCount: this.nullStateCount,
|
||||||
|
})
|
||||||
|
if (this.nullStateCount >= 3) {
|
||||||
|
log("[tmux-session-manager] stopping deferred attach loop after consecutive null states", {
|
||||||
|
nullStateCount: this.nullStateCount,
|
||||||
|
})
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.nullStateCount = 0
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(
|
||||||
|
state,
|
||||||
|
sessionId,
|
||||||
|
deferred.title,
|
||||||
|
this.getCapacityConfig(),
|
||||||
|
this.getSessionMappings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!decision.canSpawn || decision.actions.length === 0) {
|
||||||
|
log("[tmux-session-manager] deferred session still waiting for capacity", {
|
||||||
|
sessionId,
|
||||||
|
reason: decision.reason,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeActions(decision.actions, {
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success || !result.spawnedPaneId) {
|
||||||
|
log("[tmux-session-manager] deferred session attach failed", {
|
||||||
|
sessionId,
|
||||||
|
results: result.results.map((r) => ({
|
||||||
|
type: r.action.type,
|
||||||
|
success: r.result.success,
|
||||||
|
error: r.result.error,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||||
|
if (!sessionReady) {
|
||||||
|
log("[tmux-session-manager] deferred session not ready after timeout", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.set(
|
||||||
|
sessionId,
|
||||||
|
createTrackedSession({
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
description: deferred.title,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
this.removeDeferredSession(sessionId)
|
||||||
|
this.pollingManager.startPolling()
|
||||||
|
log("[tmux-session-manager] deferred session attached", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
sessionReady,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForSessionReady(sessionId: string): Promise<boolean> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
const statusResult = await this.client.session.status({ path: undefined })
|
||||||
|
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||||
|
|
||||||
|
if (allStatuses[sessionId]) {
|
||||||
|
log("[tmux-session-manager] session ready", {
|
||||||
|
sessionId,
|
||||||
|
status: allStatuses[sessionId].type,
|
||||||
|
waitedMs: Date.now() - startTime,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("[tmux-session-manager] session status check error", { error: String(err) })
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS))
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] session ready timeout", {
|
||||||
|
sessionId,
|
||||||
|
timeoutMs: SESSION_READY_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
|
||||||
|
const enabled = this.isEnabled()
|
||||||
|
log("[tmux-session-manager] onSessionCreated called", {
|
||||||
|
enabled,
|
||||||
|
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||||
|
isInsideTmux: this.deps.isInsideTmux(),
|
||||||
|
eventType: event.type,
|
||||||
|
infoId: event.properties?.info?.id,
|
||||||
|
infoParentID: event.properties?.info?.parentID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!enabled) return
|
||||||
|
if (event.type !== "session.created") return
|
||||||
|
|
||||||
|
const info = event.properties?.info
|
||||||
|
if (!info?.id || !info?.parentID) return
|
||||||
|
|
||||||
|
const sessionId = info.id
|
||||||
|
const title = info.title ?? "Subagent"
|
||||||
|
|
||||||
|
if (!this.sourcePaneId) {
|
||||||
|
log("[tmux-session-manager] no source pane id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.retryPendingCloses()
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.sessions.has(sessionId) ||
|
||||||
|
this.pendingSessions.has(sessionId) ||
|
||||||
|
this.deferredSessions.has(sessionId)
|
||||||
|
) {
|
||||||
|
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sourcePaneId = this.sourcePaneId
|
||||||
|
|
||||||
|
this.pendingSessions.add(sessionId)
|
||||||
|
|
||||||
|
await this.enqueueSpawn(async () => {
|
||||||
|
try {
|
||||||
|
const state = await queryWindowState(sourcePaneId)
|
||||||
|
if (!state) {
|
||||||
|
log("[tmux-session-manager] failed to query window state, deferring session")
|
||||||
|
this.enqueueDeferredSession(sessionId, title)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] window state queried", {
|
||||||
|
windowWidth: state.windowWidth,
|
||||||
|
mainPane: state.mainPane?.paneId,
|
||||||
|
agentPaneCount: state.agentPanes.length,
|
||||||
|
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(
|
||||||
|
state,
|
||||||
|
sessionId,
|
||||||
|
title,
|
||||||
|
this.getCapacityConfig(),
|
||||||
|
this.getSessionMappings()
|
||||||
|
)
|
||||||
|
|
||||||
|
log("[tmux-session-manager] spawn decision", {
|
||||||
|
canSpawn: decision.canSpawn,
|
||||||
|
reason: decision.reason,
|
||||||
|
actionCount: decision.actions.length,
|
||||||
|
actions: decision.actions.map((a) => {
|
||||||
|
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
||||||
|
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
||||||
|
return { type: "spawn", sessionId: a.sessionId }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!decision.canSpawn) {
|
||||||
|
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||||
|
this.enqueueDeferredSession(sessionId, title)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeActions(
|
||||||
|
decision.actions,
|
||||||
|
{
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const { action, result: actionResult } of result.results) {
|
||||||
|
if (action.type === "close" && actionResult.success) {
|
||||||
|
this.sessions.delete(action.sessionId)
|
||||||
|
log("[tmux-session-manager] removed closed session from cache", {
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (action.type === "replace" && actionResult.success) {
|
||||||
|
this.sessions.delete(action.oldSessionId)
|
||||||
|
log("[tmux-session-manager] removed replaced session from cache", {
|
||||||
|
oldSessionId: action.oldSessionId,
|
||||||
|
newSessionId: action.newSessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success && result.spawnedPaneId) {
|
||||||
|
const sessionReady = await this.waitForSessionReady(sessionId)
|
||||||
|
|
||||||
|
if (!sessionReady) {
|
||||||
|
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.set(
|
||||||
|
sessionId,
|
||||||
|
createTrackedSession({
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
description: title,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
log("[tmux-session-manager] pane spawned and tracked", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
sessionReady,
|
||||||
|
})
|
||||||
|
this.pollingManager.startPolling()
|
||||||
|
} else {
|
||||||
|
log("[tmux-session-manager] spawn failed", {
|
||||||
|
success: result.success,
|
||||||
|
results: result.results.map((r) => ({
|
||||||
|
type: r.action.type,
|
||||||
|
success: r.result.success,
|
||||||
|
error: r.result.error,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
log("[tmux-session-manager] re-queueing deferred session after spawn failure", {
|
||||||
|
sessionId,
|
||||||
|
})
|
||||||
|
this.enqueueDeferredSession(sessionId, title)
|
||||||
|
|
||||||
|
if (result.spawnedPaneId) {
|
||||||
|
await executeAction(
|
||||||
|
{ type: "close", paneId: result.spawnedPaneId, sessionId },
|
||||||
|
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.pendingSessions.delete(sessionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueSpawn(run: () => Promise<void>): Promise<void> {
|
||||||
|
this.spawnQueue = this.spawnQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(run)
|
||||||
|
.catch((err) => {
|
||||||
|
log("[tmux-session-manager] spawn queue task failed", {
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await this.spawnQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
if (!this.sourcePaneId) return
|
||||||
|
|
||||||
|
this.removeDeferredSession(event.sessionID)
|
||||||
|
|
||||||
|
const tracked = this.sessions.get(event.sessionID)
|
||||||
|
if (!tracked) return
|
||||||
|
|
||||||
|
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||||
|
|
||||||
|
const state = await this.queryWindowStateSafely()
|
||||||
|
if (!state) {
|
||||||
|
this.markSessionClosePending(event.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
|
||||||
|
if (!closeAction) {
|
||||||
|
this.removeTrackedSession(event.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeAction(closeAction, {
|
||||||
|
config: this.tmuxConfig,
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
sourcePaneId: this.sourcePaneId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
this.markSessionClosePending(event.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("[tmux-session-manager] failed to close pane for deleted session", {
|
||||||
|
sessionId: event.sessionID,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
this.markSessionClosePending(event.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeTrackedSession(event.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeSessionById(sessionId: string): Promise<void> {
|
||||||
|
const tracked = this.sessions.get(sessionId)
|
||||||
|
if (!tracked) return
|
||||||
|
|
||||||
|
if (tracked.closePending && tracked.closeRetryCount >= MAX_CLOSE_RETRY_COUNT) {
|
||||||
|
log("[tmux-session-manager] force removing close-pending session after max retries", {
|
||||||
|
sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
closeRetryCount: tracked.closeRetryCount,
|
||||||
|
})
|
||||||
|
this.removeTrackedSession(sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] closing session pane", {
|
||||||
|
sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const closed = await this.tryCloseTrackedSession(tracked)
|
||||||
|
if (!closed) {
|
||||||
|
this.markSessionClosePending(sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeTrackedSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||||
|
return async (input) => {
|
||||||
|
await this.onSessionCreated(input.event as SessionCreatedEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
this.stopDeferredAttachLoop()
|
||||||
|
this.deferredQueue = []
|
||||||
|
this.deferredSessions.clear()
|
||||||
|
this.pollingManager.stopPolling()
|
||||||
|
|
||||||
|
if (this.sessions.size > 0) {
|
||||||
|
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||||
|
|
||||||
|
const sessionIds = Array.from(this.sessions.keys())
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
try {
|
||||||
|
await this.closeSessionById(sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
log("[tmux-session-manager] cleanup error for pane", {
|
||||||
|
sessionId,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.retryPendingCloses()
|
||||||
|
|
||||||
|
log("[tmux-session-manager] cleanup complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/features/tmux-subagent/oldest-agent-pane.ts
Normal file
37
src/features/tmux-subagent/oldest-agent-pane.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { TmuxPaneInfo } from "./types"
|
||||||
|
|
||||||
|
export interface SessionMapping {
|
||||||
|
sessionId: string
|
||||||
|
paneId: string
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findOldestAgentPane(
|
||||||
|
agentPanes: TmuxPaneInfo[],
|
||||||
|
sessionMappings: SessionMapping[],
|
||||||
|
): TmuxPaneInfo | null {
|
||||||
|
if (agentPanes.length === 0) return null
|
||||||
|
|
||||||
|
const paneIdToAge = new Map<string, Date>()
|
||||||
|
for (const mapping of sessionMappings) {
|
||||||
|
paneIdToAge.set(mapping.paneId, mapping.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panesWithAge = agentPanes
|
||||||
|
.map((pane) => ({ pane, age: paneIdToAge.get(pane.paneId) }))
|
||||||
|
.filter(
|
||||||
|
(item): item is { pane: TmuxPaneInfo; age: Date } => item.age !== undefined,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.age.getTime() - b.age.getTime())
|
||||||
|
|
||||||
|
if (panesWithAge.length > 0) {
|
||||||
|
return panesWithAge[0].pane
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentPanes.reduce((oldest, pane) => {
|
||||||
|
if (pane.top < oldest.top || (pane.top === oldest.top && pane.left < oldest.left)) {
|
||||||
|
return pane
|
||||||
|
}
|
||||||
|
return oldest
|
||||||
|
})
|
||||||
|
}
|
||||||
77
src/features/tmux-subagent/pane-split-availability.ts
Normal file
77
src/features/tmux-subagent/pane-split-availability.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { SplitDirection, TmuxPaneInfo } from "./types"
|
||||||
|
import {
|
||||||
|
DIVIDER_SIZE,
|
||||||
|
MAX_COLS,
|
||||||
|
MAX_ROWS,
|
||||||
|
MIN_SPLIT_HEIGHT,
|
||||||
|
} from "./tmux-grid-constants"
|
||||||
|
import { MIN_PANE_WIDTH } from "./types"
|
||||||
|
|
||||||
|
function getMinSplitWidth(minPaneWidth?: number): number {
|
||||||
|
const width = Math.max(1, minPaneWidth ?? MIN_PANE_WIDTH)
|
||||||
|
return 2 * width + DIVIDER_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnCount(paneCount: number): number {
|
||||||
|
if (paneCount <= 0) return 1
|
||||||
|
return Math.min(MAX_COLS, Math.max(1, Math.ceil(paneCount / MAX_ROWS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnWidth(agentAreaWidth: number, paneCount: number): number {
|
||||||
|
const cols = getColumnCount(paneCount)
|
||||||
|
const dividersWidth = (cols - 1) * DIVIDER_SIZE
|
||||||
|
return Math.floor((agentAreaWidth - dividersWidth) / cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSplittableAtCount(
|
||||||
|
agentAreaWidth: number,
|
||||||
|
paneCount: number,
|
||||||
|
minPaneWidth?: number,
|
||||||
|
): boolean {
|
||||||
|
const columnWidth = getColumnWidth(agentAreaWidth, paneCount)
|
||||||
|
return columnWidth >= getMinSplitWidth(minPaneWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMinimalEvictions(
|
||||||
|
agentAreaWidth: number,
|
||||||
|
currentCount: number,
|
||||||
|
minPaneWidth?: number,
|
||||||
|
): number | null {
|
||||||
|
for (let k = 1; k <= currentCount; k++) {
|
||||||
|
if (isSplittableAtCount(agentAreaWidth, currentCount - k, minPaneWidth)) {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSplitPane(
|
||||||
|
pane: TmuxPaneInfo,
|
||||||
|
direction: SplitDirection,
|
||||||
|
minPaneWidth?: number,
|
||||||
|
): boolean {
|
||||||
|
if (direction === "-h") {
|
||||||
|
return pane.width >= getMinSplitWidth(minPaneWidth)
|
||||||
|
}
|
||||||
|
return pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSplitPaneAnyDirection(
|
||||||
|
pane: TmuxPaneInfo,
|
||||||
|
minPaneWidth?: number,
|
||||||
|
): boolean {
|
||||||
|
return pane.width >= getMinSplitWidth(minPaneWidth) || pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBestSplitDirection(
|
||||||
|
pane: TmuxPaneInfo,
|
||||||
|
minPaneWidth?: number,
|
||||||
|
): SplitDirection | null {
|
||||||
|
const canH = pane.width >= getMinSplitWidth(minPaneWidth)
|
||||||
|
const canV = pane.height >= MIN_SPLIT_HEIGHT
|
||||||
|
|
||||||
|
if (!canH && !canV) return null
|
||||||
|
if (canH && !canV) return "-h"
|
||||||
|
if (!canH && canV) return "-v"
|
||||||
|
return pane.width >= pane.height ? "-h" : "-v"
|
||||||
|
}
|
||||||
135
src/features/tmux-subagent/pane-state-parser.ts
Normal file
135
src/features/tmux-subagent/pane-state-parser.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { TmuxPaneInfo } from "./types"
|
||||||
|
|
||||||
|
const MANDATORY_PANE_FIELD_COUNT = 8
|
||||||
|
|
||||||
|
type ParsedPaneState = {
|
||||||
|
windowWidth: number
|
||||||
|
windowHeight: number
|
||||||
|
panes: TmuxPaneInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedPaneLine = {
|
||||||
|
pane: TmuxPaneInfo
|
||||||
|
windowWidth: number
|
||||||
|
windowHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MandatoryPaneFields = [
|
||||||
|
paneId: string,
|
||||||
|
widthString: string,
|
||||||
|
heightString: string,
|
||||||
|
leftString: string,
|
||||||
|
topString: string,
|
||||||
|
activeString: string,
|
||||||
|
windowWidthString: string,
|
||||||
|
windowHeightString: string,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
|
||||||
|
const lines = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/\r$/, ""))
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
if (lines.length === 0) return null
|
||||||
|
|
||||||
|
const parsedPaneLines = lines
|
||||||
|
.map(parsePaneLine)
|
||||||
|
.filter((parsedPaneLine): parsedPaneLine is ParsedPaneLine => parsedPaneLine !== null)
|
||||||
|
|
||||||
|
if (parsedPaneLines.length === 0) return null
|
||||||
|
|
||||||
|
const latestPaneLine = parsedPaneLines[parsedPaneLines.length - 1]
|
||||||
|
if (!latestPaneLine) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
windowWidth: latestPaneLine.windowWidth,
|
||||||
|
windowHeight: latestPaneLine.windowHeight,
|
||||||
|
panes: parsedPaneLines.map(({ pane }) => pane),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaneLine(line: string): ParsedPaneLine | null {
|
||||||
|
const fields = line.split("\t")
|
||||||
|
const mandatoryFields = getMandatoryPaneFields(fields)
|
||||||
|
if (!mandatoryFields) return null
|
||||||
|
|
||||||
|
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields
|
||||||
|
|
||||||
|
const width = parseInteger(widthString)
|
||||||
|
const height = parseInteger(heightString)
|
||||||
|
const left = parseInteger(leftString)
|
||||||
|
const top = parseInteger(topString)
|
||||||
|
const isActive = parseActiveValue(activeString)
|
||||||
|
const windowWidth = parseInteger(windowWidthString)
|
||||||
|
const windowHeight = parseInteger(windowHeightString)
|
||||||
|
|
||||||
|
if (
|
||||||
|
width === null ||
|
||||||
|
height === null ||
|
||||||
|
left === null ||
|
||||||
|
top === null ||
|
||||||
|
isActive === null ||
|
||||||
|
windowWidth === null ||
|
||||||
|
windowHeight === null
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pane: {
|
||||||
|
paneId,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
title: fields.slice(MANDATORY_PANE_FIELD_COUNT).join("\t"),
|
||||||
|
isActive,
|
||||||
|
},
|
||||||
|
windowWidth,
|
||||||
|
windowHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
|
||||||
|
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
|
||||||
|
|
||||||
|
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields
|
||||||
|
|
||||||
|
if (
|
||||||
|
paneId === undefined ||
|
||||||
|
widthString === undefined ||
|
||||||
|
heightString === undefined ||
|
||||||
|
leftString === undefined ||
|
||||||
|
topString === undefined ||
|
||||||
|
activeString === undefined ||
|
||||||
|
windowWidthString === undefined ||
|
||||||
|
windowHeightString === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
paneId,
|
||||||
|
widthString,
|
||||||
|
heightString,
|
||||||
|
leftString,
|
||||||
|
topString,
|
||||||
|
activeString,
|
||||||
|
windowWidthString,
|
||||||
|
windowHeightString,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInteger(value: string): number | null {
|
||||||
|
if (!/^\d+$/.test(value)) return null
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(value, 10)
|
||||||
|
return Number.isNaN(parsedValue) ? null : parsedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActiveValue(value: string): boolean | null {
|
||||||
|
if (value === "1") return true
|
||||||
|
if (value === "0") return false
|
||||||
|
return null
|
||||||
|
}
|
||||||
76
src/features/tmux-subagent/pane-state-querier.ts
Normal file
76
src/features/tmux-subagent/pane-state-querier.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import type { WindowState, TmuxPaneInfo } from "./types"
|
||||||
|
import { parsePaneStateOutput } from "./pane-state-parser"
|
||||||
|
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
|
||||||
|
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) return null
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
[
|
||||||
|
tmux,
|
||||||
|
"list-panes",
|
||||||
|
"-t",
|
||||||
|
sourcePaneId,
|
||||||
|
"-F",
|
||||||
|
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}",
|
||||||
|
],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" }
|
||||||
|
)
|
||||||
|
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const stdout = await new Response(proc.stdout).text()
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
log("[pane-state-querier] list-panes failed", { exitCode })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPaneState = parsePaneStateOutput(stdout)
|
||||||
|
if (!parsedPaneState) {
|
||||||
|
log("[pane-state-querier] failed to parse pane state output", {
|
||||||
|
sourcePaneId,
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { panes } = parsedPaneState
|
||||||
|
const windowWidth = parsedPaneState.windowWidth
|
||||||
|
const windowHeight = parsedPaneState.windowHeight
|
||||||
|
|
||||||
|
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
||||||
|
|
||||||
|
const mainPane = panes.reduce<TmuxPaneInfo | null>((selected, pane) => {
|
||||||
|
if (!selected) return pane
|
||||||
|
if (pane.left !== selected.left) {
|
||||||
|
return pane.left < selected.left ? pane : selected
|
||||||
|
}
|
||||||
|
if (pane.width !== selected.width) {
|
||||||
|
return pane.width > selected.width ? pane : selected
|
||||||
|
}
|
||||||
|
if (pane.top !== selected.top) {
|
||||||
|
return pane.top < selected.top ? pane : selected
|
||||||
|
}
|
||||||
|
return pane.paneId === sourcePaneId ? pane : selected
|
||||||
|
}, null)
|
||||||
|
if (!mainPane) {
|
||||||
|
log("[pane-state-querier] CRITICAL: failed to determine main pane", {
|
||||||
|
sourcePaneId,
|
||||||
|
availablePanes: panes.map((p) => p.paneId),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentPanes = panes.filter((p) => p.paneId !== mainPane.paneId)
|
||||||
|
|
||||||
|
log("[pane-state-querier] window state", {
|
||||||
|
windowWidth,
|
||||||
|
windowHeight,
|
||||||
|
mainPane: mainPane.paneId,
|
||||||
|
agentPaneCount: agentPanes.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { windowWidth, windowHeight, mainPane, agentPanes }
|
||||||
|
}
|
||||||
4
src/features/tmux-subagent/polling-constants.ts
Normal file
4
src/features/tmux-subagent/polling-constants.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||||
|
export const STABLE_POLLS_REQUIRED = 3
|
||||||
147
src/features/tmux-subagent/polling-manager.ts
Normal file
147
src/features/tmux-subagent/polling-manager.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux"
|
||||||
|
import type { TrackedSession } from "./types"
|
||||||
|
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
|
||||||
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
|
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||||
|
const STABLE_POLLS_REQUIRED = 3
|
||||||
|
|
||||||
|
export class TmuxPollingManager {
|
||||||
|
private pollInterval?: ReturnType<typeof setInterval>
|
||||||
|
private pollingInFlight = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private client: OpencodeClient,
|
||||||
|
private sessions: Map<string, TrackedSession>,
|
||||||
|
private closeSessionById: (sessionId: string) => Promise<void>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
startPolling(): void {
|
||||||
|
if (this.pollInterval) return
|
||||||
|
|
||||||
|
this.pollInterval = setInterval(
|
||||||
|
() => this.pollSessions(),
|
||||||
|
POLL_INTERVAL_BACKGROUND_MS,
|
||||||
|
)
|
||||||
|
log("[tmux-session-manager] polling started")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling(): void {
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval)
|
||||||
|
this.pollInterval = undefined
|
||||||
|
log("[tmux-session-manager] polling stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollSessions(): Promise<void> {
|
||||||
|
if (this.pollingInFlight) return
|
||||||
|
this.pollingInFlight = true
|
||||||
|
try {
|
||||||
|
if (this.sessions.size === 0) {
|
||||||
|
this.stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusResult = await this.client.session.status({ path: undefined })
|
||||||
|
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||||
|
|
||||||
|
log("[tmux-session-manager] pollSessions", {
|
||||||
|
trackedSessions: Array.from(this.sessions.keys()),
|
||||||
|
allStatusKeys: Object.keys(allStatuses),
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const sessionsToClose: string[] = []
|
||||||
|
|
||||||
|
for (const [sessionId, tracked] of this.sessions.entries()) {
|
||||||
|
const status = allStatuses[sessionId]
|
||||||
|
const isIdle = status?.type === "idle"
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
tracked.lastSeenAt = new Date(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
|
||||||
|
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
|
||||||
|
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
|
||||||
|
const elapsedMs = now - tracked.createdAt.getTime()
|
||||||
|
|
||||||
|
let shouldCloseViaStability = false
|
||||||
|
|
||||||
|
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||||
|
try {
|
||||||
|
const messagesResult = await this.client.session.messages({
|
||||||
|
path: { id: sessionId }
|
||||||
|
})
|
||||||
|
const currentMsgCount = Array.isArray(messagesResult.data)
|
||||||
|
? messagesResult.data.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (tracked.lastMessageCount === currentMsgCount) {
|
||||||
|
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
|
||||||
|
|
||||||
|
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
|
||||||
|
const recheckResult = await this.client.session.status({ path: undefined })
|
||||||
|
const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record<string, { type: string }>)
|
||||||
|
const recheckStatus = recheckStatuses[sessionId]
|
||||||
|
|
||||||
|
if (recheckStatus?.type === "idle") {
|
||||||
|
shouldCloseViaStability = true
|
||||||
|
} else {
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", {
|
||||||
|
sessionId,
|
||||||
|
recheckStatus: recheckStatus?.type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked.lastMessageCount = currentMsgCount
|
||||||
|
} catch (msgErr) {
|
||||||
|
log("[tmux-session-manager] failed to fetch messages for stability check", {
|
||||||
|
sessionId,
|
||||||
|
error: String(msgErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (!isIdle) {
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] session check", {
|
||||||
|
sessionId,
|
||||||
|
statusType: status?.type,
|
||||||
|
isIdle,
|
||||||
|
elapsedMs,
|
||||||
|
stableIdlePolls: tracked.stableIdlePolls,
|
||||||
|
lastMessageCount: tracked.lastMessageCount,
|
||||||
|
missingSince,
|
||||||
|
missingTooLong,
|
||||||
|
isTimedOut,
|
||||||
|
shouldCloseViaStability,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (shouldCloseViaStability || missingTooLong || isTimedOut) {
|
||||||
|
sessionsToClose.push(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of sessionsToClose) {
|
||||||
|
log("[tmux-session-manager] closing session due to poll", { sessionId })
|
||||||
|
await this.closeSessionById(sessionId)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("[tmux-session-manager] poll error", { error: String(err) })
|
||||||
|
} finally {
|
||||||
|
this.pollingInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/features/tmux-subagent/polling.ts
Normal file
183
src/features/tmux-subagent/polling.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import {
|
||||||
|
POLL_INTERVAL_BACKGROUND_MS,
|
||||||
|
SESSION_MISSING_GRACE_MS,
|
||||||
|
} from "../../shared/tmux"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import type { TrackedSession } from "./types"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
|
import { executeAction } from "./action-executor"
|
||||||
|
import {
|
||||||
|
MIN_STABILITY_TIME_MS,
|
||||||
|
SESSION_TIMEOUT_MS,
|
||||||
|
STABLE_POLLS_REQUIRED,
|
||||||
|
} from "./polling-constants"
|
||||||
|
import { parseSessionStatusMap } from "./session-status-parser"
|
||||||
|
import { getMessageCount } from "./session-message-count"
|
||||||
|
import { waitForSessionReady as waitForSessionReadyFromClient } from "./session-ready-waiter"
|
||||||
|
|
||||||
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
|
export interface SessionPollingController {
|
||||||
|
startPolling: () => void
|
||||||
|
stopPolling: () => void
|
||||||
|
closeSessionById: (sessionId: string) => Promise<void>
|
||||||
|
waitForSessionReady: (sessionId: string) => Promise<boolean>
|
||||||
|
pollSessions: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionPollingController(params: {
|
||||||
|
client: OpencodeClient
|
||||||
|
tmuxConfig: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
sourcePaneId: string | undefined
|
||||||
|
sessions: Map<string, TrackedSession>
|
||||||
|
}): SessionPollingController {
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
async function closeSessionById(sessionId: string): Promise<void> {
|
||||||
|
const tracked = params.sessions.get(sessionId)
|
||||||
|
if (!tracked) return
|
||||||
|
|
||||||
|
log("[tmux-session-manager] closing session pane", {
|
||||||
|
sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = params.sourcePaneId ? await queryWindowState(params.sourcePaneId) : null
|
||||||
|
if (state) {
|
||||||
|
await executeAction(
|
||||||
|
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||||
|
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.sessions.delete(sessionId)
|
||||||
|
|
||||||
|
if (params.sessions.size === 0) {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSessions(): Promise<void> {
|
||||||
|
if (params.sessions.size === 0) {
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusResult = await params.client.session.status({ path: undefined })
|
||||||
|
const allStatuses = parseSessionStatusMap(statusResult.data)
|
||||||
|
|
||||||
|
log("[tmux-session-manager] pollSessions", {
|
||||||
|
trackedSessions: Array.from(params.sessions.keys()),
|
||||||
|
allStatusKeys: Object.keys(allStatuses),
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const sessionsToClose: string[] = []
|
||||||
|
|
||||||
|
for (const [sessionId, tracked] of params.sessions.entries()) {
|
||||||
|
const status = allStatuses[sessionId]
|
||||||
|
const isIdle = status?.type === "idle"
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
tracked.lastSeenAt = new Date(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
|
||||||
|
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
|
||||||
|
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
|
||||||
|
const elapsedMs = now - tracked.createdAt.getTime()
|
||||||
|
|
||||||
|
let shouldCloseViaStability = false
|
||||||
|
|
||||||
|
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||||
|
try {
|
||||||
|
const messagesResult = await params.client.session.messages({
|
||||||
|
path: { id: sessionId },
|
||||||
|
})
|
||||||
|
const currentMessageCount = getMessageCount(messagesResult.data)
|
||||||
|
|
||||||
|
if (tracked.lastMessageCount === currentMessageCount) {
|
||||||
|
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
|
||||||
|
|
||||||
|
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
|
||||||
|
const recheckResult = await params.client.session.status({ path: undefined })
|
||||||
|
const recheckStatuses = parseSessionStatusMap(recheckResult.data)
|
||||||
|
const recheckStatus = recheckStatuses[sessionId]
|
||||||
|
|
||||||
|
if (recheckStatus?.type === "idle") {
|
||||||
|
shouldCloseViaStability = true
|
||||||
|
} else {
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
log(
|
||||||
|
"[tmux-session-manager] stability reached but session not idle on recheck, resetting",
|
||||||
|
{ sessionId, recheckStatus: recheckStatus?.type },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked.lastMessageCount = currentMessageCount
|
||||||
|
} catch (messageError) {
|
||||||
|
log("[tmux-session-manager] failed to fetch messages for stability check", {
|
||||||
|
sessionId,
|
||||||
|
error: String(messageError),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (!isIdle) {
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] session check", {
|
||||||
|
sessionId,
|
||||||
|
statusType: status?.type,
|
||||||
|
isIdle,
|
||||||
|
elapsedMs,
|
||||||
|
stableIdlePolls: tracked.stableIdlePolls,
|
||||||
|
lastMessageCount: tracked.lastMessageCount,
|
||||||
|
missingSince,
|
||||||
|
missingTooLong,
|
||||||
|
isTimedOut,
|
||||||
|
shouldCloseViaStability,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (shouldCloseViaStability || missingTooLong || isTimedOut) {
|
||||||
|
sessionsToClose.push(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of sessionsToClose) {
|
||||||
|
log("[tmux-session-manager] closing session due to poll", { sessionId })
|
||||||
|
await closeSessionById(sessionId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("[tmux-session-manager] poll error", { error: String(error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(): void {
|
||||||
|
if (pollInterval) return
|
||||||
|
pollInterval = setInterval(() => {
|
||||||
|
void pollSessions()
|
||||||
|
}, POLL_INTERVAL_BACKGROUND_MS)
|
||||||
|
log("[tmux-session-manager] polling started")
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (!pollInterval) return
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = undefined
|
||||||
|
log("[tmux-session-manager] polling stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSessionReady(sessionId: string): Promise<boolean> {
|
||||||
|
return waitForSessionReadyFromClient({ client: params.client, sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startPolling, stopPolling, closeSessionById, waitForSessionReady, pollSessions }
|
||||||
|
}
|
||||||
44
src/features/tmux-subagent/session-created-event.ts
Normal file
44
src/features/tmux-subagent/session-created-event.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
type UnknownRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedRecord(value: unknown, key: string): UnknownRecord | undefined {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
const nested = value[key]
|
||||||
|
return isRecord(nested) ? nested : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedString(value: unknown, key: string): string | undefined {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
const nested = value[key]
|
||||||
|
return typeof nested === "string" ? nested : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCreatedEvent {
|
||||||
|
type: string
|
||||||
|
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceSessionCreatedEvent(input: {
|
||||||
|
type: string
|
||||||
|
properties?: unknown
|
||||||
|
}): SessionCreatedEvent {
|
||||||
|
const properties = isRecord(input.properties) ? input.properties : undefined
|
||||||
|
const info = getNestedRecord(properties, "info")
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: input.type,
|
||||||
|
properties:
|
||||||
|
info || properties
|
||||||
|
? {
|
||||||
|
info: {
|
||||||
|
id: getNestedString(info, "id"),
|
||||||
|
parentID: getNestedString(info, "parentID"),
|
||||||
|
title: getNestedString(info, "title"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/features/tmux-subagent/session-created-handler.ts
Normal file
175
src/features/tmux-subagent/session-created-handler.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import type { CapacityConfig, TrackedSession } from "./types"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
|
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
|
||||||
|
import { executeActions } from "./action-executor"
|
||||||
|
import type { SessionCreatedEvent } from "./session-created-event"
|
||||||
|
import { createTrackedSession } from "./tracked-session-state"
|
||||||
|
|
||||||
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
|
export interface SessionCreatedHandlerDeps {
|
||||||
|
client: OpencodeClient
|
||||||
|
tmuxConfig: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
sourcePaneId: string | undefined
|
||||||
|
sessions: Map<string, TrackedSession>
|
||||||
|
pendingSessions: Set<string>
|
||||||
|
isInsideTmux: () => boolean
|
||||||
|
isEnabled: () => boolean
|
||||||
|
getCapacityConfig: () => CapacityConfig
|
||||||
|
getSessionMappings: () => SessionMapping[]
|
||||||
|
waitForSessionReady: (sessionId: string) => Promise<boolean>
|
||||||
|
startPolling: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSessionCreated(
|
||||||
|
deps: SessionCreatedHandlerDeps,
|
||||||
|
event: SessionCreatedEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
const enabled = deps.isEnabled()
|
||||||
|
log("[tmux-session-manager] onSessionCreated called", {
|
||||||
|
enabled,
|
||||||
|
tmuxConfigEnabled: deps.tmuxConfig.enabled,
|
||||||
|
isInsideTmux: deps.isInsideTmux(),
|
||||||
|
eventType: event.type,
|
||||||
|
infoId: event.properties?.info?.id,
|
||||||
|
infoParentID: event.properties?.info?.parentID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!enabled) return
|
||||||
|
if (event.type !== "session.created") return
|
||||||
|
|
||||||
|
const info = event.properties?.info
|
||||||
|
if (!info?.id || !info?.parentID) return
|
||||||
|
|
||||||
|
const sessionId = info.id
|
||||||
|
const title = info.title ?? "Subagent"
|
||||||
|
|
||||||
|
if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) {
|
||||||
|
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.sourcePaneId) {
|
||||||
|
log("[tmux-session-manager] no source pane id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.pendingSessions.add(sessionId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await queryWindowState(deps.sourcePaneId)
|
||||||
|
if (!state) {
|
||||||
|
log("[tmux-session-manager] failed to query window state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] window state queried", {
|
||||||
|
windowWidth: state.windowWidth,
|
||||||
|
mainPane: state.mainPane?.paneId,
|
||||||
|
agentPaneCount: state.agentPanes.length,
|
||||||
|
agentPanes: state.agentPanes.map((p) => p.paneId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const decision = decideSpawnActions(
|
||||||
|
state,
|
||||||
|
sessionId,
|
||||||
|
title,
|
||||||
|
deps.getCapacityConfig(),
|
||||||
|
deps.getSessionMappings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
log("[tmux-session-manager] spawn decision", {
|
||||||
|
canSpawn: decision.canSpawn,
|
||||||
|
reason: decision.reason,
|
||||||
|
actionCount: decision.actions.length,
|
||||||
|
actions: decision.actions.map((a) => {
|
||||||
|
if (a.type === "close") return { type: "close", paneId: a.paneId }
|
||||||
|
if (a.type === "replace") {
|
||||||
|
return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
|
||||||
|
}
|
||||||
|
return { type: "spawn", sessionId: a.sessionId }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!decision.canSpawn) {
|
||||||
|
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeActions(decision.actions, {
|
||||||
|
config: deps.tmuxConfig,
|
||||||
|
serverUrl: deps.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const { action, result: actionResult } of result.results) {
|
||||||
|
if (action.type === "close" && actionResult.success) {
|
||||||
|
deps.sessions.delete(action.sessionId)
|
||||||
|
log("[tmux-session-manager] removed closed session from cache", {
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (action.type === "replace" && actionResult.success) {
|
||||||
|
deps.sessions.delete(action.oldSessionId)
|
||||||
|
log("[tmux-session-manager] removed replaced session from cache", {
|
||||||
|
oldSessionId: action.oldSessionId,
|
||||||
|
newSessionId: action.newSessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success || !result.spawnedPaneId) {
|
||||||
|
log("[tmux-session-manager] spawn failed", {
|
||||||
|
success: result.success,
|
||||||
|
results: result.results.map((r) => ({
|
||||||
|
type: r.action.type,
|
||||||
|
success: r.result.success,
|
||||||
|
error: r.result.error,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionReady = await deps.waitForSessionReady(sessionId)
|
||||||
|
if (!sessionReady) {
|
||||||
|
log("[tmux-session-manager] session not ready after timeout, closing spawned pane", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await executeActions(
|
||||||
|
[{ type: "close", paneId: result.spawnedPaneId, sessionId }],
|
||||||
|
{
|
||||||
|
config: deps.tmuxConfig,
|
||||||
|
serverUrl: deps.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.sessions.set(
|
||||||
|
sessionId,
|
||||||
|
createTrackedSession({
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
description: title,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
log("[tmux-session-manager] pane spawned and tracked", {
|
||||||
|
sessionId,
|
||||||
|
paneId: result.spawnedPaneId,
|
||||||
|
sessionReady,
|
||||||
|
})
|
||||||
|
|
||||||
|
deps.startPolling()
|
||||||
|
} finally {
|
||||||
|
deps.pendingSessions.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/features/tmux-subagent/session-deleted-handler.ts
Normal file
50
src/features/tmux-subagent/session-deleted-handler.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { TmuxConfig } from "../../config/schema"
|
||||||
|
import type { TrackedSession } from "./types"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import { queryWindowState } from "./pane-state-querier"
|
||||||
|
import { decideCloseAction, type SessionMapping } from "./decision-engine"
|
||||||
|
import { executeAction } from "./action-executor"
|
||||||
|
|
||||||
|
export interface SessionDeletedHandlerDeps {
|
||||||
|
tmuxConfig: TmuxConfig
|
||||||
|
serverUrl: string
|
||||||
|
sourcePaneId: string | undefined
|
||||||
|
sessions: Map<string, TrackedSession>
|
||||||
|
isEnabled: () => boolean
|
||||||
|
getSessionMappings: () => SessionMapping[]
|
||||||
|
stopPolling: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleSessionDeleted(
|
||||||
|
deps: SessionDeletedHandlerDeps,
|
||||||
|
event: { sessionID: string },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!deps.isEnabled()) return
|
||||||
|
if (!deps.sourcePaneId) return
|
||||||
|
|
||||||
|
const tracked = deps.sessions.get(event.sessionID)
|
||||||
|
if (!tracked) return
|
||||||
|
|
||||||
|
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||||
|
|
||||||
|
const state = await queryWindowState(deps.sourcePaneId)
|
||||||
|
if (!state) {
|
||||||
|
deps.sessions.delete(event.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAction = decideCloseAction(state, event.sessionID, deps.getSessionMappings())
|
||||||
|
if (closeAction) {
|
||||||
|
await executeAction(closeAction, {
|
||||||
|
config: deps.tmuxConfig,
|
||||||
|
serverUrl: deps.serverUrl,
|
||||||
|
windowState: state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.sessions.delete(event.sessionID)
|
||||||
|
|
||||||
|
if (deps.sessions.size === 0) {
|
||||||
|
deps.stopPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/features/tmux-subagent/session-message-count.ts
Normal file
3
src/features/tmux-subagent/session-message-count.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function getMessageCount(data: unknown): number {
|
||||||
|
return Array.isArray(data) ? data.length : 0
|
||||||
|
}
|
||||||
44
src/features/tmux-subagent/session-ready-waiter.ts
Normal file
44
src/features/tmux-subagent/session-ready-waiter.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import {
|
||||||
|
SESSION_READY_POLL_INTERVAL_MS,
|
||||||
|
SESSION_READY_TIMEOUT_MS,
|
||||||
|
} from "../../shared/tmux"
|
||||||
|
import { log } from "../../shared"
|
||||||
|
import { parseSessionStatusMap } from "./session-status-parser"
|
||||||
|
|
||||||
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
|
export async function waitForSessionReady(params: {
|
||||||
|
client: OpencodeClient
|
||||||
|
sessionId: string
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
const statusResult = await params.client.session.status({ path: undefined })
|
||||||
|
const allStatuses = parseSessionStatusMap(statusResult.data)
|
||||||
|
|
||||||
|
if (allStatuses[params.sessionId]) {
|
||||||
|
log("[tmux-session-manager] session ready", {
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
status: allStatuses[params.sessionId].type,
|
||||||
|
waitedMs: Date.now() - startTime,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("[tmux-session-manager] session status check error", { error: String(error) })
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, SESSION_READY_POLL_INTERVAL_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[tmux-session-manager] session ready timeout", {
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
timeoutMs: SESSION_READY_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
17
src/features/tmux-subagent/session-status-parser.ts
Normal file
17
src/features/tmux-subagent/session-status-parser.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
type SessionStatus = { type: string }
|
||||||
|
|
||||||
|
export function parseSessionStatusMap(data: unknown): Record<string, SessionStatus> {
|
||||||
|
if (typeof data !== "object" || data === null) return {}
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
|
||||||
|
const result: Record<string, SessionStatus> = {}
|
||||||
|
for (const [sessionId, value] of Object.entries(record)) {
|
||||||
|
if (typeof value !== "object" || value === null) continue
|
||||||
|
const valueRecord = value as Record<string, unknown>
|
||||||
|
const type = valueRecord.type
|
||||||
|
if (typeof type !== "string") continue
|
||||||
|
result[sessionId] = { type }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
147
src/features/tmux-subagent/spawn-action-decider.ts
Normal file
147
src/features/tmux-subagent/spawn-action-decider.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type {
|
||||||
|
CapacityConfig,
|
||||||
|
PaneAction,
|
||||||
|
SpawnDecision,
|
||||||
|
TmuxPaneInfo,
|
||||||
|
WindowState,
|
||||||
|
} from "./types"
|
||||||
|
import { computeAgentAreaWidth } from "./tmux-grid-constants"
|
||||||
|
import {
|
||||||
|
canSplitPane,
|
||||||
|
findMinimalEvictions,
|
||||||
|
isSplittableAtCount,
|
||||||
|
} from "./pane-split-availability"
|
||||||
|
import { findSpawnTarget } from "./spawn-target-finder"
|
||||||
|
import { findOldestAgentPane, type SessionMapping } from "./oldest-agent-pane"
|
||||||
|
|
||||||
|
function getInitialSplitDirection(layout?: string): "-h" | "-v" {
|
||||||
|
return layout === "main-horizontal" ? "-v" : "-h"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainLayout(layout?: string): boolean {
|
||||||
|
return layout === "main-vertical" || layout === "main-horizontal"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideSpawnActions(
|
||||||
|
state: WindowState,
|
||||||
|
sessionId: string,
|
||||||
|
description: string,
|
||||||
|
config: CapacityConfig,
|
||||||
|
sessionMappings: SessionMapping[],
|
||||||
|
): SpawnDecision {
|
||||||
|
if (!state.mainPane) {
|
||||||
|
return { canSpawn: false, actions: [], reason: "no main pane found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentAreaWidth = computeAgentAreaWidth(state.windowWidth, config)
|
||||||
|
const minAgentPaneWidth = config.agentPaneWidth
|
||||||
|
const currentCount = state.agentPanes.length
|
||||||
|
const strictLayout = isStrictMainLayout(config.layout)
|
||||||
|
const initialSplitDirection = getInitialSplitDirection(config.layout)
|
||||||
|
|
||||||
|
if (agentAreaWidth < minAgentPaneWidth && currentCount > 0) {
|
||||||
|
return {
|
||||||
|
canSpawn: false,
|
||||||
|
actions: [],
|
||||||
|
reason: `window too small for agent panes: ${state.windowWidth}x${state.windowHeight}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldestPane = findOldestAgentPane(state.agentPanes, sessionMappings)
|
||||||
|
const oldestMapping = oldestPane
|
||||||
|
? sessionMappings.find((m) => m.paneId === oldestPane.paneId) ?? null
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (currentCount === 0) {
|
||||||
|
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||||
|
if (canSplitPane(virtualMainPane, initialSplitDirection, minAgentPaneWidth)) {
|
||||||
|
return {
|
||||||
|
canSpawn: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "spawn",
|
||||||
|
sessionId,
|
||||||
|
description,
|
||||||
|
targetPaneId: state.mainPane.paneId,
|
||||||
|
splitDirection: initialSplitDirection,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { canSpawn: false, actions: [], reason: "mainPane too small to split" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEvaluateSpawnTarget =
|
||||||
|
strictLayout ||
|
||||||
|
isSplittableAtCount(agentAreaWidth, currentCount, minAgentPaneWidth)
|
||||||
|
|
||||||
|
if (canEvaluateSpawnTarget) {
|
||||||
|
const spawnTarget = findSpawnTarget(state, config)
|
||||||
|
if (spawnTarget) {
|
||||||
|
return {
|
||||||
|
canSpawn: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "spawn",
|
||||||
|
sessionId,
|
||||||
|
description,
|
||||||
|
targetPaneId: spawnTarget.targetPaneId,
|
||||||
|
splitDirection: spawnTarget.splitDirection,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!strictLayout) {
|
||||||
|
const minEvictions = findMinimalEvictions(
|
||||||
|
agentAreaWidth,
|
||||||
|
currentCount,
|
||||||
|
minAgentPaneWidth,
|
||||||
|
)
|
||||||
|
if (minEvictions === 1 && oldestPane) {
|
||||||
|
return {
|
||||||
|
canSpawn: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "close",
|
||||||
|
paneId: oldestPane.paneId,
|
||||||
|
sessionId: oldestMapping?.sessionId || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "spawn",
|
||||||
|
sessionId,
|
||||||
|
description,
|
||||||
|
targetPaneId: state.mainPane.paneId,
|
||||||
|
splitDirection: initialSplitDirection,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reason: "closed 1 pane to make room for split",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldestPane) {
|
||||||
|
return {
|
||||||
|
canSpawn: false,
|
||||||
|
actions: [],
|
||||||
|
reason: "no split target available (defer attach)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canSpawn: false, actions: [], reason: "no split target available (defer attach)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideCloseAction(
|
||||||
|
state: WindowState,
|
||||||
|
sessionId: string,
|
||||||
|
sessionMappings: SessionMapping[],
|
||||||
|
): PaneAction | null {
|
||||||
|
const mapping = sessionMappings.find((m) => m.sessionId === sessionId)
|
||||||
|
if (!mapping) return null
|
||||||
|
|
||||||
|
const paneExists = state.agentPanes.some((pane) => pane.paneId === mapping.paneId)
|
||||||
|
if (!paneExists) return null
|
||||||
|
|
||||||
|
return { type: "close", paneId: mapping.paneId, sessionId }
|
||||||
|
}
|
||||||
146
src/features/tmux-subagent/spawn-target-finder.ts
Normal file
146
src/features/tmux-subagent/spawn-target-finder.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { CapacityConfig, SplitDirection, TmuxPaneInfo, WindowState } from "./types"
|
||||||
|
import { computeMainPaneWidth } from "./tmux-grid-constants"
|
||||||
|
import { computeGridPlan, mapPaneToSlot } from "./grid-planning"
|
||||||
|
import { canSplitPane } from "./pane-split-availability"
|
||||||
|
|
||||||
|
export interface SpawnTarget {
|
||||||
|
targetPaneId: string
|
||||||
|
splitDirection: SplitDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainVertical(config: CapacityConfig): boolean {
|
||||||
|
return config.layout === "main-vertical"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainHorizontal(config: CapacityConfig): boolean {
|
||||||
|
return config.layout === "main-horizontal"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrictMainLayout(config: CapacityConfig): boolean {
|
||||||
|
return isStrictMainVertical(config) || isStrictMainHorizontal(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialSplitDirection(config: CapacityConfig): SplitDirection {
|
||||||
|
return isStrictMainHorizontal(config) ? "-v" : "-h"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStrictFollowupSplitDirection(config: CapacityConfig): SplitDirection {
|
||||||
|
return isStrictMainHorizontal(config) ? "-h" : "-v"
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPanesForStrictLayout(panes: TmuxPaneInfo[], config: CapacityConfig): TmuxPaneInfo[] {
|
||||||
|
if (isStrictMainHorizontal(config)) {
|
||||||
|
return [...panes].sort((a, b) => a.left - b.left || a.top - b.top)
|
||||||
|
}
|
||||||
|
return [...panes].sort((a, b) => a.top - b.top || a.left - b.left)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOccupancy(
|
||||||
|
agentPanes: TmuxPaneInfo[],
|
||||||
|
plan: ReturnType<typeof computeGridPlan>,
|
||||||
|
mainPaneWidth: number,
|
||||||
|
): Map<string, TmuxPaneInfo> {
|
||||||
|
const occupancy = new Map<string, TmuxPaneInfo>()
|
||||||
|
for (const pane of agentPanes) {
|
||||||
|
const slot = mapPaneToSlot(pane, plan, mainPaneWidth)
|
||||||
|
occupancy.set(`${slot.row}:${slot.col}`, pane)
|
||||||
|
}
|
||||||
|
return occupancy
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstEmptySlot(
|
||||||
|
occupancy: Map<string, TmuxPaneInfo>,
|
||||||
|
plan: ReturnType<typeof computeGridPlan>,
|
||||||
|
): { row: number; col: number } {
|
||||||
|
for (let row = 0; row < plan.rows; row++) {
|
||||||
|
for (let col = 0; col < plan.cols; col++) {
|
||||||
|
if (!occupancy.has(`${row}:${col}`)) {
|
||||||
|
return { row, col }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row: plan.rows - 1, col: plan.cols - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSplittableTarget(
|
||||||
|
state: WindowState,
|
||||||
|
config: CapacityConfig,
|
||||||
|
_preferredDirection?: SplitDirection,
|
||||||
|
): SpawnTarget | null {
|
||||||
|
if (!state.mainPane) return null
|
||||||
|
const existingCount = state.agentPanes.length
|
||||||
|
const minAgentPaneWidth = config.agentPaneWidth
|
||||||
|
const initialDirection = getInitialSplitDirection(config)
|
||||||
|
|
||||||
|
if (existingCount === 0) {
|
||||||
|
const virtualMainPane: TmuxPaneInfo = { ...state.mainPane, width: state.windowWidth }
|
||||||
|
if (canSplitPane(virtualMainPane, initialDirection, minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: state.mainPane.paneId, splitDirection: initialDirection }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrictMainLayout(config)) {
|
||||||
|
const followupDirection = getStrictFollowupSplitDirection(config)
|
||||||
|
const panesByPriority = sortPanesForStrictLayout(state.agentPanes, config)
|
||||||
|
for (const pane of panesByPriority) {
|
||||||
|
if (canSplitPane(pane, followupDirection, minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: pane.paneId, splitDirection: followupDirection }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = computeGridPlan(
|
||||||
|
state.windowWidth,
|
||||||
|
state.windowHeight,
|
||||||
|
existingCount + 1,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
const mainPaneWidth = computeMainPaneWidth(state.windowWidth, config)
|
||||||
|
const occupancy = buildOccupancy(state.agentPanes, plan, mainPaneWidth)
|
||||||
|
const targetSlot = findFirstEmptySlot(occupancy, plan)
|
||||||
|
|
||||||
|
const leftPane = occupancy.get(`${targetSlot.row}:${targetSlot.col - 1}`)
|
||||||
|
if (
|
||||||
|
!isStrictMainVertical(config) &&
|
||||||
|
leftPane &&
|
||||||
|
canSplitPane(leftPane, "-h", minAgentPaneWidth)
|
||||||
|
) {
|
||||||
|
return { targetPaneId: leftPane.paneId, splitDirection: "-h" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const abovePane = occupancy.get(`${targetSlot.row - 1}:${targetSlot.col}`)
|
||||||
|
if (abovePane && canSplitPane(abovePane, "-v", minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: abovePane.paneId, splitDirection: "-v" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const panesByPosition = [...state.agentPanes].sort(
|
||||||
|
(a, b) => a.left - b.left || a.top - b.top,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const pane of panesByPosition) {
|
||||||
|
if (canSplitPane(pane, "-v", minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: pane.paneId, splitDirection: "-v" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStrictMainVertical(config)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pane of panesByPosition) {
|
||||||
|
if (canSplitPane(pane, "-h", minAgentPaneWidth)) {
|
||||||
|
return { targetPaneId: pane.paneId, splitDirection: "-h" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSpawnTarget(
|
||||||
|
state: WindowState,
|
||||||
|
config: CapacityConfig,
|
||||||
|
): SpawnTarget | null {
|
||||||
|
return findSplittableTarget(state, config)
|
||||||
|
}
|
||||||
57
src/features/tmux-subagent/tmux-grid-constants.ts
Normal file
57
src/features/tmux-subagent/tmux-grid-constants.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { MIN_PANE_HEIGHT, MIN_PANE_WIDTH } from "./types"
|
||||||
|
import type { CapacityConfig } from "./types"
|
||||||
|
|
||||||
|
export const MAIN_PANE_RATIO = 0.5
|
||||||
|
const DEFAULT_MAIN_PANE_SIZE = MAIN_PANE_RATIO * 100
|
||||||
|
export const MAX_COLS = 2
|
||||||
|
export const MAX_ROWS = 3
|
||||||
|
export const MAX_GRID_SIZE = 4
|
||||||
|
export const DIVIDER_SIZE = 1
|
||||||
|
|
||||||
|
export const MIN_SPLIT_WIDTH = 2 * MIN_PANE_WIDTH + DIVIDER_SIZE
|
||||||
|
export const MIN_SPLIT_HEIGHT = 2 * MIN_PANE_HEIGHT + DIVIDER_SIZE
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainPaneSizePercent(config?: CapacityConfig): number {
|
||||||
|
return clamp(config?.mainPaneSize ?? DEFAULT_MAIN_PANE_SIZE, 20, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMainPaneWidth(
|
||||||
|
windowWidth: number,
|
||||||
|
config?: CapacityConfig,
|
||||||
|
): number {
|
||||||
|
const safeWindowWidth = Math.max(0, windowWidth)
|
||||||
|
if (!config) {
|
||||||
|
return Math.floor(safeWindowWidth * MAIN_PANE_RATIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dividerWidth = DIVIDER_SIZE
|
||||||
|
const minMainPaneWidth = config?.mainPaneMinWidth ?? Math.floor(safeWindowWidth * MAIN_PANE_RATIO)
|
||||||
|
const minAgentPaneWidth = config?.agentPaneWidth ?? MIN_PANE_WIDTH
|
||||||
|
const percentageMainPaneWidth = Math.floor(
|
||||||
|
(safeWindowWidth - dividerWidth) * (getMainPaneSizePercent(config) / 100),
|
||||||
|
)
|
||||||
|
const maxMainPaneWidth = Math.max(0, safeWindowWidth - dividerWidth - minAgentPaneWidth)
|
||||||
|
|
||||||
|
return clamp(
|
||||||
|
Math.max(percentageMainPaneWidth, minMainPaneWidth),
|
||||||
|
0,
|
||||||
|
maxMainPaneWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeAgentAreaWidth(
|
||||||
|
windowWidth: number,
|
||||||
|
config?: CapacityConfig,
|
||||||
|
): number {
|
||||||
|
const safeWindowWidth = Math.max(0, windowWidth)
|
||||||
|
if (!config) {
|
||||||
|
return Math.floor(safeWindowWidth * (1 - MAIN_PANE_RATIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPaneWidth = computeMainPaneWidth(safeWindowWidth, config)
|
||||||
|
return Math.max(0, safeWindowWidth - DIVIDER_SIZE - mainPaneWidth)
|
||||||
|
}
|
||||||
28
src/features/tmux-subagent/tracked-session-state.ts
Normal file
28
src/features/tmux-subagent/tracked-session-state.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { TrackedSession } from "./types"
|
||||||
|
|
||||||
|
export function createTrackedSession(params: {
|
||||||
|
sessionId: string
|
||||||
|
paneId: string
|
||||||
|
description: string
|
||||||
|
now?: Date
|
||||||
|
}): TrackedSession {
|
||||||
|
const now = params.now ?? new Date()
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
paneId: params.paneId,
|
||||||
|
description: params.description,
|
||||||
|
createdAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
closePending: false,
|
||||||
|
closeRetryCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markTrackedSessionClosePending(tracked: TrackedSession): TrackedSession {
|
||||||
|
return {
|
||||||
|
...tracked,
|
||||||
|
closePending: true,
|
||||||
|
closeRetryCount: tracked.closePending ? tracked.closeRetryCount + 1 : tracked.closeRetryCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/features/tmux-subagent/types.ts
Normal file
51
src/features/tmux-subagent/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface TrackedSession {
|
||||||
|
sessionId: string
|
||||||
|
paneId: string
|
||||||
|
description: string
|
||||||
|
createdAt: Date
|
||||||
|
lastSeenAt: Date
|
||||||
|
closePending: boolean
|
||||||
|
closeRetryCount: number
|
||||||
|
lastMessageCount?: number
|
||||||
|
stableIdlePolls?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MIN_PANE_WIDTH = 52
|
||||||
|
export const MIN_PANE_HEIGHT = 11
|
||||||
|
|
||||||
|
export interface TmuxPaneInfo {
|
||||||
|
paneId: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
title: string
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowState {
|
||||||
|
windowWidth: number
|
||||||
|
windowHeight: number
|
||||||
|
mainPane: TmuxPaneInfo | null
|
||||||
|
agentPanes: TmuxPaneInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SplitDirection = "-h" | "-v"
|
||||||
|
|
||||||
|
export type PaneAction =
|
||||||
|
| { type: "close"; paneId: string; sessionId: string }
|
||||||
|
| { type: "spawn"; sessionId: string; description: string; targetPaneId: string; splitDirection: SplitDirection }
|
||||||
|
| { type: "replace"; paneId: string; oldSessionId: string; newSessionId: string; description: string }
|
||||||
|
|
||||||
|
export interface SpawnDecision {
|
||||||
|
canSpawn: boolean
|
||||||
|
actions: PaneAction[]
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapacityConfig {
|
||||||
|
layout?: string
|
||||||
|
mainPaneSize?: number
|
||||||
|
mainPaneMinWidth: number
|
||||||
|
agentPaneWidth: number
|
||||||
|
}
|
||||||
46
src/index.ts
Normal file
46
src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Plugin } from "@opencode-ai/plugin"
|
||||||
|
import { TmuxConfigSchema } from "./config/schema"
|
||||||
|
import { TmuxSessionManager } from "./features/tmux-subagent"
|
||||||
|
import { loadPluginConfig } from "./plugin-config"
|
||||||
|
import { log } from "./shared"
|
||||||
|
import { interactive_bash, startTmuxCheck } from "./tools"
|
||||||
|
|
||||||
|
let activeManager: TmuxSessionManager | null = null
|
||||||
|
|
||||||
|
const TmuxUtilsPlugin: Plugin = async (ctx) => {
|
||||||
|
startTmuxCheck()
|
||||||
|
await activeManager?.cleanup().catch((error) => {
|
||||||
|
log("[tmux-utils] cleanup error", { error: String(error) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pluginConfig = loadPluginConfig(ctx.directory)
|
||||||
|
const tmuxConfig = TmuxConfigSchema.parse(pluginConfig.tmux ?? {})
|
||||||
|
|
||||||
|
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig)
|
||||||
|
activeManager = tmuxSessionManager
|
||||||
|
|
||||||
|
const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => {
|
||||||
|
const { event } = input
|
||||||
|
if (event.type === "session.created") {
|
||||||
|
await tmuxSessionManager.onSessionCreated(event as { type: string; properties?: { info?: { id?: string; parentID?: string; title?: string } } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.deleted") {
|
||||||
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
const info = (props?.info as { id?: string } | undefined)
|
||||||
|
if (info?.id) {
|
||||||
|
await tmuxSessionManager.onSessionDeleted({ sessionID: info.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tool: {
|
||||||
|
interactive_bash,
|
||||||
|
},
|
||||||
|
event: eventHandler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TmuxUtilsPlugin
|
||||||
60
src/plugin-config.ts
Normal file
60
src/plugin-config.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { PluginConfigSchema, type TmuxUtilsConfig } from "./config/schema"
|
||||||
|
import { detectConfigFile, getOpenCodeConfigDir, log, parseJsonc } from "./shared"
|
||||||
|
|
||||||
|
export function loadConfigFromPath(configPath: string): TmuxUtilsConfig | null {
|
||||||
|
try {
|
||||||
|
if (!existsSync(configPath)) return null
|
||||||
|
const content = readFileSync(configPath, "utf-8")
|
||||||
|
const rawConfig = parseJsonc<Record<string, unknown>>(content)
|
||||||
|
const result = PluginConfigSchema.safeParse(rawConfig)
|
||||||
|
if (result.success) {
|
||||||
|
log(`Config loaded from ${configPath}`)
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Config validation error in ${configPath}:`, result.error.issues)
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error loading config from ${configPath}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeConfigs(base: TmuxUtilsConfig, override: TmuxUtilsConfig): TmuxUtilsConfig {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...override,
|
||||||
|
tmux: {
|
||||||
|
...(base.tmux ?? {}),
|
||||||
|
...(override.tmux ?? {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPluginConfig(directory: string): TmuxUtilsConfig {
|
||||||
|
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||||
|
const userBasePath = join(configDir, "tmux-utils")
|
||||||
|
const userDetected = detectConfigFile(userBasePath)
|
||||||
|
const userConfigPath =
|
||||||
|
userDetected.format !== "none"
|
||||||
|
? userDetected.path
|
||||||
|
: `${userBasePath}.json`
|
||||||
|
|
||||||
|
const projectBasePath = join(directory, ".opencode", "tmux-utils")
|
||||||
|
const projectDetected = detectConfigFile(projectBasePath)
|
||||||
|
const projectConfigPath =
|
||||||
|
projectDetected.format !== "none"
|
||||||
|
? projectDetected.path
|
||||||
|
: `${projectBasePath}.json`
|
||||||
|
|
||||||
|
let config: TmuxUtilsConfig = loadConfigFromPath(userConfigPath) ?? {}
|
||||||
|
|
||||||
|
const projectConfig = loadConfigFromPath(projectConfigPath)
|
||||||
|
if (projectConfig) {
|
||||||
|
config = mergeConfigs(config, projectConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
17
src/shared/index.ts
Normal file
17
src/shared/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { log, getLogFilePath } from "./logger"
|
||||||
|
export { normalizeSDKResponse } from "./normalize-sdk-response"
|
||||||
|
export { parseJsonc, parseJsoncSafe, readJsoncFile, detectConfigFile } from "./jsonc-parser"
|
||||||
|
export {
|
||||||
|
getOpenCodeConfigDir,
|
||||||
|
getOpenCodeConfigPaths,
|
||||||
|
detectExistingConfigDir,
|
||||||
|
isDevBuild,
|
||||||
|
TAURI_APP_IDENTIFIER,
|
||||||
|
TAURI_APP_IDENTIFIER_DEV,
|
||||||
|
} from "./opencode-config-dir"
|
||||||
|
export type {
|
||||||
|
OpenCodeBinaryType,
|
||||||
|
OpenCodeConfigDirOptions,
|
||||||
|
OpenCodeConfigPaths,
|
||||||
|
} from "./opencode-config-dir"
|
||||||
|
export * from "./tmux"
|
||||||
66
src/shared/jsonc-parser.ts
Normal file
66
src/shared/jsonc-parser.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import { parse, printParseErrorCode, type ParseError } from "jsonc-parser"
|
||||||
|
|
||||||
|
export interface JsoncParseResult<T> {
|
||||||
|
data: T | null
|
||||||
|
errors: Array<{ message: string; offset: number; length: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsonc<T = unknown>(content: string): T {
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const result = parse(content, errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
disallowComments: false,
|
||||||
|
}) as T
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const errorMessages = errors
|
||||||
|
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
|
||||||
|
.join(", ")
|
||||||
|
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const data = parse(content, errors, {
|
||||||
|
allowTrailingComma: true,
|
||||||
|
disallowComments: false,
|
||||||
|
}) as T | null
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: errors.length > 0 ? null : data,
|
||||||
|
errors: errors.map((e) => ({
|
||||||
|
message: printParseErrorCode(e.error),
|
||||||
|
offset: e.offset,
|
||||||
|
length: e.length,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsoncFile<T = unknown>(filePath: string): T | null {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, "utf-8")
|
||||||
|
return parseJsonc<T>(content)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectConfigFile(basePath: string): {
|
||||||
|
format: "json" | "jsonc" | "none"
|
||||||
|
path: string
|
||||||
|
} {
|
||||||
|
const jsoncPath = `${basePath}.jsonc`
|
||||||
|
const jsonPath = `${basePath}.json`
|
||||||
|
|
||||||
|
if (existsSync(jsoncPath)) {
|
||||||
|
return { format: "jsonc", path: jsoncPath }
|
||||||
|
}
|
||||||
|
if (existsSync(jsonPath)) {
|
||||||
|
return { format: "json", path: jsonPath }
|
||||||
|
}
|
||||||
|
return { format: "none", path: jsonPath }
|
||||||
|
}
|
||||||
46
src/shared/logger.ts
Normal file
46
src/shared/logger.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as fs from "node:fs"
|
||||||
|
import * as os from "node:os"
|
||||||
|
import * as path from "node:path"
|
||||||
|
|
||||||
|
const logFile = path.join(os.tmpdir(), "tmux-utils.log")
|
||||||
|
|
||||||
|
let buffer: string[] = []
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const FLUSH_INTERVAL_MS = 500
|
||||||
|
const BUFFER_SIZE_LIMIT = 50
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
if (buffer.length === 0) return
|
||||||
|
const data = buffer.join("")
|
||||||
|
buffer = []
|
||||||
|
try {
|
||||||
|
fs.appendFileSync(logFile, data)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(): void {
|
||||||
|
if (flushTimer) return
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null
|
||||||
|
flush()
|
||||||
|
}, FLUSH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(message: string, data?: unknown): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
|
||||||
|
buffer.push(logEntry)
|
||||||
|
if (buffer.length >= BUFFER_SIZE_LIMIT) {
|
||||||
|
flush()
|
||||||
|
} else {
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogFilePath(): string {
|
||||||
|
return logFile
|
||||||
|
}
|
||||||
36
src/shared/normalize-sdk-response.ts
Normal file
36
src/shared/normalize-sdk-response.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface NormalizeSDKResponseOptions {
|
||||||
|
preferResponseOnMissingData?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSDKResponse<TData>(
|
||||||
|
response: unknown,
|
||||||
|
fallback: TData,
|
||||||
|
options?: NormalizeSDKResponseOptions,
|
||||||
|
): TData {
|
||||||
|
if (response === null || response === undefined) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response as TData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response === "object" && response !== null && "data" in response) {
|
||||||
|
const data = (response as { data?: unknown }).data
|
||||||
|
if (data !== null && data !== undefined) {
|
||||||
|
return data as TData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.preferResponseOnMissingData === true) {
|
||||||
|
return response as TData
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.preferResponseOnMissingData === true) {
|
||||||
|
return response as TData
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
15
src/shared/opencode-config-dir-types.ts
Normal file
15
src/shared/opencode-config-dir-types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type OpenCodeBinaryType = "opencode" | "opencode-desktop"
|
||||||
|
|
||||||
|
export type OpenCodeConfigDirOptions = {
|
||||||
|
binary: OpenCodeBinaryType
|
||||||
|
version?: string | null
|
||||||
|
checkExisting?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenCodeConfigPaths = {
|
||||||
|
configDir: string
|
||||||
|
configJson: string
|
||||||
|
configJsonc: string
|
||||||
|
packageJson: string
|
||||||
|
omoConfig: string
|
||||||
|
}
|
||||||
118
src/shared/opencode-config-dir.ts
Normal file
118
src/shared/opencode-config-dir.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { existsSync } from "node:fs"
|
||||||
|
import { homedir } from "node:os"
|
||||||
|
import { join, resolve } from "node:path"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OpenCodeBinaryType,
|
||||||
|
OpenCodeConfigDirOptions,
|
||||||
|
OpenCodeConfigPaths,
|
||||||
|
} from "./opencode-config-dir-types"
|
||||||
|
|
||||||
|
export type {
|
||||||
|
OpenCodeBinaryType,
|
||||||
|
OpenCodeConfigDirOptions,
|
||||||
|
OpenCodeConfigPaths,
|
||||||
|
} from "./opencode-config-dir-types"
|
||||||
|
|
||||||
|
export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop"
|
||||||
|
export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev"
|
||||||
|
|
||||||
|
export function isDevBuild(version: string | null | undefined): boolean {
|
||||||
|
if (!version) return false
|
||||||
|
return version.includes("-dev") || version.includes(".dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTauriConfigDir(identifier: string): string {
|
||||||
|
const platform = process.platform
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case "darwin":
|
||||||
|
return join(homedir(), "Library", "Application Support", identifier)
|
||||||
|
|
||||||
|
case "win32": {
|
||||||
|
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
|
||||||
|
return join(appData, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
||||||
|
return join(xdgConfig, identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCliConfigDir(): string {
|
||||||
|
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
||||||
|
if (envConfigDir) {
|
||||||
|
return resolve(envConfigDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
||||||
|
return join(xdgConfig, "opencode")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string {
|
||||||
|
const { binary, version, checkExisting = true } = options
|
||||||
|
|
||||||
|
if (binary === "opencode") {
|
||||||
|
return getCliConfigDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
||||||
|
const tauriDir = getTauriConfigDir(identifier)
|
||||||
|
|
||||||
|
if (checkExisting) {
|
||||||
|
const legacyDir = getCliConfigDir()
|
||||||
|
const legacyConfig = join(legacyDir, "opencode.json")
|
||||||
|
const legacyConfigC = join(legacyDir, "opencode.jsonc")
|
||||||
|
|
||||||
|
if (existsSync(legacyConfig) || existsSync(legacyConfigC)) {
|
||||||
|
return legacyDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tauriDir
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenCodeConfigPaths {
|
||||||
|
const configDir = getOpenCodeConfigDir(options)
|
||||||
|
|
||||||
|
return {
|
||||||
|
configDir,
|
||||||
|
configJson: join(configDir, "opencode.json"),
|
||||||
|
configJsonc: join(configDir, "opencode.jsonc"),
|
||||||
|
packageJson: join(configDir, "package.json"),
|
||||||
|
omoConfig: join(configDir, "oh-my-opencode.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: string | null): string | null {
|
||||||
|
const locations: string[] = []
|
||||||
|
|
||||||
|
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
||||||
|
if (envConfigDir) {
|
||||||
|
locations.push(resolve(envConfigDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binary === "opencode-desktop") {
|
||||||
|
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
||||||
|
locations.push(getTauriConfigDir(identifier))
|
||||||
|
|
||||||
|
if (isDevBuild(version)) {
|
||||||
|
locations.push(getTauriConfigDir(TAURI_APP_IDENTIFIER))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locations.push(getCliConfigDir())
|
||||||
|
|
||||||
|
for (const dir of locations) {
|
||||||
|
const configJson = join(dir, "opencode.json")
|
||||||
|
const configJsonc = join(dir, "opencode.jsonc")
|
||||||
|
|
||||||
|
if (existsSync(configJson) || existsSync(configJsonc)) {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
84
src/shared/shell-env.ts
Normal file
84
src/shared/shell-env.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
export type ShellType = "unix" | "powershell" | "cmd"
|
||||||
|
|
||||||
|
export function detectShellType(): ShellType {
|
||||||
|
if (process.env.PSModulePath) {
|
||||||
|
return "powershell"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SHELL) {
|
||||||
|
return "unix"
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.platform === "win32" ? "cmd" : "unix"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shellEscape(value: string, shellType: ShellType): string {
|
||||||
|
if (value === "") {
|
||||||
|
return shellType === "cmd" ? '""' : "''"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (shellType) {
|
||||||
|
case "unix":
|
||||||
|
if (/[^a-zA-Z0-9_\-.:/]/.test(value)) {
|
||||||
|
return `'${value.replace(/'/g, "'\\''")}'`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
case "powershell":
|
||||||
|
return `'${value.replace(/'/g, "''")}'`
|
||||||
|
|
||||||
|
case "cmd":
|
||||||
|
return `"${value.replace(/%/g, "%%").replace(/"/g, '""')}"`
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEnvPrefix(env: Record<string, string>, shellType: ShellType): string {
|
||||||
|
const entries = Object.entries(env)
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (shellType) {
|
||||||
|
case "unix": {
|
||||||
|
const assignments = entries
|
||||||
|
.map(([key, value]) => `${key}=${shellEscape(value, shellType)}`)
|
||||||
|
.join(" ")
|
||||||
|
return `export ${assignments};`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "powershell": {
|
||||||
|
const assignments = entries
|
||||||
|
.map(([key, value]) => `$env:${key}=${shellEscape(value, shellType)}`)
|
||||||
|
.join("; ")
|
||||||
|
return `${assignments};`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cmd": {
|
||||||
|
const assignments = entries
|
||||||
|
.map(([key, value]) => `set ${key}=${shellEscape(value, shellType)}`)
|
||||||
|
.join(" && ")
|
||||||
|
return `${assignments} &&`
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shellEscapeForDoubleQuotedCommand(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\$/g, "\\$")
|
||||||
|
.replace(/`/g, "\\`")
|
||||||
|
.replace(/"/g, "\\\"")
|
||||||
|
.replace(/;/g, "\\;")
|
||||||
|
.replace(/\|/g, "\\|")
|
||||||
|
.replace(/&/g, "\\&")
|
||||||
|
.replace(/#/g, "\\#")
|
||||||
|
.replace(/\(/g, "\\(")
|
||||||
|
.replace(/\)/g, "\\)")
|
||||||
|
}
|
||||||
84
src/shared/spawn-with-windows-hide.ts
Normal file
84
src/shared/spawn-with-windows-hide.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { spawn as bunSpawn } from "bun"
|
||||||
|
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
|
||||||
|
export interface SpawnOptions {
|
||||||
|
cwd?: string
|
||||||
|
env?: Record<string, string | undefined>
|
||||||
|
stdin?: "pipe" | "inherit" | "ignore"
|
||||||
|
stdout?: "pipe" | "inherit" | "ignore"
|
||||||
|
stderr?: "pipe" | "inherit" | "ignore"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnedProcess {
|
||||||
|
readonly exitCode: number | null
|
||||||
|
readonly exited: Promise<number>
|
||||||
|
readonly stdout: ReadableStream<Uint8Array> | undefined
|
||||||
|
readonly stderr: ReadableStream<Uint8Array> | undefined
|
||||||
|
kill(signal?: NodeJS.Signals): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> | undefined {
|
||||||
|
if (!stream) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapNodeProcess(proc: ChildProcess): SpawnedProcess {
|
||||||
|
let resolveExited: (exitCode: number) => void
|
||||||
|
let exitCode: number | null = null
|
||||||
|
|
||||||
|
const exited = new Promise<number>((resolve) => {
|
||||||
|
resolveExited = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on("exit", (code) => {
|
||||||
|
exitCode = code ?? 1
|
||||||
|
resolveExited(exitCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on("error", () => {
|
||||||
|
if (exitCode === null) {
|
||||||
|
exitCode = 1
|
||||||
|
resolveExited(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
get exitCode() {
|
||||||
|
return exitCode
|
||||||
|
},
|
||||||
|
exited,
|
||||||
|
stdout: toReadableStream(proc.stdout),
|
||||||
|
stderr: toReadableStream(proc.stderr),
|
||||||
|
kill(signal?: NodeJS.Signals): void {
|
||||||
|
try {
|
||||||
|
if (!signal) {
|
||||||
|
proc.kill()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.kill(signal)
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnWithWindowsHide(command: string[], options: SpawnOptions): SpawnedProcess {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return bunSpawn(command, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cmd, ...args] = command
|
||||||
|
const proc = nodeSpawn(cmd, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
shell: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return wrapNodeProcess(proc)
|
||||||
|
}
|
||||||
5
src/shared/tmux/constants.ts
Normal file
5
src/shared/tmux/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const POLL_INTERVAL_BACKGROUND_MS = 2000
|
||||||
|
export const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
export const SESSION_MISSING_GRACE_MS = 6000
|
||||||
|
export const SESSION_READY_POLL_INTERVAL_MS = 500
|
||||||
|
export const SESSION_READY_TIMEOUT_MS = 10_000
|
||||||
3
src/shared/tmux/index.ts
Normal file
3
src/shared/tmux/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./tmux-utils"
|
||||||
|
export * from "./constants"
|
||||||
|
export * from "./types"
|
||||||
13
src/shared/tmux/tmux-utils.ts
Normal file
13
src/shared/tmux/tmux-utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export { isInsideTmux, getCurrentPaneId } from "./tmux-utils/environment"
|
||||||
|
export type { SplitDirection } from "./tmux-utils/environment"
|
||||||
|
|
||||||
|
export { isServerRunning, resetServerCheck } from "./tmux-utils/server-health"
|
||||||
|
|
||||||
|
export { getPaneDimensions } from "./tmux-utils/pane-dimensions"
|
||||||
|
export type { PaneDimensions } from "./tmux-utils/pane-dimensions"
|
||||||
|
|
||||||
|
export { spawnTmuxPane } from "./tmux-utils/pane-spawn"
|
||||||
|
export { closeTmuxPane } from "./tmux-utils/pane-close"
|
||||||
|
export { replaceTmuxPane } from "./tmux-utils/pane-replace"
|
||||||
|
|
||||||
|
export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
|
||||||
13
src/shared/tmux/tmux-utils/environment.ts
Normal file
13
src/shared/tmux/tmux-utils/environment.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type SplitDirection = "-h" | "-v"
|
||||||
|
|
||||||
|
export function isInsideTmuxEnvironment(environment: Record<string, string | undefined>): boolean {
|
||||||
|
return Boolean(environment.TMUX)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInsideTmux(): boolean {
|
||||||
|
return isInsideTmuxEnvironment(process.env)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPaneId(): string | undefined {
|
||||||
|
return process.env.TMUX_PANE
|
||||||
|
}
|
||||||
96
src/shared/tmux/tmux-utils/layout.ts
Normal file
96
src/shared/tmux/tmux-utils/layout.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import type { TmuxLayout } from "../../../config/schema"
|
||||||
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
|
||||||
|
type TmuxSpawnCommand = (
|
||||||
|
args: string[],
|
||||||
|
options: { stdout: "ignore"; stderr: "ignore" },
|
||||||
|
) => { exited: Promise<number> }
|
||||||
|
|
||||||
|
interface LayoutDeps {
|
||||||
|
spawnCommand?: TmuxSpawnCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainPaneWidthOptions {
|
||||||
|
mainPaneSize?: number
|
||||||
|
mainPaneMinWidth?: number
|
||||||
|
agentPaneMinWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMainPaneWidth(
|
||||||
|
windowWidth: number,
|
||||||
|
options?: MainPaneWidthOptions,
|
||||||
|
): number {
|
||||||
|
const dividerWidth = 1
|
||||||
|
const sizePercent = clamp(options?.mainPaneSize ?? 50, 20, 80)
|
||||||
|
const minMainPaneWidth = options?.mainPaneMinWidth ?? 0
|
||||||
|
const minAgentPaneWidth = options?.agentPaneMinWidth ?? 0
|
||||||
|
const desiredMainPaneWidth = Math.floor(
|
||||||
|
(windowWidth - dividerWidth) * (sizePercent / 100),
|
||||||
|
)
|
||||||
|
const maxMainPaneWidth = Math.max(
|
||||||
|
0,
|
||||||
|
windowWidth - dividerWidth - minAgentPaneWidth,
|
||||||
|
)
|
||||||
|
|
||||||
|
return clamp(Math.max(desiredMainPaneWidth, minMainPaneWidth), 0, maxMainPaneWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLayout(
|
||||||
|
tmux: string,
|
||||||
|
layout: TmuxLayout,
|
||||||
|
mainPaneSize: number,
|
||||||
|
deps?: LayoutDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
const spawnCommand: TmuxSpawnCommand = deps?.spawnCommand ?? spawn
|
||||||
|
const layoutProc = spawnCommand([tmux, "select-layout", layout], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
await layoutProc.exited
|
||||||
|
|
||||||
|
if (layout.startsWith("main-")) {
|
||||||
|
const dimension =
|
||||||
|
layout === "main-horizontal" ? "main-pane-height" : "main-pane-width"
|
||||||
|
const sizeProc = spawnCommand(
|
||||||
|
[tmux, "set-window-option", dimension, `${mainPaneSize}%`],
|
||||||
|
{ stdout: "ignore", stderr: "ignore" },
|
||||||
|
)
|
||||||
|
await sizeProc.exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceMainPaneWidth(
|
||||||
|
mainPaneId: string,
|
||||||
|
windowWidth: number,
|
||||||
|
mainPaneSizeOrOptions?: number | MainPaneWidthOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const { log } = await import("../../logger")
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) return
|
||||||
|
|
||||||
|
const options: MainPaneWidthOptions =
|
||||||
|
typeof mainPaneSizeOrOptions === "number"
|
||||||
|
? { mainPaneSize: mainPaneSizeOrOptions }
|
||||||
|
: mainPaneSizeOrOptions ?? {}
|
||||||
|
const mainWidth = calculateMainPaneWidth(windowWidth, options)
|
||||||
|
|
||||||
|
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
|
log("[enforceMainPaneWidth] main pane resized", {
|
||||||
|
mainPaneId,
|
||||||
|
mainWidth,
|
||||||
|
windowWidth,
|
||||||
|
mainPaneSize: options?.mainPaneSize,
|
||||||
|
mainPaneMinWidth: options?.mainPaneMinWidth,
|
||||||
|
agentPaneMinWidth: options?.agentPaneMinWidth,
|
||||||
|
})
|
||||||
|
}
|
||||||
48
src/shared/tmux/tmux-utils/pane-close.ts
Normal file
48
src/shared/tmux/tmux-utils/pane-close.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import { isInsideTmux } from "./environment"
|
||||||
|
|
||||||
|
function delay(milliseconds: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeTmuxPane(paneId: string): Promise<boolean> {
|
||||||
|
const { log } = await import("../../logger")
|
||||||
|
|
||||||
|
if (!isInsideTmux()) {
|
||||||
|
log("[closeTmuxPane] SKIP: not inside tmux")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) {
|
||||||
|
log("[closeTmuxPane] SKIP: tmux not found")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
||||||
|
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
await ctrlCProc.exited
|
||||||
|
|
||||||
|
await delay(250)
|
||||||
|
|
||||||
|
log("[closeTmuxPane] killing pane", { paneId })
|
||||||
|
|
||||||
|
const proc = spawn([tmux, "kill-pane", "-t", paneId], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const stderr = await new Response(proc.stderr).text()
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
|
||||||
|
} else {
|
||||||
|
log("[closeTmuxPane] SUCCESS", { paneId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return exitCode === 0
|
||||||
|
}
|
||||||
28
src/shared/tmux/tmux-utils/pane-dimensions.ts
Normal file
28
src/shared/tmux/tmux-utils/pane-dimensions.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
|
||||||
|
export interface PaneDimensions {
|
||||||
|
paneWidth: number
|
||||||
|
windowWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPaneDimensions(
|
||||||
|
paneId: string,
|
||||||
|
): Promise<PaneDimensions | null> {
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) return null
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
[tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"],
|
||||||
|
{ stdout: "pipe", stderr: "pipe" },
|
||||||
|
)
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const stdout = await new Response(proc.stdout).text()
|
||||||
|
|
||||||
|
if (exitCode !== 0) return null
|
||||||
|
|
||||||
|
const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number)
|
||||||
|
if (Number.isNaN(paneWidth) || Number.isNaN(windowWidth)) return null
|
||||||
|
|
||||||
|
return { paneWidth, windowWidth }
|
||||||
|
}
|
||||||
73
src/shared/tmux/tmux-utils/pane-replace.ts
Normal file
73
src/shared/tmux/tmux-utils/pane-replace.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import type { TmuxConfig } from "../../../config/schema"
|
||||||
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import type { SpawnPaneResult } from "../types"
|
||||||
|
import { isInsideTmux } from "./environment"
|
||||||
|
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||||
|
|
||||||
|
export async function replaceTmuxPane(
|
||||||
|
paneId: string,
|
||||||
|
sessionId: string,
|
||||||
|
description: string,
|
||||||
|
config: TmuxConfig,
|
||||||
|
serverUrl: string,
|
||||||
|
): Promise<SpawnPaneResult> {
|
||||||
|
const { log } = await import("../../logger")
|
||||||
|
|
||||||
|
log("[replaceTmuxPane] called", { paneId, sessionId, description })
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
if (!isInsideTmux()) {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
||||||
|
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
await ctrlCProc.exited
|
||||||
|
|
||||||
|
const shell = process.env.SHELL || "/bin/sh"
|
||||||
|
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||||
|
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||||
|
|
||||||
|
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const stderr = await new Response(proc.stderr).text()
|
||||||
|
log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = `omo-subagent-${description.slice(0, 20)}`
|
||||||
|
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
|
||||||
|
const titleExitCode = await titleProc.exited
|
||||||
|
if (titleExitCode !== 0) {
|
||||||
|
const titleStderr = await stderrPromise
|
||||||
|
log("[replaceTmuxPane] WARNING: failed to set pane title", {
|
||||||
|
paneId,
|
||||||
|
title,
|
||||||
|
exitCode: titleExitCode,
|
||||||
|
stderr: titleStderr.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[replaceTmuxPane] SUCCESS", { paneId, sessionId })
|
||||||
|
return { success: true, paneId }
|
||||||
|
}
|
||||||
94
src/shared/tmux/tmux-utils/pane-spawn.ts
Normal file
94
src/shared/tmux/tmux-utils/pane-spawn.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
import type { TmuxConfig } from "../../../config/schema"
|
||||||
|
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||||
|
import type { SpawnPaneResult } from "../types"
|
||||||
|
import type { SplitDirection } from "./environment"
|
||||||
|
import { isInsideTmux } from "./environment"
|
||||||
|
import { isServerRunning } from "./server-health"
|
||||||
|
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||||
|
|
||||||
|
export async function spawnTmuxPane(
|
||||||
|
sessionId: string,
|
||||||
|
description: string,
|
||||||
|
config: TmuxConfig,
|
||||||
|
serverUrl: string,
|
||||||
|
targetPaneId?: string,
|
||||||
|
splitDirection: SplitDirection = "-h",
|
||||||
|
): Promise<SpawnPaneResult> {
|
||||||
|
const { log } = await import("../../logger")
|
||||||
|
|
||||||
|
log("[spawnTmuxPane] called", {
|
||||||
|
sessionId,
|
||||||
|
description,
|
||||||
|
serverUrl,
|
||||||
|
configEnabled: config.enabled,
|
||||||
|
targetPaneId,
|
||||||
|
splitDirection,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
log("[spawnTmuxPane] SKIP: config.enabled is false")
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
if (!isInsideTmux()) {
|
||||||
|
log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX })
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverRunning = await isServerRunning(serverUrl)
|
||||||
|
if (!serverRunning) {
|
||||||
|
log("[spawnTmuxPane] SKIP: server not running", { serverUrl })
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmux = await getTmuxPath()
|
||||||
|
if (!tmux) {
|
||||||
|
log("[spawnTmuxPane] SKIP: tmux not found")
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[spawnTmuxPane] all checks passed, spawning...")
|
||||||
|
|
||||||
|
const shell = process.env.SHELL || "/bin/sh"
|
||||||
|
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||||
|
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"split-window",
|
||||||
|
splitDirection,
|
||||||
|
"-d",
|
||||||
|
"-P",
|
||||||
|
"-F",
|
||||||
|
"#{pane_id}",
|
||||||
|
...(targetPaneId ? ["-t", targetPaneId] : []),
|
||||||
|
opencodeCmd,
|
||||||
|
]
|
||||||
|
|
||||||
|
const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" })
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
const stdout = await new Response(proc.stdout).text()
|
||||||
|
const paneId = stdout.trim()
|
||||||
|
|
||||||
|
if (exitCode !== 0 || !paneId) {
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = `omo-subagent-${description.slice(0, 20)}`
|
||||||
|
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
|
||||||
|
const titleExitCode = await titleProc.exited
|
||||||
|
if (titleExitCode !== 0) {
|
||||||
|
const titleStderr = await stderrPromise
|
||||||
|
log("[spawnTmuxPane] WARNING: failed to set pane title", {
|
||||||
|
paneId,
|
||||||
|
title,
|
||||||
|
exitCode: titleExitCode,
|
||||||
|
stderr: titleStderr.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, paneId }
|
||||||
|
}
|
||||||
47
src/shared/tmux/tmux-utils/server-health.ts
Normal file
47
src/shared/tmux/tmux-utils/server-health.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
let serverAvailable: boolean | null = null
|
||||||
|
let serverCheckUrl: string | null = null
|
||||||
|
|
||||||
|
function delay(milliseconds: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isServerRunning(serverUrl: string): Promise<boolean> {
|
||||||
|
if (serverCheckUrl === serverUrl && serverAvailable === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthUrl = new URL("/global/health", serverUrl).toString()
|
||||||
|
const timeoutMs = 3000
|
||||||
|
const maxAttempts = 2
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(healthUrl, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}).catch(() => null)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (response?.ok) {
|
||||||
|
serverCheckUrl = serverUrl
|
||||||
|
serverAvailable = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await delay(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetServerCheck(): void {
|
||||||
|
serverAvailable = null
|
||||||
|
serverCheckUrl = null
|
||||||
|
}
|
||||||
4
src/shared/tmux/types.ts
Normal file
4
src/shared/tmux/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface SpawnPaneResult {
|
||||||
|
success: boolean
|
||||||
|
paneId?: string
|
||||||
|
}
|
||||||
1
src/tools/index.ts
Normal file
1
src/tools/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||||
18
src/tools/interactive-bash/constants.ts
Normal file
18
src/tools/interactive-bash/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
export const BLOCKED_TMUX_SUBCOMMANDS = [
|
||||||
|
"capture-pane",
|
||||||
|
"capturep",
|
||||||
|
"save-buffer",
|
||||||
|
"saveb",
|
||||||
|
"show-buffer",
|
||||||
|
"showb",
|
||||||
|
"pipe-pane",
|
||||||
|
"pipep",
|
||||||
|
]
|
||||||
|
|
||||||
|
export const INTERACTIVE_BASH_DESCRIPTION = `WARNING: This is TMUX ONLY. Pass tmux subcommands directly (without 'tmux' prefix).
|
||||||
|
|
||||||
|
Examples: new-session -d -s omo-dev, send-keys -t omo-dev "vim" Enter
|
||||||
|
|
||||||
|
For TUI apps needing ongoing interaction (vim, htop, pudb). One-shot commands -> use Bash with &.`
|
||||||
4
src/tools/interactive-bash/index.ts
Normal file
4
src/tools/interactive-bash/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { interactive_bash } from "./tools"
|
||||||
|
import { startBackgroundCheck } from "./tmux-path-resolver"
|
||||||
|
|
||||||
|
export { interactive_bash, startBackgroundCheck }
|
||||||
71
src/tools/interactive-bash/tmux-path-resolver.ts
Normal file
71
src/tools/interactive-bash/tmux-path-resolver.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { spawn } from "bun"
|
||||||
|
|
||||||
|
let tmuxPath: string | null = null
|
||||||
|
let initPromise: Promise<string | null> | null = null
|
||||||
|
|
||||||
|
async function findTmuxPath(): Promise<string | null> {
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const cmd = isWindows ? "where" : "which"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = spawn([cmd, "tmux"], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitCode = await proc.exited
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = await new Response(proc.stdout).text()
|
||||||
|
const path = stdout.trim().split("\n")[0]
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyProc = spawn([path, "-V"], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const verifyExitCode = await verifyProc.exited
|
||||||
|
if (verifyExitCode !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTmuxPath(): Promise<string | null> {
|
||||||
|
if (tmuxPath !== null) {
|
||||||
|
return tmuxPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initPromise) {
|
||||||
|
return initPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
const path = await findTmuxPath()
|
||||||
|
tmuxPath = path
|
||||||
|
return path
|
||||||
|
})()
|
||||||
|
|
||||||
|
return initPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedTmuxPath(): string | null {
|
||||||
|
return tmuxPath
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBackgroundCheck(): void {
|
||||||
|
if (!initPromise) {
|
||||||
|
initPromise = getTmuxPath()
|
||||||
|
initPromise.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/tools/interactive-bash/tools.ts
Normal file
128
src/tools/interactive-bash/tools.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||||
|
import { getCachedTmuxPath } from "./tmux-path-resolver"
|
||||||
|
|
||||||
|
export function tokenizeCommand(cmd: string): string[] {
|
||||||
|
const tokens: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let inQuote = false
|
||||||
|
let quoteChar = ""
|
||||||
|
let escaped = false
|
||||||
|
|
||||||
|
for (let i = 0; i < cmd.length; i++) {
|
||||||
|
const char = cmd[i]
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
current += char
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((char === "'" || char === '"') && !inQuote) {
|
||||||
|
inQuote = true
|
||||||
|
quoteChar = char
|
||||||
|
} else if (char === quoteChar && inQuote) {
|
||||||
|
inQuote = false
|
||||||
|
quoteChar = ""
|
||||||
|
} else if (char === " " && !inQuote) {
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) tokens.push(current)
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export const interactive_bash: ToolDefinition = tool({
|
||||||
|
description: INTERACTIVE_BASH_DESCRIPTION,
|
||||||
|
args: {
|
||||||
|
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
|
||||||
|
},
|
||||||
|
execute: async (args) => {
|
||||||
|
try {
|
||||||
|
const tmuxPath = getCachedTmuxPath() ?? "tmux"
|
||||||
|
|
||||||
|
const parts = tokenizeCommand(args.tmux_command)
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "Error: Empty tmux command"
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = parts[0].toLowerCase()
|
||||||
|
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
|
||||||
|
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
|
||||||
|
let sessionName = "omo-session"
|
||||||
|
if (sessionIdx !== -1) {
|
||||||
|
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
|
||||||
|
sessionName = parts[sessionIdx + 1]
|
||||||
|
} else if (parts[sessionIdx].startsWith("-t")) {
|
||||||
|
sessionName = parts[sessionIdx].slice(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Error: '${parts[0]}' is blocked in interactive_bash.
|
||||||
|
|
||||||
|
**USE BASH TOOL INSTEAD:**
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# Capture terminal output
|
||||||
|
tmux capture-pane -p -t ${sessionName}
|
||||||
|
|
||||||
|
# Or capture with history (last 1000 lines)
|
||||||
|
tmux capture-pane -p -t ${sessionName} -S -1000
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
const timeoutError = new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`)
|
||||||
|
try {
|
||||||
|
proc.kill()
|
||||||
|
void proc.exited.catch(() => {})
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
reject(timeoutError)
|
||||||
|
}, DEFAULT_TIMEOUT_MS)
|
||||||
|
proc.exited
|
||||||
|
.then(() => clearTimeout(id))
|
||||||
|
.catch(() => clearTimeout(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.race([
|
||||||
|
Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]),
|
||||||
|
timeoutPromise,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const errorMsg = stderr.trim() || `Command failed with exit code ${exitCode}`
|
||||||
|
return `Error: ${errorMsg}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout || "(no output)"
|
||||||
|
} catch (e) {
|
||||||
|
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
0
test-setup.ts
Normal file
0
test-setup.ts
Normal file
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "dist",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "script"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user