diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd3614d..8a358a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ ## Skills -A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform a NanoClaw installation. +A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform an EJClaw installation. A PR that contributes a skill should not modify any source files. diff --git a/launchd/com.nanoclaw-codex.plist b/launchd/com.ejclaw-codex.plist similarity index 87% rename from launchd/com.nanoclaw-codex.plist rename to launchd/com.ejclaw-codex.plist index 8abc9a7..855828e 100644 --- a/launchd/com.nanoclaw-codex.plist +++ b/launchd/com.ejclaw-codex.plist @@ -3,7 +3,7 @@ Label - com.nanoclaw-codex + com.ejclaw-codex ProgramArguments {{NODE_PATH}} @@ -31,8 +31,8 @@ {{PROJECT_ROOT}}/data-codex StandardOutPath - {{PROJECT_ROOT}}/logs/nanoclaw-codex.log + {{PROJECT_ROOT}}/logs/ejclaw-codex.log StandardErrorPath - {{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log + {{PROJECT_ROOT}}/logs/ejclaw-codex.error.log diff --git a/launchd/com.nanoclaw.plist b/launchd/com.ejclaw.plist similarity index 84% rename from launchd/com.nanoclaw.plist rename to launchd/com.ejclaw.plist index 82bef0a..9625b6c 100644 --- a/launchd/com.nanoclaw.plist +++ b/launchd/com.ejclaw.plist @@ -3,7 +3,7 @@ Label - com.nanoclaw + com.ejclaw ProgramArguments {{NODE_PATH}} @@ -25,8 +25,8 @@ Andy StandardOutPath - {{PROJECT_ROOT}}/logs/nanoclaw.log + {{PROJECT_ROOT}}/logs/ejclaw.log StandardErrorPath - {{PROJECT_ROOT}}/logs/nanoclaw.error.log + {{PROJECT_ROOT}}/logs/ejclaw.error.log diff --git a/package-lock.json b/package-lock.json index 14be4ac..9bcbe11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "nanoclaw", + "name": "ejclaw", "version": "1.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nanoclaw", + "name": "ejclaw", "version": "1.2.12", "dependencies": { "better-sqlite3": "^11.8.1", @@ -1982,6 +1982,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2548,6 +2549,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2615,6 +2617,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2690,6 +2693,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2811,6 +2815,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/runners/agent-runner/package-lock.json b/runners/agent-runner/package-lock.json index 89cee2c..15fc530 100644 --- a/runners/agent-runner/package-lock.json +++ b/runners/agent-runner/package-lock.json @@ -1,14 +1,14 @@ { - "name": "nanoclaw-agent-runner", + "name": "ejclaw-agent-runner", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nanoclaw-agent-runner", + "name": "ejclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -19,9 +19,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.68", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz", - "integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==", + "version": "0.2.76", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", + "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" diff --git a/runners/agent-runner/package.json b/runners/agent-runner/package.json index bf13328..1154b6e 100644 --- a/runners/agent-runner/package.json +++ b/runners/agent-runner/package.json @@ -1,15 +1,15 @@ { - "name": "nanoclaw-agent-runner", + "name": "ejclaw-agent-runner", "version": "1.0.0", "type": "module", - "description": "Container-side agent runner for NanoClaw", + "description": "Container-side agent runner for EJClaw", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" diff --git a/runners/agent-runner/pnpm-lock.yaml b/runners/agent-runner/pnpm-lock.yaml new file mode 100644 index 0000000..a5dcc26 --- /dev/null +++ b/runners/agent-runner/pnpm-lock.yaml @@ -0,0 +1,994 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.76 + version: 0.2.76(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.27.1(zod@4.3.6) + cron-parser: + specifier: ^5.0.0 + version: 5.5.0 + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.10.7 + version: 22.19.15 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + +packages: + + '@anthropic-ai/claude-agent-sdk@0.2.76': + resolution: {integrity: sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk@0.2.76(zod@4.3.6)': + dependencies: + zod: 4.3.6 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + '@hono/node-server@1.19.11(hono@4.12.8)': + dependencies: + hono: 4.12.8 + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.8) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.8 + jose: 6.2.1 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + 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 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.8: {} + + 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 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + luxon@3.7.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.3.0: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + 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 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 7ebcf6a..4e52dcd 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -556,22 +556,32 @@ async function runQuery( if (isError) { // Log full error details for debugging const msg = message as Record; + const sdkErrors = Array.isArray(msg.errors) ? msg.errors as string[] : []; const errorDetail = JSON.stringify({ subtype: message.subtype, result: textResult?.slice(0, 500), - errors: msg.errors, + errors: sdkErrors, stop_reason: msg.stop_reason, duration_ms: msg.duration_ms, duration_api_ms: msg.duration_api_ms, session_id: msg.session_id, }); log(`Error result detail: ${errorDetail}`); + // Pass SDK errors through so host can detect session issues + const errorText = sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined; + writeOutput({ + status: 'error', + result: textResult || null, + newSessionId, + error: errorText || `Agent error: ${message.subtype}`, + }); + } else { + writeOutput({ + status: 'success', + result: textResult || null, + newSessionId + }); } - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId - }); } } diff --git a/runners/codex-runner/pnpm-lock.yaml b/runners/codex-runner/pnpm-lock.yaml new file mode 100644 index 0000000..e397a43 --- /dev/null +++ b/runners/codex-runner/pnpm-lock.yaml @@ -0,0 +1,119 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@openai/codex-sdk': + specifier: ^0.115.0 + version: 0.115.0 + devDependencies: + '@types/node': + specifier: ^22.10.7 + version: 22.19.15 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + +packages: + + '@openai/codex-sdk@0.115.0': + resolution: {integrity: sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==} + engines: {node: '>=18'} + + '@openai/codex@0.115.0': + resolution: {integrity: sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.115.0-darwin-arm64': + resolution: {integrity: sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.115.0-darwin-x64': + resolution: {integrity: sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.115.0-linux-arm64': + resolution: {integrity: sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.115.0-linux-x64': + resolution: {integrity: sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.115.0-win32-arm64': + resolution: {integrity: sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.115.0-win32-x64': + resolution: {integrity: sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@openai/codex-sdk@0.115.0': + dependencies: + '@openai/codex': 0.115.0 + + '@openai/codex@0.115.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.115.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.115.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.115.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.115.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.115.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.115.0-win32-x64' + + '@openai/codex@0.115.0-darwin-arm64': + optional: true + + '@openai/codex@0.115.0-darwin-x64': + optional: true + + '@openai/codex@0.115.0-linux-arm64': + optional: true + + '@openai/codex@0.115.0-linux-x64': + optional: true + + '@openai/codex@0.115.0-win32-arm64': + optional: true + + '@openai/codex@0.115.0-win32-x64': + optional: true + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/runners/codex-runner/src/app-server-state.ts b/runners/codex-runner/src/app-server-state.ts index 69a5f35..97c0b86 100644 --- a/runners/codex-runner/src/app-server-state.ts +++ b/runners/codex-runner/src/app-server-state.ts @@ -170,5 +170,5 @@ export function isAppServerTurnFinished(state: AppServerTurnState): boolean { export function getAppServerTurnResult( state: AppServerTurnState, ): string | null { - return state.finalAnswer || state.latestAgentMessage; + return state.finalAnswer; } diff --git a/runners/codex-runner/test/app-server-state.test.ts b/runners/codex-runner/test/app-server-state.test.ts new file mode 100644 index 0000000..87462c7 --- /dev/null +++ b/runners/codex-runner/test/app-server-state.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { + createInitialAppServerTurnState, + getAppServerTurnResult, + reduceAppServerTurnState, +} from '../src/app-server-state.js'; + +describe('codex app-server turn state', () => { + it('prefers final_answer over commentary when building final output', () => { + let state = createInitialAppServerTurnState(); + + state = reduceAppServerTurnState(state, { + method: 'item/completed', + params: { + item: { + type: 'agentMessage', + phase: 'commentary', + text: '중간 설명입니다.', + }, + }, + }); + + state = reduceAppServerTurnState(state, { + method: 'item/completed', + params: { + item: { + type: 'agentMessage', + phase: 'final_answer', + text: '최종 답변입니다.', + }, + }, + }); + + expect(getAppServerTurnResult(state)).toBe('최종 답변입니다.'); + }); + + it('does not treat commentary-only messages as a final output', () => { + const state = reduceAppServerTurnState(createInitialAppServerTurnState(), { + method: 'item/completed', + params: { + item: { + type: 'agentMessage', + phase: 'commentary', + text: '작업 중 상태입니다.', + }, + }, + }); + + expect(state.latestAgentMessage).toBe('작업 중 상태입니다.'); + expect(getAppServerTurnResult(state)).toBeNull(); + }); + + it('records compaction completion from contextCompaction items', () => { + const state = reduceAppServerTurnState(createInitialAppServerTurnState(), { + method: 'item/completed', + params: { + item: { + type: 'contextCompaction', + id: 'ctx_1', + }, + }, + }); + + expect(state.compactionCompleted).toBe(true); + }); + + it('captures upstream HTTP status from app-server error events', () => { + const state = reduceAppServerTurnState(createInitialAppServerTurnState(), { + method: 'error', + params: { + error: { + message: 'Too many requests', + codexErrorInfo: { + httpStatusCode: 429, + }, + }, + }, + }); + + expect(state.errorMessage).toBe('Too many requests (HTTP 429)'); + }); +}); diff --git a/setup/platform.ts b/setup/platform.ts index 5544eac..9f84ed9 100644 --- a/setup/platform.ts +++ b/setup/platform.ts @@ -1,5 +1,5 @@ /** - * Cross-platform detection utilities for NanoClaw setup. + * Cross-platform detection utilities for EJClaw setup. */ import { execSync } from 'child_process'; import fs from 'fs'; diff --git a/setup/register.test.ts b/setup/register.test.ts index 256f10e..6d0058f 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -12,14 +12,18 @@ import Database from 'better-sqlite3'; function createTestDb(): Database.Database { const db = new Database(':memory:'); db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, + jid TEXT NOT NULL, name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, + folder TEXT NOT NULL, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, agent_config TEXT, requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0 + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) )`); return db; } diff --git a/setup/service.test.ts b/setup/service.test.ts index eb15db8..3458be0 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -19,7 +19,7 @@ function generatePlist( Label - com.nanoclaw + com.ejclaw ProgramArguments ${nodePath} @@ -39,9 +39,9 @@ function generatePlist( ${homeDir} StandardOutPath - ${projectRoot}/logs/nanoclaw.log + ${projectRoot}/logs/ejclaw.log StandardErrorPath - ${projectRoot}/logs/nanoclaw.error.log + ${projectRoot}/logs/ejclaw.error.log `; } @@ -53,7 +53,7 @@ function generateSystemdUnit( isSystem: boolean, ): string { return `[Unit] -Description=NanoClaw Personal Assistant +Description=EJClaw Personal Assistant After=network.target [Service] @@ -64,8 +64,8 @@ Restart=always RestartSec=5 Environment=HOME=${homeDir} Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin -StandardOutput=append:${projectRoot}/logs/nanoclaw.log -StandardError=append:${projectRoot}/logs/nanoclaw.error.log +StandardOutput=append:${projectRoot}/logs/ejclaw.log +StandardError=append:${projectRoot}/logs/ejclaw.error.log [Install] WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`; @@ -75,16 +75,16 @@ describe('plist generation', () => { it('contains the correct label', () => { const plist = generatePlist( '/usr/local/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); - expect(plist).toContain('com.nanoclaw'); + expect(plist).toContain('com.ejclaw'); }); it('uses the correct node path', () => { const plist = generatePlist( '/opt/node/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); expect(plist).toContain('/opt/node/bin/node'); @@ -93,20 +93,20 @@ describe('plist generation', () => { it('points to dist/index.js', () => { const plist = generatePlist( '/usr/local/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); - expect(plist).toContain('/home/user/nanoclaw/dist/index.js'); + expect(plist).toContain('/home/user/ejclaw/dist/index.js'); }); it('sets log paths', () => { const plist = generatePlist( '/usr/local/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', ); - expect(plist).toContain('nanoclaw.log'); - expect(plist).toContain('nanoclaw.error.log'); + expect(plist).toContain('ejclaw.log'); + expect(plist).toContain('ejclaw.error.log'); }); }); @@ -114,7 +114,7 @@ describe('systemd unit generation', () => { it('user unit uses default.target', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', false, ); @@ -124,7 +124,7 @@ describe('systemd unit generation', () => { it('system unit uses multi-user.target', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', true, ); @@ -134,7 +134,7 @@ describe('systemd unit generation', () => { it('contains restart policy', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/home/user/nanoclaw', + '/home/user/ejclaw', '/home/user', false, ); @@ -145,32 +145,32 @@ describe('systemd unit generation', () => { it('sets correct ExecStart', () => { const unit = generateSystemdUnit( '/usr/bin/node', - '/srv/nanoclaw', + '/srv/ejclaw', '/home/user', false, ); expect(unit).toContain( - 'ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js', + 'ExecStart=/usr/bin/node /srv/ejclaw/dist/index.js', ); }); }); describe('WSL nohup fallback', () => { it('generates a valid wrapper script', () => { - const projectRoot = '/home/user/nanoclaw'; + const projectRoot = '/home/user/ejclaw'; const nodePath = '/usr/bin/node'; - const pidFile = path.join(projectRoot, 'nanoclaw.pid'); + const pidFile = path.join(projectRoot, 'ejclaw.pid'); // Simulate what service.ts generates const wrapper = `#!/bin/bash set -euo pipefail cd ${JSON.stringify(projectRoot)} -nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log & +nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/ejclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/ejclaw.error.log & echo $! > ${JSON.stringify(pidFile)}`; expect(wrapper).toContain('#!/bin/bash'); expect(wrapper).toContain('nohup'); expect(wrapper).toContain(nodePath); - expect(wrapper).toContain('nanoclaw.pid'); + expect(wrapper).toContain('ejclaw.pid'); }); }); diff --git a/setup/service.ts b/setup/service.ts index ffdeb9f..48cd862 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -20,6 +20,9 @@ import { } from './platform.js'; import { emitStatus } from './status.js'; +const EJCLAW_SERVICE_NAME = 'ejclaw'; +const EJCLAW_LAUNCHD_LABEL = 'com.ejclaw'; + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); @@ -77,7 +80,7 @@ function setupLaunchd( homeDir, 'Library', 'LaunchAgents', - 'com.nanoclaw.plist', + `${EJCLAW_LAUNCHD_LABEL}.plist`, ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); @@ -86,7 +89,7 @@ function setupLaunchd( Label - com.nanoclaw + ${EJCLAW_LAUNCHD_LABEL} ProgramArguments ${nodePath} @@ -106,9 +109,9 @@ function setupLaunchd( ${homeDir} StandardOutPath - ${projectRoot}/logs/nanoclaw.log + ${projectRoot}/logs/ejclaw.log StandardErrorPath - ${projectRoot}/logs/nanoclaw.error.log + ${projectRoot}/logs/ejclaw.error.log `; @@ -128,7 +131,7 @@ function setupLaunchd( let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - serviceLoaded = output.includes('com.nanoclaw'); + serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL); } catch { // launchctl list failed } @@ -160,7 +163,7 @@ function setupLinux( } /** - * Kill any orphaned nanoclaw node processes left from previous runs or debugging. + * Kill any orphaned ejclaw node processes left from previous runs or debugging. * Prevents connection conflicts when two instances connect to the same channel simultaneously. */ function killOrphanedProcesses(projectRoot: string): void { @@ -168,7 +171,7 @@ function killOrphanedProcesses(projectRoot: string): void { execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, { stdio: 'ignore', }); - logger.info('Stopped any orphaned nanoclaw processes'); + logger.info('Stopped any orphaned ejclaw processes'); } catch { // pkill not available or no orphans } @@ -186,7 +189,7 @@ function setupSystemd( let systemctlPrefix: string; if (runningAsRoot) { - unitPath = '/etc/systemd/system/nanoclaw.service'; + unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`; systemctlPrefix = 'systemctl'; logger.info('Running as root — installing system-level systemd unit'); } else { @@ -202,12 +205,12 @@ function setupSystemd( } const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); fs.mkdirSync(unitDir, { recursive: true }); - unitPath = path.join(unitDir, 'nanoclaw.service'); + unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`); systemctlPrefix = 'systemctl --user'; } const unit = `[Unit] -Description=NanoClaw Personal Assistant +Description=EJClaw Personal Assistant After=network.target [Service] @@ -218,8 +221,8 @@ Restart=always RestartSec=5 Environment=HOME=${homeDir} Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin -StandardOutput=append:${projectRoot}/logs/nanoclaw.log -StandardError=append:${projectRoot}/logs/nanoclaw.error.log +StandardOutput=append:${projectRoot}/logs/ejclaw.log +StandardError=append:${projectRoot}/logs/ejclaw.error.log [Install] WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; @@ -227,7 +230,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); logger.info({ unitPath }, 'Wrote systemd unit'); - // Kill orphaned nanoclaw processes to avoid channel connection conflicts + // Kill orphaned ejclaw processes to avoid channel connection conflicts killOrphanedProcesses(projectRoot); // Enable and start @@ -238,13 +241,17 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; } try { - execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, { + stdio: 'ignore', + }); } catch (err) { logger.error({ err }, 'systemctl enable failed'); } try { - execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, { + stdio: 'ignore', + }); } catch (err) { logger.error({ err }, 'systemctl start failed'); } @@ -252,7 +259,9 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // Verify let serviceLoaded = false; try { - execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, { + stdio: 'ignore', + }); serviceLoaded = true; } catch { // Not active @@ -276,12 +285,12 @@ function setupNohupFallback( ): void { logger.warn('No systemd detected — generating nohup wrapper script'); - const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh'); - const pidFile = path.join(projectRoot, 'nanoclaw.pid'); + const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh'); + const pidFile = path.join(projectRoot, 'ejclaw.pid'); const lines = [ '#!/bin/bash', - '# start-nanoclaw.sh — Start NanoClaw without systemd', + '# start-ejclaw.sh — Start EJClaw without systemd', `# To stop: kill \\$(cat ${pidFile})`, '', 'set -euo pipefail', @@ -292,20 +301,20 @@ function setupNohupFallback( `if [ -f ${JSON.stringify(pidFile)} ]; then`, ` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`, ' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then', - ' echo "Stopping existing NanoClaw (PID $OLD_PID)..."', + ' echo "Stopping existing EJClaw (PID $OLD_PID)..."', ' kill "$OLD_PID" 2>/dev/null || true', ' sleep 2', ' fi', 'fi', '', - 'echo "Starting NanoClaw..."', + 'echo "Starting EJClaw..."', `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, - ` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`, - ` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`, + ` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`, + ` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`, '', `echo $! > ${JSON.stringify(pidFile)}`, - 'echo "NanoClaw started (PID $!)"', - `echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`, + 'echo "EJClaw started (PID $!)"', + `echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`, ]; const wrapper = lines.join('\n') + '\n'; diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 1ff94ce..eda9e70 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -20,6 +20,7 @@ import { import { isPairedRoomJid } from '../db.js'; import { readEnvFile } from '../env.js'; import { logger } from '../logger.js'; +import { formatOutbound } from '../router.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); @@ -449,6 +450,7 @@ export class DiscordChannel implements Channel { for (const [name, id] of Object.entries(mentionMap)) { cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`); } + cleaned = formatOutbound(cleaned); // Discord has a 2000 character limit per message — split if needed const MAX_LENGTH = 2000; @@ -457,6 +459,11 @@ export class DiscordChannel implements Channel { name: path.basename(f), })); + if (!cleaned && files.length === 0) { + logger.debug({ jid }, 'Skipping empty Discord outbound message'); + return; + } + if (cleaned.length <= MAX_LENGTH) { await textChannel.send({ content: cleaned || undefined, @@ -497,6 +504,7 @@ export class DiscordChannel implements Channel { return groupType === this.agentTypeFilter; } + async disconnect(): Promise { if (this.client) { this.client.destroy(); diff --git a/src/claude-usage.test.ts b/src/claude-usage.test.ts new file mode 100644 index 0000000..e868e2b --- /dev/null +++ b/src/claude-usage.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { parseClaudeUsagePanel } from './claude-usage.js'; + +describe('parseClaudeUsagePanel', () => { + it('parses session and weekly usage from Claude CLI panel output', () => { + const sample = ` +Settings: Status Config Usage +Loading usage data... + +Current session +████ 4% used +Resets in 8m (Asia/Seoul) + +Current week (all models) +███████████████████████████████████████ 78% used +Resets Mar 17 at 10pm (Asia/Seoul) + +Current week (Sonnet only) +███ 6% used +Resets Mar 17 at 11pm (Asia/Seoul) + +Extra usage +Extra usage not enabled • /extra-usage to enable +`; + + expect(parseClaudeUsagePanel(sample)).toEqual({ + five_hour: { + utilization: 4, + resets_at: 'Resets in 8m (Asia/Seoul)', + }, + seven_day: { + utilization: 78, + resets_at: 'Resets Mar 17 at 10pm (Asia/Seoul)', + }, + }); + }); + + it('converts percent left into used percent', () => { + const sample = ` +Current session +60% left +Resets in 1h +`; + + expect(parseClaudeUsagePanel(sample)).toEqual({ + five_hour: { + utilization: 40, + resets_at: 'Resets in 1h', + }, + }); + }); +}); diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts new file mode 100644 index 0000000..0cb2120 --- /dev/null +++ b/src/dashboard.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStatusContent, type DashboardOptions } from './dashboard.js'; + +function makeOptions(sessionId?: string): DashboardOptions { + const sessions: Record = sessionId + ? { 'group-folder': sessionId } + : {}; + + return { + assistantName: 'Test', + channels: [], + getSessions: () => sessions, + queue: { + getStatuses: () => [ + { + jid: 'dc:123', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + ], + } as unknown as DashboardOptions['queue'], + registeredGroups: () => ({ + 'dc:123': { + name: 'clone-test', + folder: 'group-folder', + trigger: '@bot', + added_at: '2026-03-16T00:00:00.000Z', + }, + }), + statusChannelId: 'status', + statusUpdateInterval: 60000, + usageUpdateInterval: 60000, + }; +} + +describe('buildStatusContent', () => { + it('shows cleared sessions as empty', () => { + const content = buildStatusContent(makeOptions()); + expect(content).toContain('세션 없음'); + }); + + it('shows a shortened session id when a session exists', () => { + const content = buildStatusContent(makeOptions('session-1234567890')); + expect(content).toContain('세션 34567890'); + }); +}); diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..7bf756a --- /dev/null +++ b/src/dashboard.ts @@ -0,0 +1,534 @@ +import { ChildProcess, execSync, spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + fetchClaudeUsageViaCli, + type ClaudeUsageData, +} from './claude-usage.js'; +import { USAGE_DASHBOARD_ENABLED } from './config.js'; +import { readEnvFile } from './env.js'; +import { GroupQueue, GroupStatus } from './group-queue.js'; +import { logger } from './logger.js'; +import { Channel, ChannelMeta, RegisteredGroup } from './types.js'; + +export interface DashboardOptions { + assistantName: string; + channels: Channel[]; + getSessions: () => Record; + queue: GroupQueue; + registeredGroups: () => Record; + statusChannelId: string; + statusUpdateInterval: number; + usageUpdateInterval: number; +} + +interface CodexRateLimit { + limitId?: string; + limitName: string | null; + primary: { usedPercent: number; resetsAt: string | number }; + secondary: { usedPercent: number; resetsAt: string | number }; +} + +const STATUS_ICONS: Record = { + processing: '🟡', + idle: '🟢', + waiting: '🔵', + inactive: '⚪', +}; + +const CHANNEL_META_REFRESH_MS = 300000; + +let statusMessageId: string | null = null; +let usageMessageId: string | null = null; +let usageUpdateInProgress = false; +let channelMetaCache = new Map(); +let channelMetaLastRefresh = 0; + +function findDiscordChannel(channels: Channel[]): Channel | undefined { + return channels.find((c) => c.name.startsWith('discord') && c.isConnected()); +} + +function formatElapsed(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + if (s < 3600) { + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}m${rem.toString().padStart(2, '0')}s`; + } + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h < 24) return `${h}h${m.toString().padStart(2, '0')}m`; + const d = Math.floor(h / 24); + const remH = h % 24; + return `${d}d${remH}h`; +} + +function formatResetKST(value: string | number): string { + try { + const date = + typeof value === 'number' ? new Date(value * 1000) : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('ko-KR', { + timeZone: 'Asia/Seoul', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return String(value); + } +} + +async function refreshChannelMeta(opts: DashboardOptions): Promise { + const now = Date.now(); + if (now - channelMetaLastRefresh < CHANNEL_META_REFRESH_MS) return; + + const ch = opts.channels.find( + (c) => c.name.startsWith('discord') && c.isConnected() && c.getChannelMeta, + ); + if (!ch?.getChannelMeta) return; + + const jids = Object.keys(opts.registeredGroups()).filter((j) => + j.startsWith('dc:'), + ); + try { + channelMetaCache = await ch.getChannelMeta(jids); + channelMetaLastRefresh = now; + } catch (err) { + logger.debug({ err }, 'Failed to refresh channel metadata'); + } +} + +function getStatusLabel(status: GroupStatus): string { + if (status.status === 'processing') { + return `처리 중 (${formatElapsed(status.elapsedMs || 0)})`; + } + if (status.status === 'idle') return '대기 중'; + if (status.status === 'waiting') { + return status.pendingTasks > 0 + ? `큐 대기 (태스크 ${status.pendingTasks}개)` + : '큐 대기 (메시지)'; + } + return '비활성'; +} + +function getSessionLabel(sessionId: string | undefined): string { + if (!sessionId) return '세션 없음'; + const shortId = sessionId.length > 8 ? sessionId.slice(-8) : sessionId; + return `세션 ${shortId}`; +} + +/** @internal - exported for testing */ +export function buildStatusContent(opts: DashboardOptions): string { + const registeredGroups = opts.registeredGroups(); + const sessions = opts.getSessions(); + const jids = Object.keys(registeredGroups); + const statuses = opts.queue.getStatuses(jids); + + const entries = statuses + .map((status) => ({ + status, + group: registeredGroups[status.jid], + meta: channelMetaCache.get(status.jid), + })) + .filter((entry) => entry.group); + + const categoryMap = new Map(); + for (const entry of entries) { + const category = entry.meta?.category || '기타'; + if (!categoryMap.has(category)) categoryMap.set(category, []); + categoryMap.get(category)!.push(entry); + } + + const sortedCategories = [...categoryMap.entries()].sort((a, b) => { + const posA = a[1][0]?.meta?.categoryPosition ?? 999; + const posB = b[1][0]?.meta?.categoryPosition ?? 999; + return posA - posB; + }); + + const sections: string[] = []; + let totalActive = 0; + let totalIdle = 0; + let total = 0; + + for (const [categoryName, categoryEntries] of sortedCategories) { + categoryEntries.sort( + (a, b) => (a.meta?.position ?? 999) - (b.meta?.position ?? 999), + ); + + const lines = categoryEntries.map((entry) => { + const icon = STATUS_ICONS[entry.status.status] || '⚪'; + const label = getStatusLabel(entry.status); + const sessionLabel = getSessionLabel(sessions[entry.group.folder]); + const name = entry.meta?.name ? `#${entry.meta.name}` : entry.group.name; + return ` ${icon} **${name}** — ${label} · ${sessionLabel}`; + }); + + if (channelMetaCache.size > 0 && categoryName !== '기타') { + sections.push(`📁 **${categoryName}**\n${lines.join('\n')}`); + } else { + sections.push(lines.join('\n')); + } + + totalActive += categoryEntries.filter( + (entry) => entry.status.status === 'processing', + ).length; + totalIdle += categoryEntries.filter( + (entry) => entry.status.status === 'idle', + ).length; + total += categoryEntries.length; + } + + const header = `**에이전트 상태** (${opts.assistantName}) — 활성 ${totalActive} | 대기 ${totalIdle} | 전체 ${total}`; + return `${header}\n\n${sections.join('\n\n')}\n\n_${new Date().toLocaleTimeString('ko-KR')}_`; +} + +async function fetchClaudeUsage(): Promise { + const cliUsage = await fetchClaudeUsageViaCli(); + if (cliUsage) return cliUsage; + + try { + const envToken = readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN']); + let token = + process.env.CLAUDE_CODE_OAUTH_TOKEN || + envToken.CLAUDE_CODE_OAUTH_TOKEN || + ''; + if (!token) { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const credsPath = path.join(configDir, '.credentials.json'); + if (!fs.existsSync(credsPath)) return null; + const creds = JSON.parse(fs.readFileSync(credsPath, 'utf-8')); + token = creds?.claudeAiOauth?.accessToken || ''; + } + if (!token) return null; + + const res = await fetch('https://api.anthropic.com/api/oauth/usage', { + headers: { + Authorization: `Bearer ${token}`, + 'anthropic-beta': 'oauth-2025-04-20', + }, + }); + if (!res.ok) return null; + return (await res.json()) as ClaudeUsageData; + } catch { + return null; + } +} + +async function fetchCodexUsage(): Promise { + const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); + const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; + + return new Promise((resolve) => { + let done = false; + const finish = (value: CodexRateLimit[] | null) => { + if (done) return; + done = true; + clearTimeout(timer); + if (proc) { + try { + proc.kill(); + } catch { + /* ignore */ + } + } + resolve(value); + }; + + const timer = setTimeout(() => finish(null), 20000); + + let proc: ChildProcess | null = null; + try { + proc = spawn(codexBin, ['app-server'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...(process.env as Record), + PATH: [ + path.dirname(process.execPath), + path.join(os.homedir(), '.npm-global', 'bin'), + process.env.PATH || '', + ].join(':'), + }, + }); + } catch { + resolve(null); + return; + } + + if (!proc.stdout || !proc.stdin) { + finish(null); + return; + } + + const stdout = proc.stdout; + const stdin = proc.stdin; + + proc.on('error', () => finish(null)); + proc.on('close', () => finish(null)); + + let buf = ''; + stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString(); + const lines = buf.split('\n'); + buf = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 1) { + stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'account/rateLimits/read', + params: {}, + }) + '\n', + ); + } else if (msg.id === 2 && msg.result) { + const byId = msg.result.rateLimitsByLimitId; + if (byId && typeof byId === 'object') { + finish(Object.values(byId) as CodexRateLimit[]); + } else { + finish(null); + } + } + } catch { + /* non-JSON line */ + } + } + }); + + stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, + }) + '\n', + ); + }); +} + +async function buildUsageContent(): Promise { + const lines: string[] = []; + const [claudeUsage, codexUsage] = await Promise.all([ + fetchClaudeUsage(), + fetchCodexUsage(), + ]); + + const bar = (pct: number) => { + const filled = Math.round(pct / 10); + return '█'.repeat(filled) + '░'.repeat(10 - filled); + }; + + lines.push('📊 *사용량*'); + + type UsageRow = { + name: string; + h5pct: number; + h5reset: string; + d7pct: number; + d7reset: string; + }; + const rows: UsageRow[] = []; + + if (claudeUsage) { + const h5 = claudeUsage.five_hour; + const d7 = claudeUsage.seven_day; + rows.push({ + name: 'Claude', + h5pct: h5 + ? h5.utilization > 1 + ? Math.round(h5.utilization) + : Math.round(h5.utilization * 100) + : -1, + h5reset: h5 ? formatResetKST(h5.resets_at) : '', + d7pct: d7 + ? d7.utilization > 1 + ? Math.round(d7.utilization) + : Math.round(d7.utilization * 100) + : -1, + d7reset: d7 ? formatResetKST(d7.resets_at) : '', + }); + } + + if (codexUsage && Array.isArray(codexUsage)) { + const relevant = codexUsage.filter( + (limit) => + limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0, + ); + const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1); + for (const limit of display) { + rows.push({ + name: 'Codex', + h5pct: Math.round(limit.primary.usedPercent), + h5reset: formatResetKST(limit.primary.resetsAt), + d7pct: Math.round(limit.secondary.usedPercent), + d7reset: formatResetKST(limit.secondary.resetsAt), + }); + } + } + + if (rows.length > 0) { + lines.push('```'); + lines.push(' 5-Hour 7-Day'); + for (const row of rows) { + const h5 = + row.h5pct >= 0 + ? `${bar(row.h5pct)} ${String(row.h5pct).padStart(3)}%` + : ' — '; + const d7 = + row.d7pct >= 0 + ? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%` + : ' — '; + lines.push(`${row.name.padEnd(8)}${h5} ${d7}`); + } + lines.push('```'); + } else { + lines.push('_조회 불가_'); + } + lines.push(''); + + lines.push('🖥️ *서버*'); + + const loadAvg = os.loadavg(); + const cpuCount = os.cpus().length; + const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100); + + const totalMem = os.totalmem(); + const usedMem = totalMem - os.freemem(); + const memPct = Math.round((usedMem / totalMem) * 100); + const memUsedGB = (usedMem / 1073741824).toFixed(1); + const memTotalGB = (totalMem / 1073741824).toFixed(1); + + let diskPct = 0; + let diskUsedGB = '?'; + let diskTotalGB = '?'; + try { + const df = execSync('df -B1 / | tail -1', { + encoding: 'utf-8', + timeout: 5000, + }).trim(); + const parts = df.split(/\s+/); + const diskUsed = parseInt(parts[2], 10); + const diskTotal = parseInt(parts[1], 10); + diskPct = Math.round((diskUsed / diskTotal) * 100); + diskUsedGB = (diskUsed / 1073741824).toFixed(0); + diskTotalGB = (diskTotal / 1073741824).toFixed(0); + } catch { + /* ignore */ + } + + lines.push('```'); + lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`); + lines.push( + `${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`, + ); + lines.push( + `${'Disk'.padEnd(8)}${bar(diskPct)} ${String(diskPct).padStart(3)}% ${diskUsedGB}/${diskTotalGB}GB`, + ); + lines.push(`${'Uptime'.padEnd(8)}${formatElapsed(os.uptime() * 1000)}`); + lines.push('```'); + + return ( + lines.join('\n') + + `\n_${new Date().toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + })}_` + ); +} + +export async function purgeDashboardChannel( + opts: Pick, +): Promise { + if (!opts.statusChannelId) return; + + const statusJid = `dc:${opts.statusChannelId}`; + const ch = opts.channels.find( + (channel) => + channel.name.startsWith('discord') && + channel.isConnected() && + channel.purgeChannel, + ); + if (ch?.purgeChannel) { + await ch.purgeChannel(statusJid); + } +} + +export async function startStatusDashboard( + opts: DashboardOptions, +): Promise { + if (!opts.statusChannelId) return; + + const statusJid = `dc:${opts.statusChannelId}`; + + const updateStatus = async () => { + const ch = findDiscordChannel(opts.channels); + if (!ch) return; + + try { + await refreshChannelMeta(opts); + const content = buildStatusContent(opts); + + if (statusMessageId && ch.editMessage) { + await ch.editMessage(statusJid, statusMessageId, content); + } else if (ch.sendAndTrack) { + const id = await ch.sendAndTrack(statusJid, content); + if (id) statusMessageId = id; + } + } catch (err) { + logger.debug({ err }, 'Status dashboard update failed'); + statusMessageId = null; + } + }; + + setInterval(updateStatus, opts.statusUpdateInterval); + await updateStatus(); + logger.info({ channelId: opts.statusChannelId }, 'Status dashboard started'); +} + +export async function startUsageDashboard( + opts: DashboardOptions, +): Promise { + if (!opts.statusChannelId) return; + if (!USAGE_DASHBOARD_ENABLED) return; + + const statusJid = `dc:${opts.statusChannelId}`; + + const updateUsage = async () => { + if (usageUpdateInProgress) return; + usageUpdateInProgress = true; + + const ch = findDiscordChannel(opts.channels); + if (!ch) { + usageUpdateInProgress = false; + return; + } + + try { + const content = await buildUsageContent(); + + if (usageMessageId && ch.editMessage) { + await ch.editMessage(statusJid, usageMessageId, content); + } else if (ch.sendAndTrack) { + const id = await ch.sendAndTrack(statusJid, content); + if (id) usageMessageId = id; + } + } catch (err) { + logger.debug({ err }, 'Usage dashboard update failed'); + usageMessageId = null; + } finally { + usageUpdateInProgress = false; + } + }; + + setInterval(updateUsage, opts.usageUpdateInterval); + await updateUsage(); + logger.info('Usage dashboard started'); +} diff --git a/src/db.test.ts b/src/db.test.ts index ad36427..687f7be 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -3,12 +3,18 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, createTask, + deleteSession, deleteTask, getAllChats, getAllRegisteredGroups, + getDueTasks, + getRegisteredAgentTypesForJid, getMessagesSince, getNewMessages, + isPairedRoomJid, + getSession, getTaskById, + setSession, setRegisteredGroup, storeChatMetadata, storeMessage, @@ -187,13 +193,12 @@ describe('getMessagesSince', () => { '2024-01-01T00:00:02.000Z', 'Andy', ); - // Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included expect(msgs).toHaveLength(2); expect(msgs[0].content).toBe('bot reply'); expect(msgs[1].content).toBe('third'); }); - it('includes bot messages with is_bot_message flag', () => { + it('includes bot messages from other senders', () => { const msgs = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', @@ -204,9 +209,8 @@ describe('getMessagesSince', () => { expect(botMsgs[0].is_bot_message).toBe(true); }); - it('returns all messages including bot when sinceTimestamp is empty', () => { + it('returns all messages when sinceTimestamp is empty', () => { const msgs = getMessagesSince('group@g.us', '', 'Andy'); - // 3 user messages + 1 bot message expect(msgs).toHaveLength(4); }); @@ -277,7 +281,6 @@ describe('getNewMessages', () => { '2024-01-01T00:00:00.000Z', 'Andy', ); - // Includes bot message, returns all 4 messages expect(messages).toHaveLength(4); expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); }); @@ -288,7 +291,6 @@ describe('getNewMessages', () => { '2024-01-01T00:00:02.000Z', 'Andy', ); - // bot reply (00:00:03) + g1 msg2 (00:00:04) after ts expect(messages).toHaveLength(2); expect(messages[0].content).toBe('bot reply'); expect(messages[1].content).toBe('g1 msg2'); @@ -301,6 +303,16 @@ describe('getNewMessages', () => { }); }); +describe('session accessors', () => { + it('deletes only the current service session for a group', () => { + setSession('group-a', 'session-123'); + expect(getSession('group-a')).toBe('session-123'); + + deleteSession('group-a'); + expect(getSession('group-a')).toBeUndefined(); + }); +}); + // --- storeChatMetadata --- describe('storeChatMetadata', () => { @@ -392,6 +404,43 @@ describe('task CRUD', () => { deleteTask('task-3'); expect(getTaskById('task-3')).toBeUndefined(); }); + + it('returns due tasks only for the requested agent type', () => { + const dueAt = new Date(Date.now() - 1_000).toISOString(); + + createTask({ + id: 'task-claude', + group_folder: 'main', + chat_jid: 'group@g.us', + prompt: 'claude task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2024-01-01T00:00:00.000Z', + }); + createTask({ + id: 'task-codex', + group_folder: 'main', + chat_jid: 'group@g.us', + agent_type: 'codex', + prompt: 'codex task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2024-01-01T00:00:01.000Z', + }); + + expect(getDueTasks('claude-code').map((task) => task.id)).toEqual([ + 'task-claude', + ]); + expect(getDueTasks('codex').map((task) => task.id)).toEqual([ + 'task-codex', + ]); + }); }); // --- LIMIT behavior --- @@ -510,3 +559,41 @@ describe('registered group isMain', () => { expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex'); }); }); + +describe('paired room registration', () => { + it('detects when both Claude and Codex are registered on the same jid', () => { + setRegisteredGroup('dc:123', { + name: 'Paired Room Claude', + folder: 'paired-claude', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + setRegisteredGroup('dc:123', { + name: 'Paired Room Codex', + folder: 'paired-codex', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([ + 'claude-code', + 'codex', + ]); + expect(isPairedRoomJid('dc:123')).toBe(true); + }); + + it('does not mark solo rooms as paired', () => { + setRegisteredGroup('dc:solo', { + name: 'Solo Claude Room', + folder: 'solo-claude', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + + expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']); + expect(isPairedRoomJid('dc:solo')).toBe(false); + }); +}); diff --git a/src/db.ts b/src/db.ts index 37e5f0e..ae1657d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -48,6 +48,9 @@ function createSchema(database: Database.Database): void { id TEXT PRIMARY KEY, group_folder TEXT NOT NULL, chat_jid TEXT NOT NULL, + agent_type TEXT, + status_message_id TEXT, + status_started_at TEXT, prompt TEXT NOT NULL, schedule_type TEXT NOT NULL, schedule_value TEXT NOT NULL, @@ -107,6 +110,47 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`); + } catch { + /* column already exists */ + } + + database.exec(` + UPDATE scheduled_tasks + SET agent_type = COALESCE( + ( + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END + FROM registered_groups + WHERE jid = scheduled_tasks.chat_jid + AND folder = scheduled_tasks.group_folder + ), + ( + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END + FROM registered_groups + WHERE jid = scheduled_tasks.chat_jid + ), + ( + SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END + FROM registered_groups + WHERE folder = scheduled_tasks.group_folder + ) + ) + WHERE agent_type IS NULL; + `); + // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { database.exec( @@ -476,17 +520,31 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null { } export function createTask( - task: Omit, + task: Omit< + ScheduledTask, + | 'last_run' + | 'last_result' + | 'agent_type' + | 'status_message_id' + | 'status_started_at' + > & { + agent_type?: AgentType | null; + status_message_id?: string | null; + status_started_at?: string | null; + }, ): void { db.prepare( ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, + task.agent_type || SERVICE_AGENT_TYPE, + task.status_message_id || null, + task.status_started_at || null, task.prompt, task.schedule_type, task.schedule_value, @@ -503,7 +561,18 @@ export function getTaskById(id: string): ScheduledTask | undefined { | undefined; } -export function getTasksForGroup(groupFolder: string): ScheduledTask[] { +export function getTasksForGroup( + groupFolder: string, + agentType?: AgentType, +): ScheduledTask[] { + if (agentType) { + return db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE group_folder = ? AND agent_type = ? ORDER BY created_at DESC', + ) + .all(groupFolder, agentType) as ScheduledTask[]; + } + return db .prepare( 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', @@ -511,7 +580,15 @@ export function getTasksForGroup(groupFolder: string): ScheduledTask[] { .all(groupFolder) as ScheduledTask[]; } -export function getAllTasks(): ScheduledTask[] { +export function getAllTasks(agentType?: AgentType): ScheduledTask[] { + if (agentType) { + return db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE agent_type = ? ORDER BY created_at DESC', + ) + .all(agentType) as ScheduledTask[]; + } + return db .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') .all() as ScheduledTask[]; @@ -558,23 +635,49 @@ export function updateTask( ).run(...values); } +export function updateTaskStatusTracking( + id: string, + updates: Partial>, +): void { + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.status_message_id !== undefined) { + fields.push('status_message_id = ?'); + values.push(updates.status_message_id); + } + if (updates.status_started_at !== undefined) { + fields.push('status_started_at = ?'); + values.push(updates.status_started_at); + } + + if (fields.length === 0) return; + + values.push(id); + db.prepare( + `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, + ).run(...values); +} + export function deleteTask(id: string): void { // Delete child records first (FK constraint) db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); } -export function getDueTasks(): ScheduledTask[] { +export function getDueTasks( + agentType: AgentType = SERVICE_AGENT_TYPE, +): ScheduledTask[] { const now = new Date().toISOString(); return db .prepare( ` SELECT * FROM scheduled_tasks - WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? + WHERE status = 'active' AND agent_type = ? AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run `, ) - .all(now) as ScheduledTask[]; + .all(agentType, now) as ScheduledTask[]; } export function updateTaskAfterRun( diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index da14480..b914789 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -4,7 +4,7 @@ import { GroupQueue } from './group-queue.js'; // Mock config to control concurrency limit vi.mock('./config.js', () => ({ - DATA_DIR: '/tmp/nanoclaw-test-data', + DATA_DIR: '/tmp/ejclaw-test-data', MAX_CONCURRENT_AGENTS: 2, })); @@ -166,6 +166,29 @@ describe('GroupQueue', () => { expect(callCount).toBe(3); }); + it('does not bypass retry backoff when new messages arrive', async () => { + let callCount = 0; + + const processMessages = vi.fn(async () => { + callCount++; + return false; + }); + + queue.setProcessMessagesFn(processMessages); + queue.enqueueMessageCheck('group1@g.us'); + + await vi.advanceTimersByTimeAsync(10); + expect(callCount).toBe(1); + + queue.enqueueMessageCheck('group1@g.us'); + await vi.advanceTimersByTimeAsync(1000); + expect(callCount).toBe(1); + + await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(10); + expect(callCount).toBe(2); + }); + // --- Shutdown prevents new enqueues --- it('prevents new enqueues after shutdown', async () => { @@ -413,6 +436,34 @@ describe('GroupQueue', () => { await vi.advanceTimersByTimeAsync(10); }); + it('does not pipe follow-up messages to an agent after closeStdin', async () => { + let resolveProcess: () => void; + + const processMessages = vi.fn(async () => { + await new Promise((resolve) => { + resolveProcess = resolve; + }); + return true; + }); + + queue.setProcessMessagesFn(processMessages); + queue.enqueueMessageCheck('group1@g.us', 'test-group'); + await vi.advanceTimersByTimeAsync(10); + + queue.registerProcess('group1@g.us', {} as any, 'agent-1', 'test-group'); + queue.notifyIdle('group1@g.us'); + queue.closeStdin('group1@g.us'); + + expect(queue.sendMessage('group1@g.us', 'hello after clear')).toBe(false); + + queue.enqueueMessageCheck('group1@g.us', 'test-group'); + + resolveProcess!(); + await vi.advanceTimersByTimeAsync(10); + + expect(processMessages).toHaveBeenCalledTimes(2); + }); + it('preempts when idle arrives with pending tasks', async () => { const fs = await import('fs'); let resolveProcess: () => void; diff --git a/src/group-queue.ts b/src/group-queue.ts index 10fa6c4..3ac6ed8 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -11,20 +11,29 @@ interface QueuedTask { fn: () => Promise; } +export interface GroupRunContext { + runId: string; + reason: 'messages' | 'drain'; +} + const MAX_RETRIES = 5; const BASE_RETRY_MS = 5000; interface GroupState { active: boolean; idleWaiting: boolean; + closingStdin: boolean; isTaskProcess: boolean; runningTaskId: string | null; + currentRunId: string | null; pendingMessages: boolean; pendingTasks: QueuedTask[]; process: ChildProcess | null; processName: string | null; groupFolder: string | null; retryCount: number; + retryTimer: ReturnType | null; + retryScheduledAt: number | null; startedAt: number | null; } @@ -40,8 +49,9 @@ export class GroupQueue { private groups = new Map(); private activeCount = 0; private waitingGroups: string[] = []; - private processMessagesFn: ((groupJid: string) => Promise) | null = - null; + private processMessagesFn: + | ((groupJid: string, context: GroupRunContext) => Promise) + | null = null; private shuttingDown = false; private getGroup(groupJid: string): GroupState { @@ -50,14 +60,18 @@ export class GroupQueue { state = { active: false, idleWaiting: false, + closingStdin: false, isTaskProcess: false, runningTaskId: null, + currentRunId: null, pendingMessages: false, pendingTasks: [], process: null, processName: null, groupFolder: null, retryCount: 0, + retryTimer: null, + retryScheduledAt: null, startedAt: null, }; this.groups.set(groupJid, state); @@ -65,10 +79,16 @@ export class GroupQueue { return state; } - setProcessMessagesFn(fn: (groupJid: string) => Promise): void { + setProcessMessagesFn( + fn: (groupJid: string, context: GroupRunContext) => Promise, + ): void { this.processMessagesFn = fn; } + private createRunId(): string { + return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + enqueueMessageCheck(groupJid: string, groupFolder?: string): void { if (this.shuttingDown) return; @@ -85,6 +105,22 @@ export class GroupQueue { return; } + if ( + state.retryScheduledAt !== null && + Date.now() < state.retryScheduledAt + ) { + state.pendingMessages = true; + logger.debug( + { + groupJid, + retryCount: state.retryCount, + retryScheduledAt: state.retryScheduledAt, + }, + 'Retry backoff active, message queued until retry window opens', + ); + return; + } + if (this.activeCount >= MAX_CONCURRENT_AGENTS) { state.pendingMessages = true; if (!this.waitingGroups.includes(groupJid)) { @@ -154,17 +190,38 @@ export class GroupQueue { state.process = proc; state.processName = processName; if (groupFolder) state.groupFolder = groupFolder; + logger.info( + { + groupJid, + runId: state.currentRunId, + processName, + groupFolder: state.groupFolder, + isTaskProcess: state.isTaskProcess, + }, + 'Registered active process for group', + ); } /** * Mark the agent process as idle-waiting (finished work, waiting for IPC input). * If tasks are pending, preempt the idle agent process immediately. */ - notifyIdle(groupJid: string): void { + notifyIdle(groupJid: string, runId?: string): void { const state = this.getGroup(groupJid); state.idleWaiting = true; + logger.info( + { + groupJid, + runId: runId ?? state.currentRunId, + pendingTasks: state.pendingTasks.length, + }, + 'Agent entered idle wait state', + ); if (state.pendingTasks.length > 0) { - this.closeStdin(groupJid); + this.closeStdin(groupJid, { + runId: runId ?? state.currentRunId ?? undefined, + reason: 'pending-task-preemption', + }); } } @@ -174,8 +231,27 @@ export class GroupQueue { */ sendMessage(groupJid: string, text: string): boolean { const state = this.getGroup(groupJid); - if (!state.active || !state.groupFolder || state.isTaskProcess) + if (!state.active || !state.groupFolder || state.isTaskProcess) { + logger.debug( + { + groupJid, + runId: state.currentRunId, + active: state.active, + closingStdin: state.closingStdin, + groupFolder: state.groupFolder, + isTaskProcess: state.isTaskProcess, + }, + 'Cannot pipe follow-up message to active agent', + ); return false; + } + if (state.closingStdin) { + logger.info( + { groupJid, runId: state.currentRunId, groupFolder: state.groupFolder }, + 'Skipping follow-up IPC because active agent is closing', + ); + return false; + } state.idleWaiting = false; // Agent is about to receive work, no longer idle const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); @@ -186,8 +262,27 @@ export class GroupQueue { const tempPath = `${filepath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text })); fs.renameSync(tempPath, filepath); + logger.info( + { + groupJid, + runId: state.currentRunId, + groupFolder: state.groupFolder, + textLength: text.length, + filename, + }, + 'Queued follow-up message for active agent', + ); return true; - } catch { + } catch (err) { + logger.warn( + { + groupJid, + runId: state.currentRunId, + groupFolder: state.groupFolder, + err, + }, + 'Failed to queue follow-up message for active agent', + ); return false; } } @@ -195,16 +290,39 @@ export class GroupQueue { /** * Signal the active agent process to wind down by writing a close sentinel. */ - closeStdin(groupJid: string): void { + closeStdin( + groupJid: string, + metadata?: { runId?: string; reason?: string }, + ): void { const state = this.getGroup(groupJid); if (!state.active || !state.groupFolder) return; + state.closingStdin = true; + state.idleWaiting = false; const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); try { fs.mkdirSync(inputDir, { recursive: true }); fs.writeFileSync(path.join(inputDir, '_close'), ''); - } catch { - // ignore + logger.info( + { + groupJid, + runId: metadata?.runId ?? state.currentRunId, + groupFolder: state.groupFolder, + reason: metadata?.reason ?? 'unspecified', + }, + 'Signaled active agent to close stdin', + ); + } catch (err) { + logger.warn( + { + groupJid, + runId: metadata?.runId ?? state.currentRunId, + groupFolder: state.groupFolder, + reason: metadata?.reason ?? 'unspecified', + err, + }, + 'Failed to signal active agent to close stdin', + ); } } @@ -213,36 +331,65 @@ export class GroupQueue { reason: 'messages' | 'drain', ): Promise { const state = this.getGroup(groupJid); + const runId = this.createRunId(); state.active = true; state.idleWaiting = false; + state.closingStdin = false; state.isTaskProcess = false; + state.currentRunId = runId; state.pendingMessages = false; state.startedAt = Date.now(); this.activeCount++; - logger.debug( - { groupJid, reason, activeCount: this.activeCount }, - 'Starting agent process for group', + logger.info( + { groupJid, runId, reason, activeCount: this.activeCount }, + 'Starting group message run', ); + let outcome: 'success' | 'retry_scheduled' | 'error' = 'success'; try { if (this.processMessagesFn) { - const success = await this.processMessagesFn(groupJid); + const success = await this.processMessagesFn(groupJid, { + runId, + reason, + }); if (success) { state.retryCount = 0; + state.retryScheduledAt = null; } else { - this.scheduleRetry(groupJid, state); + outcome = 'retry_scheduled'; + this.scheduleRetry(groupJid, state, runId); } } } catch (err) { - logger.error({ groupJid, err }, 'Error processing messages for group'); - this.scheduleRetry(groupJid, state); + outcome = 'error'; + logger.error( + { groupJid, runId, err }, + 'Error processing messages for group', + ); + this.scheduleRetry(groupJid, state, runId); } finally { + const durationMs = state.startedAt ? Date.now() - state.startedAt : null; + logger.info( + { + groupJid, + runId, + reason, + outcome, + durationMs, + pendingMessages: state.pendingMessages, + pendingTasks: state.pendingTasks.length, + }, + 'Finished group message run', + ); state.active = false; state.startedAt = null; + state.idleWaiting = false; + state.closingStdin = false; state.process = null; state.processName = null; state.groupFolder = null; + state.currentRunId = null; this.activeCount--; this.drainGroup(groupJid); } @@ -252,6 +399,7 @@ export class GroupQueue { const state = this.getGroup(groupJid); state.active = true; state.idleWaiting = false; + state.closingStdin = false; state.isTaskProcess = true; state.runningTaskId = task.id; state.startedAt = Date.now(); @@ -271,6 +419,8 @@ export class GroupQueue { state.isTaskProcess = false; state.runningTaskId = null; state.startedAt = null; + state.idleWaiting = false; + state.closingStdin = false; state.process = null; state.processName = null; state.groupFolder = null; @@ -279,11 +429,20 @@ export class GroupQueue { } } - private scheduleRetry(groupJid: string, state: GroupState): void { + private scheduleRetry( + groupJid: string, + state: GroupState, + runId?: string, + ): void { state.retryCount++; if (state.retryCount > MAX_RETRIES) { + if (state.retryTimer) { + clearTimeout(state.retryTimer); + state.retryTimer = null; + } + state.retryScheduledAt = null; logger.error( - { groupJid, retryCount: state.retryCount }, + { groupJid, runId, retryCount: state.retryCount }, 'Max retries exceeded, dropping messages (will retry on next incoming message)', ); state.retryCount = 0; @@ -291,11 +450,17 @@ export class GroupQueue { } const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1); + state.retryScheduledAt = Date.now() + delayMs; logger.info( - { groupJid, retryCount: state.retryCount, delayMs }, + { groupJid, runId, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff', ); - setTimeout(() => { + if (state.retryTimer) { + clearTimeout(state.retryTimer); + } + state.retryTimer = setTimeout(() => { + state.retryTimer = null; + state.retryScheduledAt = null; if (!this.shuttingDown) { this.enqueueMessageCheck(groupJid); } @@ -412,7 +577,7 @@ export class GroupQueue { // via idle timeout or agent timeout. // This prevents reconnection restarts from killing working agents. const activeProcesses: string[] = []; - for (const [jid, state] of this.groups) { + for (const [, state] of this.groups) { if (state.process && !state.process.killed && state.processName) { activeProcesses.push(state.processName); } diff --git a/src/index.ts b/src/index.ts index b3bcea0..f8f0653 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1544,8 +1544,7 @@ async function main(): Promise { ); }, getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), + writeGroupsSnapshot, }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 1aa681e..18ea6b7 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -107,6 +107,31 @@ describe('schedule_task authorization', () => { expect(allTasks[0].group_folder).toBe('other-group'); }); + it('stores the target group agent type on scheduled tasks', async () => { + groups['other@g.us'] = { + ...OTHER_GROUP, + agentType: 'codex', + }; + setRegisteredGroup('other@g.us', groups['other@g.us']); + + await processTaskIpc( + { + type: 'schedule_task', + prompt: 'codex owned task', + schedule_type: 'once', + schedule_value: '2025-06-01T00:00:00', + targetJid: 'other@g.us', + }, + 'whatsapp_main', + true, + deps, + ); + + const allTasks = getAllTasks(); + expect(allTasks).toHaveLength(1); + expect(allTasks[0].agent_type).toBe('codex'); + }); + it('non-main group cannot schedule for another group', async () => { await processTaskIpc( { diff --git a/src/ipc.ts b/src/ipc.ts index eaa1b1d..a434581 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -3,7 +3,12 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + DATA_DIR, + IPC_POLL_INTERVAL, + SERVICE_AGENT_TYPE, + TIMEZONE, +} from './config.js'; import { AvailableGroup } from './agent-runner.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; @@ -20,7 +25,6 @@ export interface IpcDeps { groupFolder: string, isMain: boolean, availableGroups: AvailableGroup[], - registeredJids: Set, ) => void; } @@ -258,6 +262,7 @@ export async function processTaskIpc( id: taskId, group_folder: targetFolder, chat_jid: targetJid, + agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE, prompt: data.prompt, schedule_type: scheduleType, schedule_value: data.schedule_value, @@ -267,7 +272,13 @@ export async function processTaskIpc( created_at: new Date().toISOString(), }); logger.info( - { taskId, sourceGroup, targetFolder, contextMode }, + { + taskId, + sourceGroup, + targetFolder, + contextMode, + agentType: targetGroupEntry.agentType || SERVICE_AGENT_TYPE, + }, 'Task created via IPC', ); } @@ -401,12 +412,7 @@ export async function processTaskIpc( await deps.syncGroups(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); - deps.writeGroupsSnapshot( - sourceGroup, - true, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); + deps.writeGroupsSnapshot(sourceGroup, true, availableGroups); } else { logger.warn( { sourceGroup }, diff --git a/src/logger.ts b/src/logger.ts index 273dc0f..7411010 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,10 @@ import pino from 'pino'; +const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase(); + export const logger = pino({ level: process.env.LOG_LEVEL || 'info', + name: serviceName, transport: { target: 'pino-pretty', options: { colorize: true } }, }); diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts new file mode 100644 index 0000000..9752ce4 --- /dev/null +++ b/src/message-runtime.test.ts @@ -0,0 +1,707 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./agent-runner.js', () => ({ + runAgentProcess: vi.fn(), + writeGroupsSnapshot: vi.fn(), + writeTasksSnapshot: vi.fn(), +})); + +vi.mock('./config.js', () => ({ + isSessionCommandSenderAllowed: vi.fn(() => false), +})); + +vi.mock('./db.js', () => ({ + getAllChats: vi.fn(() => []), + getAllTasks: vi.fn(() => []), + getLastHumanMessageTimestamp: vi.fn(() => null), + getMessagesSince: vi.fn(), + getNewMessages: vi.fn(() => ({ messages: [], newTimestamp: '' })), +})); + +vi.mock('./logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('./sender-allowlist.js', () => ({ + isTriggerAllowed: vi.fn(() => true), + loadSenderAllowlist: vi.fn(() => ({})), +})); + +vi.mock('./session-commands.js', () => ({ + extractSessionCommand: vi.fn(() => null), + handleSessionCommand: vi.fn(async () => ({ handled: false })), + isSessionCommandAllowed: vi.fn(() => true), + isSessionCommandControlMessage: vi.fn(() => false), +})); + +import * as agentRunner from './agent-runner.js'; +import * as db from './db.js'; +import { createMessageRuntime } from './message-runtime.js'; +import type { Channel, RegisteredGroup } from './types.js'; + +function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup { + return { + name: 'Test Group', + folder: `test-${agentType}`, + trigger: '@Andy', + added_at: new Date().toISOString(), + requiresTrigger: false, + agentType, + }; +} + +function makeChannel(chatJid: string): Channel { + return { + name: 'discord', + connect: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + sendAndTrack: vi.fn().mockResolvedValue('progress-1'), + isConnected: vi.fn(() => true), + ownsJid: vi.fn((jid: string) => jid === chatJid), + disconnect: vi.fn().mockResolvedValue(undefined), + setTyping: vi.fn().mockResolvedValue(undefined), + editMessage: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('createMessageRuntime', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('clears Claude sessions and closes stdin immediately on poisoned output', async () => { + const chatJid = 'group@test'; + const group = makeGroup('claude-code'); + const channel = makeChannel(chatJid); + const closeStdin = vi.fn(); + const notifyIdle = vi.fn(); + const persistSession = vi.fn(); + const clearSession = vi.fn(); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-18T09:00:00.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: + 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', + newSessionId: 'session-123', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-123', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin, + notifyIdle, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession, + clearSession, + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-1', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(persistSession).toHaveBeenCalledWith(group.folder, 'session-123'); + expect(clearSession).toHaveBeenCalledWith(group.folder); + expect(closeStdin).toHaveBeenCalledWith(chatJid, { + runId: 'run-1', + reason: 'poisoned-session', + }); + expect(notifyIdle).not.toHaveBeenCalled(); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', + ); + expect(lastAgentTimestamps[chatJid]).toBe('2026-03-18T09:00:00.000Z'); + expect(saveState).toHaveBeenCalled(); + }); + + it('does not apply the poisoned-session handling to Codex groups', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const closeStdin = vi.fn(); + const notifyIdle = vi.fn(); + const clearSession = vi.fn(); + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-18T09:00:00.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: + 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.', + newSessionId: 'session-456', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-456', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin, + notifyIdle, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession, + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-2', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(clearSession).not.toHaveBeenCalled(); + expect(closeStdin).not.toHaveBeenCalled(); + expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-2'); + }); + + it('tracks Codex progress in one editable message and updates elapsed time every 10 seconds', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const notifyIdle = vi.fn(); + const persistSession = vi.fn(); + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-19T00:00:00.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'progress', + result: 'CI 상태 확인 중입니다.', + newSessionId: 'session-progress', + }); + expect(notifyIdle).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); + await onOutput?.({ + status: 'success', + result: null, + newSessionId: 'session-progress', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-progress', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession, + clearSession: vi.fn(), + }); + + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-progress', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + 'CI 상태 확인 중입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenCalledWith( + chatJid, + 'progress-1', + 'CI 상태 확인 중입니다.\n\n10초', + ); + expect(channel.sendMessage).not.toHaveBeenCalled(); + expect(notifyIdle).toHaveBeenCalledTimes(1); + expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-progress'); + expect(persistSession).toHaveBeenCalledWith( + group.folder, + 'session-progress', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('formats longer Codex progress durations with minutes and hours', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-19T00:00:00.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '오래 걸리는 작업입니다.', + newSessionId: 'session-long-progress', + }); + await vi.advanceTimersByTimeAsync(70_000); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n1분 10초', + ); + await vi.advanceTimersByTimeAsync(50_000); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n2분 0초', + ); + await vi.advanceTimersByTimeAsync(3_480_000); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n1시간 0분 0초', + ); + await vi.advanceTimersByTimeAsync(70_000); + await onOutput?.({ + status: 'success', + result: null, + newSessionId: 'session-long-progress', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-long-progress', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-long-progress', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + '오래 걸리는 작업입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenLastCalledWith( + chatJid, + 'progress-1', + '오래 걸리는 작업입니다.\n\n1시간 1분 10초', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps progress separate from the final Codex answer', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const notifyIdle = vi.fn(); + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-19T00:00:00.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '테스트를 돌리는 중입니다.', + newSessionId: 'session-final', + }); + await vi.advanceTimersByTimeAsync(10_000); + await onOutput?.({ + status: 'success', + phase: 'final', + result: '테스트가 끝났습니다.', + newSessionId: 'session-final', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-final', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle, + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-final', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + '테스트를 돌리는 중입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenCalledWith( + chatJid, + 'progress-1', + '테스트를 돌리는 중입니다.\n\n10초', + ); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + '테스트가 끝났습니다.', + ); + expect(notifyIdle).toHaveBeenCalledTimes(1); + expect(notifyIdle).toHaveBeenCalledWith(chatJid, 'run-final'); + } finally { + vi.useRealTimers(); + } + }); + + it('starts a fresh tracked progress message for each Codex turn in one runner session', async () => { + vi.useFakeTimers(); + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-19T00:00:00.000Z', + }, + ]); + + vi.mocked(channel.sendAndTrack!) + .mockResolvedValueOnce('progress-1') + .mockResolvedValueOnce('progress-2'); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '첫 번째 진행상황입니다.', + newSessionId: 'session-multi-turn', + }); + await vi.advanceTimersByTimeAsync(10_000); + await onOutput?.({ + status: 'success', + phase: 'final', + result: '첫 번째 결과입니다.', + newSessionId: 'session-multi-turn', + }); + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '두 번째 진행상황입니다.', + newSessionId: 'session-multi-turn', + }); + await vi.advanceTimersByTimeAsync(10_000); + await onOutput?.({ + status: 'success', + phase: 'final', + result: '두 번째 결과입니다.', + newSessionId: 'session-multi-turn', + }); + return { + status: 'success', + result: null, + newSessionId: 'session-multi-turn', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => ({}), + saveState: vi.fn(), + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + try { + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-multi-turn', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenNthCalledWith( + 1, + chatJid, + '첫 번째 진행상황입니다.\n\n0초', + ); + expect(channel.sendAndTrack).toHaveBeenNthCalledWith( + 2, + chatJid, + '두 번째 진행상황입니다.\n\n0초', + ); + expect(channel.editMessage).toHaveBeenNthCalledWith( + 1, + chatJid, + 'progress-1', + '첫 번째 진행상황입니다.\n\n10초', + ); + expect(channel.editMessage).toHaveBeenNthCalledWith( + 2, + chatJid, + 'progress-2', + '두 번째 진행상황입니다.\n\n10초', + ); + expect(channel.sendMessage).toHaveBeenNthCalledWith( + 1, + chatJid, + '첫 번째 결과입니다.', + ); + expect(channel.sendMessage).toHaveBeenNthCalledWith( + 2, + chatJid, + '두 번째 결과입니다.', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('does not roll back when a streamed progress message was already posted before an error', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-19T00:00:00.000Z', + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + phase: 'progress', + result: '중간 진행상황입니다.', + newSessionId: 'session-error', + }); + await onOutput?.({ + status: 'error', + result: null, + newSessionId: 'session-error', + error: 'temporary failure', + }); + return { + status: 'error', + result: null, + newSessionId: 'session-error', + error: 'temporary failure', + }; + }, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-progress-error', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendAndTrack).toHaveBeenCalledWith( + chatJid, + '중간 진행상황입니다.\n\n0초', + ); + expect(lastAgentTimestamps[chatJid]).toBe('2026-03-19T00:00:00.000Z'); + expect(saveState).toHaveBeenCalled(); + }); +}); diff --git a/src/message-runtime.ts b/src/message-runtime.ts new file mode 100644 index 0000000..253c06f --- /dev/null +++ b/src/message-runtime.ts @@ -0,0 +1,728 @@ +import { + AgentOutput, + AvailableGroup, + runAgentProcess, + writeGroupsSnapshot, + writeTasksSnapshot, +} from './agent-runner.js'; +import { + getAllChats, + getAllTasks, + getLastHumanMessageTimestamp, + getMessagesSince, + getNewMessages, +} from './db.js'; +import { isSessionCommandSenderAllowed } from './config.js'; +import { GroupQueue, GroupRunContext } from './group-queue.js'; +import { findChannel, formatMessages, formatOutbound } from './router.js'; +import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; +import { + extractSessionCommand, + handleSessionCommand, + isSessionCommandAllowed, + isSessionCommandControlMessage, +} from './session-commands.js'; +import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; +import { isTaskStatusControlMessage } from './task-scheduler.js'; +import { Channel, NewMessage, RegisteredGroup } from './types.js'; +import { logger } from './logger.js'; + +export interface MessageRuntimeDeps { + assistantName: string; + idleTimeout: number; + pollInterval: number; + timezone: string; + triggerPattern: RegExp; + channels: Channel[]; + queue: GroupQueue; + getRegisteredGroups: () => Record; + getSessions: () => Record; + getLastTimestamp: () => string; + setLastTimestamp: (timestamp: string) => void; + getLastAgentTimestamps: () => Record; + saveState: () => void; + persistSession: (groupFolder: string, sessionId: string) => void; + clearSession: (groupFolder: string) => void; +} + +export function getAvailableGroups( + registeredGroups: Record, +): AvailableGroup[] { + const chats = getAllChats(); + const registeredJids = new Set(Object.keys(registeredGroups)); + + return chats + .filter((chat) => chat.jid !== '__group_sync__' && chat.is_group) + .map((chat) => ({ + jid: chat.jid, + name: chat.name, + lastActivity: chat.last_message_time, + isRegistered: registeredJids.has(chat.jid), + })); +} + +export function createMessageRuntime(deps: MessageRuntimeDeps): { + processGroupMessages: ( + chatJid: string, + context: GroupRunContext, + ) => Promise; + recoverPendingMessages: () => void; + startMessageLoop: () => Promise; +} { + let messageLoopRunning = false; + + const filterOwnChannelMessages = (messages: NewMessage[]): NewMessage[] => + messages.filter((msg) => { + const channel = findChannel(deps.channels, msg.chat_jid); + if (channel?.isOwnMessage?.(msg)) { + return false; + } + if (msg.is_bot_message && isSessionCommandControlMessage(msg.content)) { + return false; + } + if (msg.is_bot_message && isTaskStatusControlMessage(msg.content)) { + return false; + } + return true; + }); + + const getCurrentAvailableGroups = (): AvailableGroup[] => + getAvailableGroups(deps.getRegisteredGroups()); + + const runAgent = async ( + group: RegisteredGroup, + prompt: string, + chatJid: string, + runId: string, + onOutput?: (output: AgentOutput) => Promise, + ): Promise<'success' | 'error'> => { + const isMain = group.isMain === true; + const isClaudeCodeAgent = + (group.agentType || 'claude-code') === 'claude-code'; + const sessions = deps.getSessions(); + const sessionId = sessions[group.folder]; + + const tasks = getAllTasks(group.agentType || 'claude-code'); + writeTasksSnapshot( + group.folder, + isMain, + tasks.map((task) => ({ + id: task.id, + groupFolder: task.group_folder, + prompt: task.prompt, + schedule_type: task.schedule_type, + schedule_value: task.schedule_value, + status: task.status, + next_run: task.next_run, + })), + ); + + writeGroupsSnapshot(group.folder, isMain, getCurrentAvailableGroups()); + + let resetSessionRequested = false; + + const wrappedOnOutput = onOutput + ? async (output: AgentOutput) => { + if (output.newSessionId) { + deps.persistSession(group.folder, output.newSessionId); + } + if (isClaudeCodeAgent && shouldResetSessionOnAgentFailure(output)) { + resetSessionRequested = true; + } + await onOutput(output); + } + : undefined; + + try { + const output = await runAgentProcess( + group, + { + prompt, + sessionId, + groupFolder: group.folder, + chatJid, + runId, + isMain, + assistantName: deps.assistantName, + }, + (proc, processName) => + deps.queue.registerProcess(chatJid, proc, processName, group.folder), + wrappedOnOutput, + ); + + if (output.newSessionId) { + deps.persistSession(group.folder, output.newSessionId); + } + + if ( + isClaudeCodeAgent && + (resetSessionRequested || shouldResetSessionOnAgentFailure(output)) + ) { + deps.clearSession(group.folder); + logger.warn( + { group: group.name, chatJid, runId }, + 'Cleared poisoned agent session after unrecoverable error', + ); + } + + if (output.status === 'error') { + logger.error( + { group: group.name, chatJid, runId, error: output.error }, + 'Agent process error', + ); + return 'error'; + } + + return 'success'; + } catch (err) { + logger.error({ group: group.name, chatJid, runId, err }, 'Agent error'); + return 'error'; + } + }; + + const processGroupMessages = async ( + chatJid: string, + context: GroupRunContext, + ): Promise => { + const { runId, reason } = context; + const registeredGroups = deps.getRegisteredGroups(); + const group = registeredGroups[chatJid]; + if (!group) { + logger.warn( + { chatJid, runId, reason }, + 'Registered group missing for queued run', + ); + return true; + } + + const channel = findChannel(deps.channels, chatJid); + if (!channel) { + logger.warn( + { chatJid, runId, reason }, + 'No channel owns JID, skipping messages', + ); + return true; + } + + const isMainGroup = group.isMain === true; + const lastAgentTimestamps = deps.getLastAgentTimestamps(); + const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; + const missedMessages = filterOwnChannelMessages( + getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), + ); + + if (missedMessages.length === 0) { + logger.info( + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + reason, + }, + 'No pending messages for queued run', + ); + return true; + } + + logger.info( + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + reason, + messageCount: missedMessages.length, + sinceTimestamp, + }, + 'Loaded pending messages for queued run', + ); + + const cmdResult = await handleSessionCommand({ + missedMessages, + isMainGroup, + groupName: group.name, + runId, + triggerPattern: deps.triggerPattern, + timezone: deps.timezone, + deps: { + sendMessage: (text) => channel.sendMessage(chatJid, text), + setTyping: (typing) => + channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), + runAgent: (prompt, onOutput) => + runAgent(group, prompt, chatJid, runId, onOutput), + closeStdin: () => + deps.queue.closeStdin(chatJid, { + runId, + reason: 'session-command', + }), + clearSession: () => deps.clearSession(group.folder), + advanceCursor: (timestamp) => { + lastAgentTimestamps[chatJid] = timestamp; + deps.saveState(); + }, + formatMessages, + isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender), + canSenderInteract: (msg) => { + const hasTrigger = deps.triggerPattern.test(msg.content.trim()); + const requiresTrigger = + !isMainGroup && group.requiresTrigger !== false; + return ( + isMainGroup || + !requiresTrigger || + (hasTrigger && + (msg.is_from_me || + isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()))) + ); + }, + }, + }); + if (cmdResult.handled) return cmdResult.success; + + if (!isMainGroup && group.requiresTrigger !== false) { + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = missedMessages.some( + (msg) => + deps.triggerPattern.test(msg.content.trim()) && + (msg.is_from_me || + isTriggerAllowed(chatJid, msg.sender, allowlistCfg)), + ); + if (!hasTrigger) { + logger.info( + { chatJid, group: group.name, groupFolder: group.folder, runId }, + 'Skipping queued run because no allowed trigger was found', + ); + return true; + } + } + + const prompt = formatMessages(missedMessages, deps.timezone); + const previousCursor = lastAgentTimestamps[chatJid] || ''; + lastAgentTimestamps[chatJid] = + missedMessages[missedMessages.length - 1].timestamp; + deps.saveState(); + + logger.info( + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + messageCount: missedMessages.length, + }, + 'Dispatching queued messages to agent', + ); + + let idleTimer: ReturnType | null = null; + let latestProgressText: string | null = null; + let latestProgressRendered: string | null = null; + let progressMessageId: string | null = null; + let progressStartedAt: number | null = null; + let progressTicker: ReturnType | null = null; + + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + logger.info( + { chatJid, group: group.name, groupFolder: group.folder, runId }, + 'Idle timeout reached, closing active agent stdin', + ); + deps.queue.closeStdin(chatJid, { + runId, + reason: 'idle-timeout', + }); + }, deps.idleTimeout); + }; + + let hadError = false; + let finalOutputSentToUser = false; + let progressOutputSentToUser = false; + let poisonedSessionDetected = false; + const isClaudeCodeAgent = + (group.agentType || 'claude-code') === 'claude-code'; + + const clearProgressTicker = () => { + if (progressTicker) { + clearInterval(progressTicker); + progressTicker = null; + } + }; + + const resetProgressState = () => { + clearProgressTicker(); + latestProgressText = null; + latestProgressRendered = null; + progressMessageId = null; + progressStartedAt = null; + }; + + const renderProgressMessage = (text: string) => { + const elapsedSeconds = + progressStartedAt === null + ? 0 + : Math.floor((Date.now() - progressStartedAt) / 10_000) * 10; + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + const elapsedLabel = + hours > 0 + ? `${hours}시간 ${minutes}분 ${seconds}초` + : minutes > 0 + ? `${minutes}분 ${seconds}초` + : `${seconds}초`; + + return `${text}\n\n${elapsedLabel}`; + }; + + const syncTrackedProgressMessage = async () => { + if (!progressMessageId || !channel.editMessage || !latestProgressText) { + return; + } + + const rendered = renderProgressMessage(latestProgressText); + if (rendered === latestProgressRendered) { + return; + } + + try { + await channel.editMessage(chatJid, progressMessageId, rendered); + latestProgressRendered = rendered; + } catch { + clearProgressTicker(); + progressMessageId = null; + } + }; + + const ensureProgressTicker = () => { + if (!progressMessageId || !channel.editMessage || progressTicker) { + return; + } + + progressTicker = setInterval(() => { + void syncTrackedProgressMessage(); + }, 10_000); + }; + + const finalizeProgressMessage = async () => { + await syncTrackedProgressMessage(); + resetProgressState(); + }; + + const sendProgressMessage = async (text: string) => { + if (!text || text === latestProgressText) { + return; + } + + if (progressStartedAt === null) { + progressStartedAt = Date.now(); + } + latestProgressText = text; + const rendered = renderProgressMessage(text); + + if (progressMessageId && channel.editMessage) { + await syncTrackedProgressMessage(); + progressOutputSentToUser = true; + return; + } + + if (channel.sendAndTrack) { + progressMessageId = await channel.sendAndTrack(chatJid, rendered); + if (progressMessageId) { + latestProgressRendered = rendered; + ensureProgressTicker(); + progressOutputSentToUser = true; + return; + } + } + + latestProgressRendered = rendered; + await channel.sendMessage(chatJid, rendered); + progressOutputSentToUser = true; + }; + + await channel.setTyping?.(chatJid, true); + + const output = await runAgent( + group, + prompt, + chatJid, + runId, + async (result) => { + if ( + isClaudeCodeAgent && + shouldResetSessionOnAgentFailure(result) && + !poisonedSessionDetected + ) { + poisonedSessionDetected = true; + hadError = true; + deps.clearSession(group.folder); + deps.queue.closeStdin(chatJid, { + runId, + reason: 'poisoned-session', + }); + logger.warn( + { chatJid, group: group.name, groupFolder: group.folder, runId }, + 'Detected poisoned Claude session from streamed output, forcing close', + ); + } + + if (result.result) { + const raw = + typeof result.result === 'string' + ? result.result + : JSON.stringify(result.result); + const text = formatOutbound(raw); + logger.info( + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + resultStatus: result.status, + }, + `Agent output: ${raw.slice(0, 200)}`, + ); + if (result.phase === 'progress') { + if (text) { + await sendProgressMessage(text); + } + return; + } + + if (text) { + await finalizeProgressMessage(); + await channel.sendMessage(chatJid, text); + finalOutputSentToUser = true; + } + } else { + await finalizeProgressMessage(); + } + + await channel.setTyping?.(chatJid, false); + + if (!poisonedSessionDetected) { + resetIdleTimer(); + } + + if (result.status === 'success' && !poisonedSessionDetected) { + deps.queue.notifyIdle(chatJid, runId); + } + + if (result.status === 'error') { + hadError = true; + } + }, + ); + + await channel.setTyping?.(chatJid, false); + + if (output === 'error') { + hadError = true; + } + + clearProgressTicker(); + + if (idleTimer) clearTimeout(idleTimer); + + if (hadError) { + if (finalOutputSentToUser || progressOutputSentToUser) { + logger.warn( + { chatJid, group: group.name, groupFolder: group.folder, runId }, + 'Agent error after conversational output was sent, skipping cursor rollback to prevent duplicates', + ); + return true; + } + lastAgentTimestamps[chatJid] = previousCursor; + deps.saveState(); + logger.warn( + { chatJid, group: group.name, groupFolder: group.folder, runId }, + 'Agent error, rolled back message cursor for retry', + ); + return false; + } + + logger.info( + { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + outputSentToUser: finalOutputSentToUser, + progressOutputSentToUser, + }, + 'Queued run completed successfully', + ); + + return true; + }; + + const startMessageLoop = async (): Promise => { + if (messageLoopRunning) { + logger.debug('Message loop already running, skipping duplicate start'); + return; + } + messageLoopRunning = true; + + logger.info(`EJClaw running (trigger: @${deps.assistantName})`); + + while (true) { + try { + const registeredGroups = deps.getRegisteredGroups(); + const jids = Object.keys(registeredGroups); + const { messages: rawMessages, newTimestamp } = getNewMessages( + jids, + deps.getLastTimestamp(), + deps.assistantName, + ); + const messages = filterOwnChannelMessages(rawMessages); + + if (rawMessages.length > 0) { + logger.info({ count: messages.length }, 'New messages'); + + deps.setLastTimestamp(newTimestamp); + deps.saveState(); + + if (messages.length === 0) { + continue; + } + + const messagesByGroup = new Map(); + for (const msg of messages) { + const existing = messagesByGroup.get(msg.chat_jid); + if (existing) { + existing.push(msg); + } else { + messagesByGroup.set(msg.chat_jid, [msg]); + } + } + + for (const [chatJid, groupMessages] of messagesByGroup) { + const group = registeredGroups[chatJid]; + if (!group) continue; + + const channel = findChannel(deps.channels, chatJid); + if (!channel) { + logger.warn( + { chatJid }, + 'No channel owns JID, skipping messages', + ); + continue; + } + + const isMainGroup = group.isMain === true; + const allFromBots = groupMessages.every( + (msg) => msg.is_from_me || !!msg.is_bot_message, + ); + if (allFromBots) { + const lastHuman = getLastHumanMessageTimestamp(chatJid); + if ( + !lastHuman || + Date.now() - new Date(lastHuman).getTime() > 12 * 60 * 60 * 1000 + ) { + logger.info( + { chatJid, lastHuman }, + 'Bot-collaboration timeout: no human message within 12h, skipping', + ); + continue; + } + } + + const loopCmdMsg = groupMessages.find( + (msg) => + extractSessionCommand(msg.content, deps.triggerPattern) !== + null, + ); + + if (loopCmdMsg) { + if ( + isSessionCommandAllowed( + isMainGroup, + loopCmdMsg.is_from_me === true, + isSessionCommandSenderAllowed(loopCmdMsg.sender), + ) + ) { + deps.queue.closeStdin(chatJid, { + reason: 'session-command-detected', + }); + } + deps.queue.enqueueMessageCheck(chatJid); + continue; + } + + const needsTrigger = + !isMainGroup && group.requiresTrigger !== false; + if (needsTrigger) { + const allowlistCfg = loadSenderAllowlist(); + const hasTrigger = groupMessages.some( + (msg) => + deps.triggerPattern.test(msg.content.trim()) && + (msg.is_from_me || + isTriggerAllowed(chatJid, msg.sender, allowlistCfg)), + ); + if (!hasTrigger) continue; + } + + const lastAgentTimestamps = deps.getLastAgentTimestamps(); + const allPending = filterOwnChannelMessages( + getMessagesSince( + chatJid, + lastAgentTimestamps[chatJid] || '', + deps.assistantName, + ), + ); + const messagesToSend = + allPending.length > 0 ? allPending : groupMessages; + const formatted = formatMessages(messagesToSend, deps.timezone); + + if (deps.queue.sendMessage(chatJid, formatted)) { + logger.debug( + { chatJid, count: messagesToSend.length }, + 'Piped messages to active agent', + ); + lastAgentTimestamps[chatJid] = + messagesToSend[messagesToSend.length - 1].timestamp; + deps.saveState(); + channel + .setTyping?.(chatJid, true) + ?.catch((err) => + logger.warn( + { chatJid, err }, + 'Failed to set typing indicator', + ), + ); + } else { + deps.queue.enqueueMessageCheck(chatJid, group.folder); + } + } + } + } catch (err) { + logger.error({ err }, 'Error in message loop'); + } + await new Promise((resolve) => setTimeout(resolve, deps.pollInterval)); + } + }; + + const recoverPendingMessages = (): void => { + const registeredGroups = deps.getRegisteredGroups(); + const lastAgentTimestamps = deps.getLastAgentTimestamps(); + for (const [chatJid, group] of Object.entries(registeredGroups)) { + const sinceTimestamp = lastAgentTimestamps[chatJid] || ''; + const pending = filterOwnChannelMessages( + getMessagesSince(chatJid, sinceTimestamp, deps.assistantName), + ); + if (pending.length > 0) { + logger.info( + { group: group.name, pendingCount: pending.length }, + 'Recovery: found unprocessed messages', + ); + deps.queue.enqueueMessageCheck(chatJid, group.folder); + } + } + }; + + return { + processGroupMessages, + recoverPendingMessages, + startMessageLoop, + }; +} diff --git a/src/platform-prompts.test.ts b/src/platform-prompts.test.ts new file mode 100644 index 0000000..f0fe6cc --- /dev/null +++ b/src/platform-prompts.test.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getPairedRoomPromptPath, + getPlatformPromptPath, + readPairedRoomPrompt, + readPlatformPrompt, +} from './platform-prompts.js'; + +describe('platform-prompts', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-prompts-')); + vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns undefined when the prompt file is missing', () => { + expect(readPlatformPrompt('claude-code')).toBeUndefined(); + }); + + it('reads and trims provider-specific prompt files', () => { + const promptsDir = path.join(tempDir, 'prompts'); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync( + path.join(promptsDir, 'codex-platform.md'), + '\nCodex platform prompt\n', + ); + + expect(getPlatformPromptPath('codex')).toBe( + path.join(promptsDir, 'codex-platform.md'), + ); + expect(readPlatformPrompt('codex')).toBe('Codex platform prompt'); + }); + + it('reads and trims paired-room prompt files', () => { + const promptsDir = path.join(tempDir, 'prompts'); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync( + path.join(promptsDir, 'claude-paired-room.md'), + '\nClaude paired prompt\n', + ); + + expect(getPairedRoomPromptPath('claude-code')).toBe( + path.join(promptsDir, 'claude-paired-room.md'), + ); + expect(readPairedRoomPrompt('claude-code')).toBe('Claude paired prompt'); + }); +}); diff --git a/src/router.ts b/src/router.ts index c14ca89..84ab214 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,16 +34,6 @@ export function formatOutbound(rawText: string): string { return text; } -export function routeOutbound( - channels: Channel[], - jid: string, - text: string, -): Promise { - const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected()); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); -} - export function findChannel( channels: Channel[], jid: string, diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 5a683aa..436fa8a 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { extractSessionCommand, handleSessionCommand, + isSessionCommandControlMessage, isSessionCommandAllowed, } from './session-commands.js'; import type { NewMessage } from './types.js'; @@ -18,6 +19,10 @@ describe('extractSessionCommand', () => { expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact'); }); + it('detects bare /clear', () => { + expect(extractSessionCommand('/clear', trigger)).toBe('/clear'); + }); + it('rejects /compact with extra text', () => { expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); }); @@ -43,19 +48,47 @@ describe('extractSessionCommand', () => { describe('isSessionCommandAllowed', () => { it('allows main group regardless of sender', () => { - expect(isSessionCommandAllowed(true, false)).toBe(true); + expect(isSessionCommandAllowed(true, false, false)).toBe(true); }); it('allows trusted/admin sender (is_from_me) in non-main group', () => { - expect(isSessionCommandAllowed(false, true)).toBe(true); + expect(isSessionCommandAllowed(false, true, false)).toBe(true); + }); + + it('allows configured admin sender in non-main group', () => { + expect(isSessionCommandAllowed(false, false, true)).toBe(true); }); it('denies untrusted sender in non-main group', () => { - expect(isSessionCommandAllowed(false, false)).toBe(false); + expect(isSessionCommandAllowed(false, false, false)).toBe(false); }); it('allows trusted sender in main group', () => { - expect(isSessionCommandAllowed(true, true)).toBe(true); + expect(isSessionCommandAllowed(true, true, false)).toBe(true); + }); +}); + +describe('isSessionCommandControlMessage', () => { + it('matches clear confirmation output', () => { + expect( + isSessionCommandControlMessage( + 'Current session cleared. The next message will start a new conversation.', + ), + ).toBe(true); + }); + + it('matches admin denial output', () => { + expect( + isSessionCommandControlMessage('Session commands require admin access.'), + ).toBe(true); + }); + + it('does not match regular bot conversation', () => { + expect( + isSessionCommandControlMessage( + '좋네요. 필요해지면 바로 말씀드리겠습니다.', + ), + ).toBe(false); }); }); @@ -125,6 +158,26 @@ describe('handleSessionCommand', () => { expect(deps.advanceCursor).toHaveBeenCalledWith('100'); }); + it('handles authorized /clear without invoking the agent', async () => { + const deps = makeDeps(); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/clear')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.closeStdin).toHaveBeenCalledTimes(1); + expect(deps.clearSession).toHaveBeenCalledTimes(1); + expect(deps.advanceCursor).toHaveBeenCalledWith('100'); + expect(deps.runAgent).not.toHaveBeenCalled(); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Current session cleared. The next message will start a new conversation.', + ); + }); + it('sends denial to interactable sender in non-main group', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ @@ -205,6 +258,27 @@ describe('handleSessionCommand', () => { ); }); + it('allows configured admin sender in non-main group', async () => { + const deps = makeDeps({ + isAdminSender: vi.fn().mockReturnValue(true), + }); + const result = await handleSessionCommand({ + missedMessages: [ + makeMsg('/clear', { is_from_me: false, sender: 'discord-user-1' }), + ], + isMainGroup: false, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.clearSession).toHaveBeenCalledTimes(1); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Current session cleared. The next message will start a new conversation.', + ); + }); + it('reports failure when command-stage runAgent returns error without streamed status', async () => { // runAgent resolves 'error' but callback never gets status: 'error' const deps = makeDeps({ diff --git a/src/session-recovery.ts b/src/session-recovery.ts index b1b5f15..eae9f88 100644 --- a/src/session-recovery.ts +++ b/src/session-recovery.ts @@ -3,6 +3,7 @@ import type { AgentOutput } from './agent-runner.js'; const SESSION_RESET_PATTERNS = [ /An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i, /Start a new session with fewer images\./i, + /No conversation found with session ID/i, ]; function toText(value: string | object | null | undefined): string[] { diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 2032b51..31dc01f 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -4,6 +4,9 @@ import { _initTestDatabase, createTask, getTaskById } from './db.js'; import { _resetSchedulerLoopForTests, computeNextRun, + extractWatchCiTarget, + isWatchCiTask, + renderWatchCiStatusMessage, startSchedulerLoop, } from './task-scheduler.js'; @@ -52,12 +55,99 @@ describe('task scheduler', () => { expect(task?.status).toBe('paused'); }); + it('only enqueues tasks owned by the current service agent type', async () => { + const dueAt = new Date(Date.now() - 60_000).toISOString(); + createTask({ + id: 'task-claude', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + prompt: 'claude task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:00.000Z', + }); + createTask({ + id: 'task-codex', + group_folder: 'shared-group', + chat_jid: 'shared@g.us', + agent_type: 'codex', + prompt: 'codex task', + schedule_type: 'once', + schedule_value: dueAt, + context_mode: 'isolated', + next_run: dueAt, + status: 'active', + created_at: '2026-02-22T00:00:01.000Z', + }); + + const enqueueTask = vi.fn(); + + startSchedulerLoop({ + serviceAgentType: 'codex', + registeredGroups: () => ({ + 'shared@g.us': { + name: 'Shared', + folder: 'shared-group', + trigger: '@Codex', + added_at: '2026-02-22T00:00:00.000Z', + agentType: 'codex', + }, + }), + getSessions: () => ({}), + queue: { enqueueTask } as any, + onProcess: () => {}, + sendMessage: async () => {}, + }); + + await vi.advanceTimersByTimeAsync(10); + + expect(enqueueTask).toHaveBeenCalledTimes(1); + expect(enqueueTask.mock.calls[0][1]).toBe('task-codex'); + }); + + it('renders watcher heartbeat messages with target and timing', () => { + const prompt = ` +[BACKGROUND CI WATCH] + +Watch target: +GitHub Actions run 123456 + +Task ID: +task-123 + +Check instructions: +Check the run. +`.trim(); + + expect(isWatchCiTask({ prompt } as any)).toBe(true); + expect(extractWatchCiTarget(prompt)).toBe('GitHub Actions run 123456'); + + const rendered = renderWatchCiStatusMessage({ + task: { prompt }, + phase: 'waiting', + checkedAt: '2026-03-19T07:02:10.000Z', + nextRun: '2026-03-19T07:04:10.000Z', + }); + + expect(rendered).toContain('CI 감시 중: GitHub Actions run 123456'); + expect(rendered).toContain('- 상태: 대기 중'); + expect(rendered).toContain('- 마지막 확인:'); + expect(rendered).toContain('- 다음 확인:'); + expect(rendered).not.toContain('2분 10초'); + }); + it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => { const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago const task = { id: 'drift-test', group_folder: 'test', chat_jid: 'test@g.us', + agent_type: 'claude-code' as const, + status_message_id: null, + status_started_at: null, prompt: 'test', schedule_type: 'interval' as const, schedule_value: '60000', // 1 minute @@ -82,6 +172,9 @@ describe('task scheduler', () => { id: 'once-test', group_folder: 'test', chat_jid: 'test@g.us', + agent_type: 'claude-code' as const, + status_message_id: null, + status_started_at: null, prompt: 'test', schedule_type: 'once' as const, schedule_value: '2026-01-01T00:00:00.000Z', @@ -106,6 +199,9 @@ describe('task scheduler', () => { id: 'skip-test', group_folder: 'test', chat_jid: 'test@g.us', + agent_type: 'claude-code' as const, + status_message_id: null, + status_started_at: null, prompt: 'test', schedule_type: 'interval' as const, schedule_value: String(ms), diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index a0a9dee..d7519d7 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process'; import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; -import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; +import { + ASSISTANT_NAME, + SCHEDULER_POLL_INTERVAL, + SERVICE_AGENT_TYPE, + TIMEZONE, +} from './config.js'; import { AgentOutput, runAgentProcess, @@ -13,13 +18,14 @@ import { getDueTasks, getTaskById, logTaskRun, + updateTaskStatusTracking, updateTask, updateTaskAfterRun, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; -import { RegisteredGroup, ScheduledTask } from './types.js'; +import { AgentType, RegisteredGroup, ScheduledTask } from './types.js'; /** * Compute the next run time for a recurring task, anchored to the @@ -63,6 +69,7 @@ export function computeNextRun(task: ScheduledTask): string | null { } export interface SchedulerDependencies { + serviceAgentType?: AgentType; registeredGroups: () => Record; getSessions: () => Record; queue: GroupQueue; @@ -73,6 +80,70 @@ export interface SchedulerDependencies { groupFolder: string, ) => void; sendMessage: (jid: string, text: string) => Promise; + sendTrackedMessage?: (jid: string, text: string) => Promise; + editTrackedMessage?: ( + jid: string, + messageId: string, + text: string, + ) => Promise; +} + +type WatcherStatusPhase = 'checking' | 'waiting' | 'retrying' | 'completed'; + +const WATCH_CI_PREFIX = '[BACKGROUND CI WATCH]'; +const TASK_STATUS_MESSAGE_PREFIX = '\u2063\u2063\u2063'; + +export function isWatchCiTask(task: Pick): boolean { + return task.prompt.startsWith(WATCH_CI_PREFIX); +} + +export function isTaskStatusControlMessage(content: string): boolean { + return content.startsWith(TASK_STATUS_MESSAGE_PREFIX); +} + +export function extractWatchCiTarget(prompt: string): string | null { + const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nTask ID:/); + return match?.[1]?.trim() || null; +} + +function formatTimeLabel(timestampIso: string): string { + return new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: TIMEZONE, + }).format(new Date(timestampIso)); +} + +export function renderWatchCiStatusMessage(args: { + task: Pick; + phase: WatcherStatusPhase; + checkedAt: string; + nextRun?: string | null; +}): string { + const target = extractWatchCiTarget(args.task.prompt) || 'CI watcher'; + const title = + args.phase === 'completed' ? `CI 감시 종료: ${target}` : `CI 감시 중: ${target}`; + const statusLabel = + args.phase === 'checking' + ? '확인 중' + : args.phase === 'retrying' + ? '재시도 대기' + : args.phase === 'completed' + ? '완료' + : '대기 중'; + + const lines = [ + title, + `- 상태: ${statusLabel}`, + `- 마지막 확인: ${formatTimeLabel(args.checkedAt)}`, + ]; + + if (args.nextRun) { + lines.push(`- 다음 확인: ${formatTimeLabel(args.nextRun)}`); + } + return lines.join('\n'); } async function runTask( @@ -131,7 +202,9 @@ async function runTask( // Update tasks snapshot for agent to read (filtered by group) const isMain = group.isMain === true; - const tasks = getAllTasks(); + const taskAgentType = + task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE; + const tasks = getAllTasks(taskAgentType); writeTasksSnapshot( task.group_folder, isMain, @@ -148,6 +221,8 @@ async function runTask( let result: string | null = null; let error: string | null = null; + let statusMessageId = task.status_message_id; + let statusStartedAt = task.status_started_at; // For group context mode, use the group's current session const sessions = deps.getSessions(); @@ -168,7 +243,62 @@ async function runTask( }, TASK_CLOSE_DELAY_MS); }; + const shouldTrackStatus = + isWatchCiTask(task) && + typeof deps.sendTrackedMessage === 'function' && + typeof deps.editTrackedMessage === 'function'; + + const persistStatusTracking = () => { + const currentTask = getTaskById(task.id); + if (!currentTask) return; + updateTaskStatusTracking(task.id, { + status_message_id: statusMessageId, + status_started_at: statusStartedAt, + }); + }; + + const updateWatcherStatus = async ( + phase: WatcherStatusPhase, + nextRun?: string | null, + ) => { + if (!shouldTrackStatus) { + return; + } + + const checkedAt = new Date().toISOString(); + if (!statusStartedAt) { + statusStartedAt = checkedAt; + } + + const text = renderWatchCiStatusMessage({ + task, + phase, + checkedAt, + nextRun, + }); + const payload = `${TASK_STATUS_MESSAGE_PREFIX}${text}`; + + if (statusMessageId) { + try { + await deps.editTrackedMessage!(task.chat_jid, statusMessageId, payload); + persistStatusTracking(); + return; + } catch { + statusMessageId = null; + persistStatusTracking(); + } + } + + const messageId = await deps.sendTrackedMessage!(task.chat_jid, payload); + if (messageId) { + statusMessageId = messageId; + persistStatusTracking(); + } + }; + try { + await updateWatcherStatus('checking'); + const output = await runAgentProcess( group, { @@ -183,6 +313,9 @@ async function runTask( (proc, processName) => deps.onProcess(task.chat_jid, proc, processName, task.group_folder), async (streamedOutput: AgentOutput) => { + if (streamedOutput.phase === 'progress') { + return; + } if (streamedOutput.result) { result = streamedOutput.result; // Forward result to user (sendMessage handles formatting) @@ -209,7 +342,11 @@ async function runTask( } logger.info( - { taskId: task.id, durationMs: Date.now() - startTime }, + { + taskId: task.id, + agentType: taskAgentType, + durationMs: Date.now() - startTime, + }, 'Task completed', ); } catch (err) { @@ -219,6 +356,22 @@ async function runTask( } const durationMs = Date.now() - startTime; + const currentTask = getTaskById(task.id); + const nextRun = currentTask ? computeNextRun(task) : null; + + if (!currentTask) { + await updateWatcherStatus('completed'); + logger.debug({ taskId: task.id }, 'Task deleted during execution, skipping persistence'); + return; + } + + if (error) { + await updateWatcherStatus('retrying', nextRun); + } else if (nextRun) { + await updateWatcherStatus('waiting', nextRun); + } else { + await updateWatcherStatus('completed'); + } logTaskRun({ task_id: task.id, @@ -229,7 +382,6 @@ async function runTask( error, }); - const nextRun = computeNextRun(task); const resultSummary = error ? `Error: ${error}` : result @@ -250,7 +402,9 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void { const loop = async () => { try { - const dueTasks = getDueTasks(); + const dueTasks = getDueTasks( + deps.serviceAgentType || SERVICE_AGENT_TYPE, + ); if (dueTasks.length > 0) { logger.info({ count: dueTasks.length }, 'Found due tasks'); } diff --git a/src/types.ts b/src/types.ts index e93593d..e464264 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,9 @@ export interface ScheduledTask { id: string; group_folder: string; chat_jid: string; + agent_type: AgentType | null; + status_message_id: string | null; + status_started_at: string | null; prompt: string; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string; diff --git a/vitest.config.ts b/vitest.config.ts index a456d1c..a887ef0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: [ + 'src/**/*.test.ts', + 'setup/**/*.test.ts', + 'runners/**/test/**/*.test.ts', + ], }, });