From 064ae0f251cf06ce675691917dd23f3cde5bbb81 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Sun, 19 Apr 2026 17:53:29 -0400 Subject: [PATCH] 1 Signed-off-by: Dmytro Stanchiev --- .gitignore | 2 + bun.lock | 304 ++++++++ bunfig.toml | 2 + package.json | 66 ++ src/config/schema.ts | 26 + .../tmux-subagent/action-executor-core.ts | 82 +++ src/features/tmux-subagent/action-executor.ts | 137 ++++ src/features/tmux-subagent/cleanup.ts | 42 ++ src/features/tmux-subagent/decision-engine.ts | 22 + src/features/tmux-subagent/event-handlers.ts | 6 + src/features/tmux-subagent/grid-planning.ts | 137 ++++ src/features/tmux-subagent/index.ts | 16 + src/features/tmux-subagent/manager.ts | 697 ++++++++++++++++++ .../tmux-subagent/oldest-agent-pane.ts | 37 + .../tmux-subagent/pane-split-availability.ts | 77 ++ .../tmux-subagent/pane-state-parser.ts | 135 ++++ .../tmux-subagent/pane-state-querier.ts | 76 ++ .../tmux-subagent/polling-constants.ts | 4 + src/features/tmux-subagent/polling-manager.ts | 147 ++++ src/features/tmux-subagent/polling.ts | 183 +++++ .../tmux-subagent/session-created-event.ts | 44 ++ .../tmux-subagent/session-created-handler.ts | 175 +++++ .../tmux-subagent/session-deleted-handler.ts | 50 ++ .../tmux-subagent/session-message-count.ts | 3 + .../tmux-subagent/session-ready-waiter.ts | 44 ++ .../tmux-subagent/session-status-parser.ts | 17 + .../tmux-subagent/spawn-action-decider.ts | 147 ++++ .../tmux-subagent/spawn-target-finder.ts | 146 ++++ .../tmux-subagent/tmux-grid-constants.ts | 57 ++ .../tmux-subagent/tracked-session-state.ts | 28 + src/features/tmux-subagent/types.ts | 51 ++ src/index.ts | 46 ++ src/plugin-config.ts | 60 ++ src/shared/index.ts | 17 + src/shared/jsonc-parser.ts | 66 ++ src/shared/logger.ts | 46 ++ src/shared/normalize-sdk-response.ts | 36 + src/shared/opencode-config-dir-types.ts | 15 + src/shared/opencode-config-dir.ts | 118 +++ src/shared/shell-env.ts | 84 +++ src/shared/spawn-with-windows-hide.ts | 84 +++ src/shared/tmux/constants.ts | 5 + src/shared/tmux/index.ts | 3 + src/shared/tmux/tmux-utils.ts | 13 + src/shared/tmux/tmux-utils/environment.ts | 13 + src/shared/tmux/tmux-utils/layout.ts | 96 +++ src/shared/tmux/tmux-utils/pane-close.ts | 48 ++ src/shared/tmux/tmux-utils/pane-dimensions.ts | 28 + src/shared/tmux/tmux-utils/pane-replace.ts | 73 ++ src/shared/tmux/tmux-utils/pane-spawn.ts | 94 +++ src/shared/tmux/tmux-utils/server-health.ts | 47 ++ src/shared/tmux/types.ts | 4 + src/tools/index.ts | 1 + src/tools/interactive-bash/constants.ts | 18 + src/tools/interactive-bash/index.ts | 4 + .../interactive-bash/tmux-path-resolver.ts | 71 ++ src/tools/interactive-bash/tools.ts | 128 ++++ test-setup.ts | 0 tsconfig.json | 20 + 59 files changed, 4198 insertions(+) create mode 100644 .gitignore create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 src/config/schema.ts create mode 100644 src/features/tmux-subagent/action-executor-core.ts create mode 100644 src/features/tmux-subagent/action-executor.ts create mode 100644 src/features/tmux-subagent/cleanup.ts create mode 100644 src/features/tmux-subagent/decision-engine.ts create mode 100644 src/features/tmux-subagent/event-handlers.ts create mode 100644 src/features/tmux-subagent/grid-planning.ts create mode 100644 src/features/tmux-subagent/index.ts create mode 100644 src/features/tmux-subagent/manager.ts create mode 100644 src/features/tmux-subagent/oldest-agent-pane.ts create mode 100644 src/features/tmux-subagent/pane-split-availability.ts create mode 100644 src/features/tmux-subagent/pane-state-parser.ts create mode 100644 src/features/tmux-subagent/pane-state-querier.ts create mode 100644 src/features/tmux-subagent/polling-constants.ts create mode 100644 src/features/tmux-subagent/polling-manager.ts create mode 100644 src/features/tmux-subagent/polling.ts create mode 100644 src/features/tmux-subagent/session-created-event.ts create mode 100644 src/features/tmux-subagent/session-created-handler.ts create mode 100644 src/features/tmux-subagent/session-deleted-handler.ts create mode 100644 src/features/tmux-subagent/session-message-count.ts create mode 100644 src/features/tmux-subagent/session-ready-waiter.ts create mode 100644 src/features/tmux-subagent/session-status-parser.ts create mode 100644 src/features/tmux-subagent/spawn-action-decider.ts create mode 100644 src/features/tmux-subagent/spawn-target-finder.ts create mode 100644 src/features/tmux-subagent/tmux-grid-constants.ts create mode 100644 src/features/tmux-subagent/tracked-session-state.ts create mode 100644 src/features/tmux-subagent/types.ts create mode 100644 src/index.ts create mode 100644 src/plugin-config.ts create mode 100644 src/shared/index.ts create mode 100644 src/shared/jsonc-parser.ts create mode 100644 src/shared/logger.ts create mode 100644 src/shared/normalize-sdk-response.ts create mode 100644 src/shared/opencode-config-dir-types.ts create mode 100644 src/shared/opencode-config-dir.ts create mode 100644 src/shared/shell-env.ts create mode 100644 src/shared/spawn-with-windows-hide.ts create mode 100644 src/shared/tmux/constants.ts create mode 100644 src/shared/tmux/index.ts create mode 100644 src/shared/tmux/tmux-utils.ts create mode 100644 src/shared/tmux/tmux-utils/environment.ts create mode 100644 src/shared/tmux/tmux-utils/layout.ts create mode 100644 src/shared/tmux/tmux-utils/pane-close.ts create mode 100644 src/shared/tmux/tmux-utils/pane-dimensions.ts create mode 100644 src/shared/tmux/tmux-utils/pane-replace.ts create mode 100644 src/shared/tmux/tmux-utils/pane-spawn.ts create mode 100644 src/shared/tmux/tmux-utils/server-health.ts create mode 100644 src/shared/tmux/types.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/interactive-bash/constants.ts create mode 100644 src/tools/interactive-bash/index.ts create mode 100644 src/tools/interactive-bash/tmux-path-resolver.ts create mode 100644 src/tools/interactive-bash/tools.ts create mode 100644 test-setup.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0f1a3aa --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..9e75dd2 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test-setup.ts"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..311127b --- /dev/null +++ b/package.json @@ -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" + ] +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..cc40dc8 --- /dev/null +++ b/src/config/schema.ts @@ -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 +export type TmuxLayout = z.infer + +export const PluginConfigSchema = z.object({ + tmux: TmuxConfigSchema.partial().optional(), +}) + +export type TmuxUtilsConfig = z.infer diff --git a/src/features/tmux-subagent/action-executor-core.ts b/src/features/tmux-subagent/action-executor-core.ts new file mode 100644 index 0000000..70a8f46 --- /dev/null +++ b/src/features/tmux-subagent/action-executor-core.ts @@ -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 { + 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 { + 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, + } +} diff --git a/src/features/tmux-subagent/action-executor.ts b/src/features/tmux-subagent/action-executor.ts new file mode 100644 index 0000000..9bca876 --- /dev/null +++ b/src/features/tmux-subagent/action-executor.ts @@ -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 { + 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 { + 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 { + 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 { + 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 } +} diff --git a/src/features/tmux-subagent/cleanup.ts b/src/features/tmux-subagent/cleanup.ts new file mode 100644 index 0000000..414ad00 --- /dev/null +++ b/src/features/tmux-subagent/cleanup.ts @@ -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 + stopPolling: () => void +}): Promise { + 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") +} diff --git a/src/features/tmux-subagent/decision-engine.ts b/src/features/tmux-subagent/decision-engine.ts new file mode 100644 index 0000000..c820468 --- /dev/null +++ b/src/features/tmux-subagent/decision-engine.ts @@ -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" diff --git a/src/features/tmux-subagent/event-handlers.ts b/src/features/tmux-subagent/event-handlers.ts new file mode 100644 index 0000000..0991d10 --- /dev/null +++ b/src/features/tmux-subagent/event-handlers.ts @@ -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" diff --git a/src/features/tmux-subagent/grid-planning.ts b/src/features/tmux-subagent/grid-planning.ts new file mode 100644 index 0000000..e4ab295 --- /dev/null +++ b/src/features/tmux-subagent/grid-planning.ts @@ -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 } +} diff --git a/src/features/tmux-subagent/index.ts b/src/features/tmux-subagent/index.ts new file mode 100644 index 0000000..e900555 --- /dev/null +++ b/src/features/tmux-subagent/index.ts @@ -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" diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts new file mode 100644 index 0000000..5db6a6d --- /dev/null +++ b/src/features/tmux-subagent/manager.ts @@ -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() + private pendingSessions = new Set() + private spawnQueue: Promise = Promise.resolve() + private deferredSessions = new Map() + private deferredQueue: string[] = [] + private deferredAttachInterval?: ReturnType + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + + 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 { + 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): Promise { + 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 { + 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 { + 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 { + return async (input) => { + await this.onSessionCreated(input.event as SessionCreatedEvent) + } + } + + async cleanup(): Promise { + 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") + } +} diff --git a/src/features/tmux-subagent/oldest-agent-pane.ts b/src/features/tmux-subagent/oldest-agent-pane.ts new file mode 100644 index 0000000..e48ba01 --- /dev/null +++ b/src/features/tmux-subagent/oldest-agent-pane.ts @@ -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() + 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 + }) +} diff --git a/src/features/tmux-subagent/pane-split-availability.ts b/src/features/tmux-subagent/pane-split-availability.ts new file mode 100644 index 0000000..174335c --- /dev/null +++ b/src/features/tmux-subagent/pane-split-availability.ts @@ -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" +} diff --git a/src/features/tmux-subagent/pane-state-parser.ts b/src/features/tmux-subagent/pane-state-parser.ts new file mode 100644 index 0000000..3ae6579 --- /dev/null +++ b/src/features/tmux-subagent/pane-state-parser.ts @@ -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 +} diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts new file mode 100644 index 0000000..fe82f0b --- /dev/null +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -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 { + 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((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 } +} diff --git a/src/features/tmux-subagent/polling-constants.ts b/src/features/tmux-subagent/polling-constants.ts new file mode 100644 index 0000000..acde672 --- /dev/null +++ b/src/features/tmux-subagent/polling-constants.ts @@ -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 diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts new file mode 100644 index 0000000..912a4d1 --- /dev/null +++ b/src/features/tmux-subagent/polling-manager.ts @@ -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 + private pollingInFlight = false + + constructor( + private client: OpencodeClient, + private sessions: Map, + private closeSessionById: (sessionId: string) => Promise + ) {} + + 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 { + 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) + + 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) + 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 + } + } +} diff --git a/src/features/tmux-subagent/polling.ts b/src/features/tmux-subagent/polling.ts new file mode 100644 index 0000000..a438be4 --- /dev/null +++ b/src/features/tmux-subagent/polling.ts @@ -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 + waitForSessionReady: (sessionId: string) => Promise + pollSessions: () => Promise +} + +export function createSessionPollingController(params: { + client: OpencodeClient + tmuxConfig: TmuxConfig + serverUrl: string + sourcePaneId: string | undefined + sessions: Map +}): SessionPollingController { + let pollInterval: ReturnType | undefined + + async function closeSessionById(sessionId: string): Promise { + 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 { + 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 { + return waitForSessionReadyFromClient({ client: params.client, sessionId }) + } + + return { startPolling, stopPolling, closeSessionById, waitForSessionReady, pollSessions } +} diff --git a/src/features/tmux-subagent/session-created-event.ts b/src/features/tmux-subagent/session-created-event.ts new file mode 100644 index 0000000..53440a2 --- /dev/null +++ b/src/features/tmux-subagent/session-created-event.ts @@ -0,0 +1,44 @@ +type UnknownRecord = Record + +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, + } +} diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts new file mode 100644 index 0000000..6dd1f21 --- /dev/null +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -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 + pendingSessions: Set + isInsideTmux: () => boolean + isEnabled: () => boolean + getCapacityConfig: () => CapacityConfig + getSessionMappings: () => SessionMapping[] + waitForSessionReady: (sessionId: string) => Promise + startPolling: () => void +} + +export async function handleSessionCreated( + deps: SessionCreatedHandlerDeps, + event: SessionCreatedEvent, +): Promise { + 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) + } +} diff --git a/src/features/tmux-subagent/session-deleted-handler.ts b/src/features/tmux-subagent/session-deleted-handler.ts new file mode 100644 index 0000000..f832cf4 --- /dev/null +++ b/src/features/tmux-subagent/session-deleted-handler.ts @@ -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 + isEnabled: () => boolean + getSessionMappings: () => SessionMapping[] + stopPolling: () => void +} + +export async function handleSessionDeleted( + deps: SessionDeletedHandlerDeps, + event: { sessionID: string }, +): Promise { + 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() + } +} diff --git a/src/features/tmux-subagent/session-message-count.ts b/src/features/tmux-subagent/session-message-count.ts new file mode 100644 index 0000000..a634208 --- /dev/null +++ b/src/features/tmux-subagent/session-message-count.ts @@ -0,0 +1,3 @@ +export function getMessageCount(data: unknown): number { + return Array.isArray(data) ? data.length : 0 +} diff --git a/src/features/tmux-subagent/session-ready-waiter.ts b/src/features/tmux-subagent/session-ready-waiter.ts new file mode 100644 index 0000000..d98757c --- /dev/null +++ b/src/features/tmux-subagent/session-ready-waiter.ts @@ -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 { + 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((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 +} diff --git a/src/features/tmux-subagent/session-status-parser.ts b/src/features/tmux-subagent/session-status-parser.ts new file mode 100644 index 0000000..2952d55 --- /dev/null +++ b/src/features/tmux-subagent/session-status-parser.ts @@ -0,0 +1,17 @@ +type SessionStatus = { type: string } + +export function parseSessionStatusMap(data: unknown): Record { + if (typeof data !== "object" || data === null) return {} + const record = data as Record + + const result: Record = {} + for (const [sessionId, value] of Object.entries(record)) { + if (typeof value !== "object" || value === null) continue + const valueRecord = value as Record + const type = valueRecord.type + if (typeof type !== "string") continue + result[sessionId] = { type } + } + + return result +} diff --git a/src/features/tmux-subagent/spawn-action-decider.ts b/src/features/tmux-subagent/spawn-action-decider.ts new file mode 100644 index 0000000..ac61146 --- /dev/null +++ b/src/features/tmux-subagent/spawn-action-decider.ts @@ -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 } +} diff --git a/src/features/tmux-subagent/spawn-target-finder.ts b/src/features/tmux-subagent/spawn-target-finder.ts new file mode 100644 index 0000000..f89b3f2 --- /dev/null +++ b/src/features/tmux-subagent/spawn-target-finder.ts @@ -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, + mainPaneWidth: number, +): Map { + const occupancy = new Map() + for (const pane of agentPanes) { + const slot = mapPaneToSlot(pane, plan, mainPaneWidth) + occupancy.set(`${slot.row}:${slot.col}`, pane) + } + return occupancy +} + +function findFirstEmptySlot( + occupancy: Map, + plan: ReturnType, +): { 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) +} diff --git a/src/features/tmux-subagent/tmux-grid-constants.ts b/src/features/tmux-subagent/tmux-grid-constants.ts new file mode 100644 index 0000000..7fe9291 --- /dev/null +++ b/src/features/tmux-subagent/tmux-grid-constants.ts @@ -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) +} diff --git a/src/features/tmux-subagent/tracked-session-state.ts b/src/features/tmux-subagent/tracked-session-state.ts new file mode 100644 index 0000000..87ba19f --- /dev/null +++ b/src/features/tmux-subagent/tracked-session-state.ts @@ -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, + } +} diff --git a/src/features/tmux-subagent/types.ts b/src/features/tmux-subagent/types.ts new file mode 100644 index 0000000..fa165e0 --- /dev/null +++ b/src/features/tmux-subagent/types.ts @@ -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 +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..069fd60 --- /dev/null +++ b/src/index.ts @@ -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 | 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 diff --git a/src/plugin-config.ts b/src/plugin-config.ts new file mode 100644 index 0000000..fd99b96 --- /dev/null +++ b/src/plugin-config.ts @@ -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>(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 +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..57560cc --- /dev/null +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/jsonc-parser.ts b/src/shared/jsonc-parser.ts new file mode 100644 index 0000000..ed8b9f1 --- /dev/null +++ b/src/shared/jsonc-parser.ts @@ -0,0 +1,66 @@ +import { existsSync, readFileSync } from "node:fs" +import { parse, printParseErrorCode, type ParseError } from "jsonc-parser" + +export interface JsoncParseResult { + data: T | null + errors: Array<{ message: string; offset: number; length: number }> +} + +export function parseJsonc(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(content: string): JsoncParseResult { + 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(filePath: string): T | null { + try { + const content = readFileSync(filePath, "utf-8") + return parseJsonc(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 } +} diff --git a/src/shared/logger.ts b/src/shared/logger.ts new file mode 100644 index 0000000..b67f462 --- /dev/null +++ b/src/shared/logger.ts @@ -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 | 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 +} diff --git a/src/shared/normalize-sdk-response.ts b/src/shared/normalize-sdk-response.ts new file mode 100644 index 0000000..8e69de4 --- /dev/null +++ b/src/shared/normalize-sdk-response.ts @@ -0,0 +1,36 @@ +export interface NormalizeSDKResponseOptions { + preferResponseOnMissingData?: boolean +} + +export function normalizeSDKResponse( + 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 +} diff --git a/src/shared/opencode-config-dir-types.ts b/src/shared/opencode-config-dir-types.ts new file mode 100644 index 0000000..bf2f222 --- /dev/null +++ b/src/shared/opencode-config-dir-types.ts @@ -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 +} diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts new file mode 100644 index 0000000..d403358 --- /dev/null +++ b/src/shared/opencode-config-dir.ts @@ -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 +} diff --git a/src/shared/shell-env.ts b/src/shared/shell-env.ts new file mode 100644 index 0000000..1b4c873 --- /dev/null +++ b/src/shared/shell-env.ts @@ -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, 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, "\\)") +} diff --git a/src/shared/spawn-with-windows-hide.ts b/src/shared/spawn-with-windows-hide.ts new file mode 100644 index 0000000..7da9ed0 --- /dev/null +++ b/src/shared/spawn-with-windows-hide.ts @@ -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 + stdin?: "pipe" | "inherit" | "ignore" + stdout?: "pipe" | "inherit" | "ignore" + stderr?: "pipe" | "inherit" | "ignore" +} + +export interface SpawnedProcess { + readonly exitCode: number | null + readonly exited: Promise + readonly stdout: ReadableStream | undefined + readonly stderr: ReadableStream | undefined + kill(signal?: NodeJS.Signals): void +} + +function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream | undefined { + if (!stream) { + return undefined + } + + return Readable.toWeb(stream as Readable) as ReadableStream +} + +function wrapNodeProcess(proc: ChildProcess): SpawnedProcess { + let resolveExited: (exitCode: number) => void + let exitCode: number | null = null + + const exited = new Promise((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) +} diff --git a/src/shared/tmux/constants.ts b/src/shared/tmux/constants.ts new file mode 100644 index 0000000..c644246 --- /dev/null +++ b/src/shared/tmux/constants.ts @@ -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 diff --git a/src/shared/tmux/index.ts b/src/shared/tmux/index.ts new file mode 100644 index 0000000..a22e558 --- /dev/null +++ b/src/shared/tmux/index.ts @@ -0,0 +1,3 @@ +export * from "./tmux-utils" +export * from "./constants" +export * from "./types" diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts new file mode 100644 index 0000000..df4d417 --- /dev/null +++ b/src/shared/tmux/tmux-utils.ts @@ -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" diff --git a/src/shared/tmux/tmux-utils/environment.ts b/src/shared/tmux/tmux-utils/environment.ts new file mode 100644 index 0000000..ed875ed --- /dev/null +++ b/src/shared/tmux/tmux-utils/environment.ts @@ -0,0 +1,13 @@ +export type SplitDirection = "-h" | "-v" + +export function isInsideTmuxEnvironment(environment: Record): boolean { + return Boolean(environment.TMUX) +} + +export function isInsideTmux(): boolean { + return isInsideTmuxEnvironment(process.env) +} + +export function getCurrentPaneId(): string | undefined { + return process.env.TMUX_PANE +} diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts new file mode 100644 index 0000000..5ac82ee --- /dev/null +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -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 } + +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 { + 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 { + 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, + }) +} diff --git a/src/shared/tmux/tmux-utils/pane-close.ts b/src/shared/tmux/tmux-utils/pane-close.ts new file mode 100644 index 0000000..cc6f4b6 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -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 { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function closeTmuxPane(paneId: string): Promise { + 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 +} diff --git a/src/shared/tmux/tmux-utils/pane-dimensions.ts b/src/shared/tmux/tmux-utils/pane-dimensions.ts new file mode 100644 index 0000000..a11ad26 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-dimensions.ts @@ -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 { + 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 } +} diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts new file mode 100644 index 0000000..271ad79 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -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 { + 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 } +} diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts new file mode 100644 index 0000000..2713eaf --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -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 { + 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 } +} diff --git a/src/shared/tmux/tmux-utils/server-health.ts b/src/shared/tmux/tmux-utils/server-health.ts new file mode 100644 index 0000000..d9e3aee --- /dev/null +++ b/src/shared/tmux/tmux-utils/server-health.ts @@ -0,0 +1,47 @@ +let serverAvailable: boolean | null = null +let serverCheckUrl: string | null = null + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function isServerRunning(serverUrl: string): Promise { + 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 +} diff --git a/src/shared/tmux/types.ts b/src/shared/tmux/types.ts new file mode 100644 index 0000000..1c3510c --- /dev/null +++ b/src/shared/tmux/types.ts @@ -0,0 +1,4 @@ +export interface SpawnPaneResult { + success: boolean + paneId?: string +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..b16d2f8 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1 @@ +export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash" diff --git a/src/tools/interactive-bash/constants.ts b/src/tools/interactive-bash/constants.ts new file mode 100644 index 0000000..8edef6c --- /dev/null +++ b/src/tools/interactive-bash/constants.ts @@ -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 &.` diff --git a/src/tools/interactive-bash/index.ts b/src/tools/interactive-bash/index.ts new file mode 100644 index 0000000..57b4e4f --- /dev/null +++ b/src/tools/interactive-bash/index.ts @@ -0,0 +1,4 @@ +import { interactive_bash } from "./tools" +import { startBackgroundCheck } from "./tmux-path-resolver" + +export { interactive_bash, startBackgroundCheck } diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts new file mode 100644 index 0000000..1aa3462 --- /dev/null +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -0,0 +1,71 @@ +import { spawn } from "bun" + +let tmuxPath: string | null = null +let initPromise: Promise | null = null + +async function findTmuxPath(): Promise { + 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 { + 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(() => {}) + } +} diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts new file mode 100644 index 0000000..8e445fc --- /dev/null +++ b/src/tools/interactive-bash/tools.ts @@ -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((_, 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)}` + } + }, +}) diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7964411 --- /dev/null +++ b/tsconfig.json @@ -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"] +}