Merge origin/main into chore/nanoclaw-recursive-room
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Skills
|
## 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.
|
A PR that contributes a skill should not modify any source files.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw-codex</string>
|
<string>com.ejclaw-codex</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{{NODE_PATH}}</string>
|
<string>{{NODE_PATH}}</string>
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
<string>{{PROJECT_ROOT}}/data-codex</string>
|
<string>{{PROJECT_ROOT}}/data-codex</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw-codex.error.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>com.ejclaw</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{{NODE_PATH}}</string>
|
<string>{{NODE_PATH}}</string>
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<string>Andy</string>
|
<string>Andy</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string>
|
<string>{{PROJECT_ROOT}}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "ejclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw",
|
"name": "ejclaw",
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
@@ -1982,6 +1982,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2548,6 +2549,7 @@
|
|||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -2615,6 +2617,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -2690,6 +2693,7 @@
|
|||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.0.18",
|
||||||
@@ -2811,6 +2815,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
12
runners/agent-runner/package-lock.json
generated
12
runners/agent-runner/package-lock.json
generated
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "ejclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "ejclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||||
"version": "0.2.68",
|
"version": "0.2.76",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
|
||||||
"integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==",
|
"integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
|
||||||
"license": "SEE LICENSE IN README.md",
|
"license": "SEE LICENSE IN README.md",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "ejclaw-agent-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Container-side agent runner for NanoClaw",
|
"description": "Container-side agent runner for EJClaw",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.34",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.76",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
|||||||
994
runners/agent-runner/pnpm-lock.yaml
generated
Normal file
994
runners/agent-runner/pnpm-lock.yaml
generated
Normal file
@@ -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: {}
|
||||||
@@ -556,22 +556,32 @@ async function runQuery(
|
|||||||
if (isError) {
|
if (isError) {
|
||||||
// Log full error details for debugging
|
// Log full error details for debugging
|
||||||
const msg = message as Record<string, unknown>;
|
const msg = message as Record<string, unknown>;
|
||||||
|
const sdkErrors = Array.isArray(msg.errors) ? msg.errors as string[] : [];
|
||||||
const errorDetail = JSON.stringify({
|
const errorDetail = JSON.stringify({
|
||||||
subtype: message.subtype,
|
subtype: message.subtype,
|
||||||
result: textResult?.slice(0, 500),
|
result: textResult?.slice(0, 500),
|
||||||
errors: msg.errors,
|
errors: sdkErrors,
|
||||||
stop_reason: msg.stop_reason,
|
stop_reason: msg.stop_reason,
|
||||||
duration_ms: msg.duration_ms,
|
duration_ms: msg.duration_ms,
|
||||||
duration_api_ms: msg.duration_api_ms,
|
duration_api_ms: msg.duration_api_ms,
|
||||||
session_id: msg.session_id,
|
session_id: msg.session_id,
|
||||||
});
|
});
|
||||||
log(`Error result detail: ${errorDetail}`);
|
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
runners/codex-runner/pnpm-lock.yaml
generated
Normal file
119
runners/codex-runner/pnpm-lock.yaml
generated
Normal file
@@ -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: {}
|
||||||
@@ -170,5 +170,5 @@ export function isAppServerTurnFinished(state: AppServerTurnState): boolean {
|
|||||||
export function getAppServerTurnResult(
|
export function getAppServerTurnResult(
|
||||||
state: AppServerTurnState,
|
state: AppServerTurnState,
|
||||||
): string | null {
|
): string | null {
|
||||||
return state.finalAnswer || state.latestAgentMessage;
|
return state.finalAnswer;
|
||||||
}
|
}
|
||||||
|
|||||||
83
runners/codex-runner/test/app-server-state.test.ts
Normal file
83
runners/codex-runner/test/app-server-state.test.ts
Normal file
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Cross-platform detection utilities for NanoClaw setup.
|
* Cross-platform detection utilities for EJClaw setup.
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ import Database from 'better-sqlite3';
|
|||||||
function createTestDb(): Database.Database {
|
function createTestDb(): Database.Database {
|
||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||||
jid TEXT PRIMARY KEY,
|
jid TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
folder TEXT NOT NULL UNIQUE,
|
folder TEXT NOT NULL,
|
||||||
trigger_pattern TEXT NOT NULL,
|
trigger_pattern TEXT NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
agent_config TEXT,
|
agent_config TEXT,
|
||||||
requires_trigger INTEGER DEFAULT 1,
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function generatePlist(
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>com.ejclaw</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>${nodePath}</string>
|
<string>${nodePath}</string>
|
||||||
@@ -39,9 +39,9 @@ function generatePlist(
|
|||||||
<string>${homeDir}</string>
|
<string>${homeDir}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.log</string>
|
<string>${projectRoot}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.error.log</string>
|
<string>${projectRoot}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>`;
|
</plist>`;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ function generateSystemdUnit(
|
|||||||
isSystem: boolean,
|
isSystem: boolean,
|
||||||
): string {
|
): string {
|
||||||
return `[Unit]
|
return `[Unit]
|
||||||
Description=NanoClaw Personal Assistant
|
Description=EJClaw Personal Assistant
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -64,8 +64,8 @@ Restart=always
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=${homeDir}
|
Environment=HOME=${homeDir}
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
|
||||||
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
StandardOutput=append:${projectRoot}/logs/ejclaw.log
|
||||||
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
|
StandardError=append:${projectRoot}/logs/ejclaw.error.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
|
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
|
||||||
@@ -75,16 +75,16 @@ describe('plist generation', () => {
|
|||||||
it('contains the correct label', () => {
|
it('contains the correct label', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/usr/local/bin/node',
|
'/usr/local/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('<string>com.nanoclaw</string>');
|
expect(plist).toContain('<string>com.ejclaw</string>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the correct node path', () => {
|
it('uses the correct node path', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/opt/node/bin/node',
|
'/opt/node/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('<string>/opt/node/bin/node</string>');
|
expect(plist).toContain('<string>/opt/node/bin/node</string>');
|
||||||
@@ -93,20 +93,20 @@ describe('plist generation', () => {
|
|||||||
it('points to dist/index.js', () => {
|
it('points to dist/index.js', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/usr/local/bin/node',
|
'/usr/local/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
|
expect(plist).toContain('/home/user/ejclaw/dist/index.js');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets log paths', () => {
|
it('sets log paths', () => {
|
||||||
const plist = generatePlist(
|
const plist = generatePlist(
|
||||||
'/usr/local/bin/node',
|
'/usr/local/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
);
|
);
|
||||||
expect(plist).toContain('nanoclaw.log');
|
expect(plist).toContain('ejclaw.log');
|
||||||
expect(plist).toContain('nanoclaw.error.log');
|
expect(plist).toContain('ejclaw.error.log');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ describe('systemd unit generation', () => {
|
|||||||
it('user unit uses default.target', () => {
|
it('user unit uses default.target', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -124,7 +124,7 @@ describe('systemd unit generation', () => {
|
|||||||
it('system unit uses multi-user.target', () => {
|
it('system unit uses multi-user.target', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -134,7 +134,7 @@ describe('systemd unit generation', () => {
|
|||||||
it('contains restart policy', () => {
|
it('contains restart policy', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/home/user/nanoclaw',
|
'/home/user/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -145,32 +145,32 @@ describe('systemd unit generation', () => {
|
|||||||
it('sets correct ExecStart', () => {
|
it('sets correct ExecStart', () => {
|
||||||
const unit = generateSystemdUnit(
|
const unit = generateSystemdUnit(
|
||||||
'/usr/bin/node',
|
'/usr/bin/node',
|
||||||
'/srv/nanoclaw',
|
'/srv/ejclaw',
|
||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(unit).toContain(
|
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', () => {
|
describe('WSL nohup fallback', () => {
|
||||||
it('generates a valid wrapper script', () => {
|
it('generates a valid wrapper script', () => {
|
||||||
const projectRoot = '/home/user/nanoclaw';
|
const projectRoot = '/home/user/ejclaw';
|
||||||
const nodePath = '/usr/bin/node';
|
const nodePath = '/usr/bin/node';
|
||||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
const pidFile = path.join(projectRoot, 'ejclaw.pid');
|
||||||
|
|
||||||
// Simulate what service.ts generates
|
// Simulate what service.ts generates
|
||||||
const wrapper = `#!/bin/bash
|
const wrapper = `#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd ${JSON.stringify(projectRoot)}
|
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)}`;
|
echo $! > ${JSON.stringify(pidFile)}`;
|
||||||
|
|
||||||
expect(wrapper).toContain('#!/bin/bash');
|
expect(wrapper).toContain('#!/bin/bash');
|
||||||
expect(wrapper).toContain('nohup');
|
expect(wrapper).toContain('nohup');
|
||||||
expect(wrapper).toContain(nodePath);
|
expect(wrapper).toContain(nodePath);
|
||||||
expect(wrapper).toContain('nanoclaw.pid');
|
expect(wrapper).toContain('ejclaw.pid');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
} from './platform.js';
|
} from './platform.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
|
const EJCLAW_SERVICE_NAME = 'ejclaw';
|
||||||
|
const EJCLAW_LAUNCHD_LABEL = 'com.ejclaw';
|
||||||
|
|
||||||
export async function run(_args: string[]): Promise<void> {
|
export async function run(_args: string[]): Promise<void> {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
@@ -77,7 +80,7 @@ function setupLaunchd(
|
|||||||
homeDir,
|
homeDir,
|
||||||
'Library',
|
'Library',
|
||||||
'LaunchAgents',
|
'LaunchAgents',
|
||||||
'com.nanoclaw.plist',
|
`${EJCLAW_LAUNCHD_LABEL}.plist`,
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
||||||
|
|
||||||
@@ -86,7 +89,7 @@ function setupLaunchd(
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>com.nanoclaw</string>
|
<string>${EJCLAW_LAUNCHD_LABEL}</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>${nodePath}</string>
|
<string>${nodePath}</string>
|
||||||
@@ -106,9 +109,9 @@ function setupLaunchd(
|
|||||||
<string>${homeDir}</string>
|
<string>${homeDir}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.log</string>
|
<string>${projectRoot}/logs/ejclaw.log</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
<string>${projectRoot}/logs/nanoclaw.error.log</string>
|
<string>${projectRoot}/logs/ejclaw.error.log</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>`;
|
</plist>`;
|
||||||
|
|
||||||
@@ -128,7 +131,7 @@ function setupLaunchd(
|
|||||||
let serviceLoaded = false;
|
let serviceLoaded = false;
|
||||||
try {
|
try {
|
||||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||||
serviceLoaded = output.includes('com.nanoclaw');
|
serviceLoaded = output.includes(EJCLAW_LAUNCHD_LABEL);
|
||||||
} catch {
|
} catch {
|
||||||
// launchctl list failed
|
// 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.
|
* Prevents connection conflicts when two instances connect to the same channel simultaneously.
|
||||||
*/
|
*/
|
||||||
function killOrphanedProcesses(projectRoot: string): void {
|
function killOrphanedProcesses(projectRoot: string): void {
|
||||||
@@ -168,7 +171,7 @@ function killOrphanedProcesses(projectRoot: string): void {
|
|||||||
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
|
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
logger.info('Stopped any orphaned nanoclaw processes');
|
logger.info('Stopped any orphaned ejclaw processes');
|
||||||
} catch {
|
} catch {
|
||||||
// pkill not available or no orphans
|
// pkill not available or no orphans
|
||||||
}
|
}
|
||||||
@@ -186,7 +189,7 @@ function setupSystemd(
|
|||||||
let systemctlPrefix: string;
|
let systemctlPrefix: string;
|
||||||
|
|
||||||
if (runningAsRoot) {
|
if (runningAsRoot) {
|
||||||
unitPath = '/etc/systemd/system/nanoclaw.service';
|
unitPath = `/etc/systemd/system/${EJCLAW_SERVICE_NAME}.service`;
|
||||||
systemctlPrefix = 'systemctl';
|
systemctlPrefix = 'systemctl';
|
||||||
logger.info('Running as root — installing system-level systemd unit');
|
logger.info('Running as root — installing system-level systemd unit');
|
||||||
} else {
|
} else {
|
||||||
@@ -202,12 +205,12 @@ function setupSystemd(
|
|||||||
}
|
}
|
||||||
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
|
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
|
||||||
fs.mkdirSync(unitDir, { recursive: true });
|
fs.mkdirSync(unitDir, { recursive: true });
|
||||||
unitPath = path.join(unitDir, 'nanoclaw.service');
|
unitPath = path.join(unitDir, `${EJCLAW_SERVICE_NAME}.service`);
|
||||||
systemctlPrefix = 'systemctl --user';
|
systemctlPrefix = 'systemctl --user';
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = `[Unit]
|
const unit = `[Unit]
|
||||||
Description=NanoClaw Personal Assistant
|
Description=EJClaw Personal Assistant
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -218,8 +221,8 @@ Restart=always
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=HOME=${homeDir}
|
Environment=HOME=${homeDir}
|
||||||
Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin
|
Environment=PATH=${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin:${homeDir}/.npm-global/bin
|
||||||
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
|
StandardOutput=append:${projectRoot}/logs/ejclaw.log
|
||||||
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
|
StandardError=append:${projectRoot}/logs/ejclaw.error.log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||||
@@ -227,7 +230,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
fs.writeFileSync(unitPath, unit);
|
fs.writeFileSync(unitPath, unit);
|
||||||
logger.info({ unitPath }, 'Wrote systemd 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);
|
killOrphanedProcesses(projectRoot);
|
||||||
|
|
||||||
// Enable and start
|
// Enable and start
|
||||||
@@ -238,13 +241,17 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} enable ${EJCLAW_SERVICE_NAME}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'systemctl enable failed');
|
logger.error({ err }, 'systemctl enable failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} start ${EJCLAW_SERVICE_NAME}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, 'systemctl start failed');
|
logger.error({ err }, 'systemctl start failed');
|
||||||
}
|
}
|
||||||
@@ -252,7 +259,9 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
// Verify
|
// Verify
|
||||||
let serviceLoaded = false;
|
let serviceLoaded = false;
|
||||||
try {
|
try {
|
||||||
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} is-active ${EJCLAW_SERVICE_NAME}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
serviceLoaded = true;
|
serviceLoaded = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Not active
|
// Not active
|
||||||
@@ -276,12 +285,12 @@ function setupNohupFallback(
|
|||||||
): void {
|
): void {
|
||||||
logger.warn('No systemd detected — generating nohup wrapper script');
|
logger.warn('No systemd detected — generating nohup wrapper script');
|
||||||
|
|
||||||
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
|
const wrapperPath = path.join(projectRoot, 'start-ejclaw.sh');
|
||||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
const pidFile = path.join(projectRoot, 'ejclaw.pid');
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'#!/bin/bash',
|
'#!/bin/bash',
|
||||||
'# start-nanoclaw.sh — Start NanoClaw without systemd',
|
'# start-ejclaw.sh — Start EJClaw without systemd',
|
||||||
`# To stop: kill \\$(cat ${pidFile})`,
|
`# To stop: kill \\$(cat ${pidFile})`,
|
||||||
'',
|
'',
|
||||||
'set -euo pipefail',
|
'set -euo pipefail',
|
||||||
@@ -292,20 +301,20 @@ function setupNohupFallback(
|
|||||||
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
|
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
|
||||||
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
|
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
|
||||||
' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
|
' 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',
|
' kill "$OLD_PID" 2>/dev/null || true',
|
||||||
' sleep 2',
|
' sleep 2',
|
||||||
' fi',
|
' fi',
|
||||||
'fi',
|
'fi',
|
||||||
'',
|
'',
|
||||||
'echo "Starting NanoClaw..."',
|
'echo "Starting EJClaw..."',
|
||||||
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
|
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
|
||||||
` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`,
|
` >> ${JSON.stringify(projectRoot + '/logs/ejclaw.log')} \\`,
|
||||||
` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`,
|
` 2>> ${JSON.stringify(projectRoot + '/logs/ejclaw.error.log')} &`,
|
||||||
'',
|
'',
|
||||||
`echo $! > ${JSON.stringify(pidFile)}`,
|
`echo $! > ${JSON.stringify(pidFile)}`,
|
||||||
'echo "NanoClaw started (PID $!)"',
|
'echo "EJClaw started (PID $!)"',
|
||||||
`echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`,
|
`echo "Logs: tail -f ${projectRoot}/logs/ejclaw.log"`,
|
||||||
];
|
];
|
||||||
const wrapper = lines.join('\n') + '\n';
|
const wrapper = lines.join('\n') + '\n';
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { isPairedRoomJid } from '../db.js';
|
import { isPairedRoomJid } from '../db.js';
|
||||||
import { readEnvFile } from '../env.js';
|
import { readEnvFile } from '../env.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
|
import { formatOutbound } from '../router.js';
|
||||||
|
|
||||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
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)) {
|
for (const [name, id] of Object.entries(mentionMap)) {
|
||||||
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
|
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
|
||||||
}
|
}
|
||||||
|
cleaned = formatOutbound(cleaned);
|
||||||
|
|
||||||
// Discord has a 2000 character limit per message — split if needed
|
// Discord has a 2000 character limit per message — split if needed
|
||||||
const MAX_LENGTH = 2000;
|
const MAX_LENGTH = 2000;
|
||||||
@@ -457,6 +459,11 @@ export class DiscordChannel implements Channel {
|
|||||||
name: path.basename(f),
|
name: path.basename(f),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (!cleaned && files.length === 0) {
|
||||||
|
logger.debug({ jid }, 'Skipping empty Discord outbound message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cleaned.length <= MAX_LENGTH) {
|
if (cleaned.length <= MAX_LENGTH) {
|
||||||
await textChannel.send({
|
await textChannel.send({
|
||||||
content: cleaned || undefined,
|
content: cleaned || undefined,
|
||||||
@@ -497,6 +504,7 @@ export class DiscordChannel implements Channel {
|
|||||||
return groupType === this.agentTypeFilter;
|
return groupType === this.agentTypeFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
this.client.destroy();
|
this.client.destroy();
|
||||||
|
|||||||
53
src/claude-usage.test.ts
Normal file
53
src/claude-usage.test.ts
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/dashboard.test.ts
Normal file
49
src/dashboard.test.ts
Normal file
@@ -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<string, string> = 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
534
src/dashboard.ts
Normal file
534
src/dashboard.ts
Normal file
@@ -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<string, string>;
|
||||||
|
queue: GroupQueue;
|
||||||
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, ChannelMeta>();
|
||||||
|
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<void> {
|
||||||
|
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<string, typeof entries>();
|
||||||
|
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<ClaudeUsageData | null> {
|
||||||
|
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<CodexRateLimit[] | null> {
|
||||||
|
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<string, string>),
|
||||||
|
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<string> {
|
||||||
|
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<DashboardOptions, 'channels' | 'statusChannelId'>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
createTask,
|
createTask,
|
||||||
|
deleteSession,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
|
getDueTasks,
|
||||||
|
getRegisteredAgentTypesForJid,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
|
isPairedRoomJid,
|
||||||
|
getSession,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
|
setSession,
|
||||||
setRegisteredGroup,
|
setRegisteredGroup,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
@@ -187,13 +193,12 @@ describe('getMessagesSince', () => {
|
|||||||
'2024-01-01T00:00:02.000Z',
|
'2024-01-01T00:00:02.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
// Should exclude m1, m2 (before/at timestamp); m3 (bot) is now included
|
|
||||||
expect(msgs).toHaveLength(2);
|
expect(msgs).toHaveLength(2);
|
||||||
expect(msgs[0].content).toBe('bot reply');
|
expect(msgs[0].content).toBe('bot reply');
|
||||||
expect(msgs[1].content).toBe('third');
|
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(
|
const msgs = getMessagesSince(
|
||||||
'group@g.us',
|
'group@g.us',
|
||||||
'2024-01-01T00:00:00.000Z',
|
'2024-01-01T00:00:00.000Z',
|
||||||
@@ -204,9 +209,8 @@ describe('getMessagesSince', () => {
|
|||||||
expect(botMsgs[0].is_bot_message).toBe(true);
|
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');
|
const msgs = getMessagesSince('group@g.us', '', 'Andy');
|
||||||
// 3 user messages + 1 bot message
|
|
||||||
expect(msgs).toHaveLength(4);
|
expect(msgs).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,7 +281,6 @@ describe('getNewMessages', () => {
|
|||||||
'2024-01-01T00:00:00.000Z',
|
'2024-01-01T00:00:00.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
// Includes bot message, returns all 4 messages
|
|
||||||
expect(messages).toHaveLength(4);
|
expect(messages).toHaveLength(4);
|
||||||
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
|
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
|
||||||
});
|
});
|
||||||
@@ -288,7 +291,6 @@ describe('getNewMessages', () => {
|
|||||||
'2024-01-01T00:00:02.000Z',
|
'2024-01-01T00:00:02.000Z',
|
||||||
'Andy',
|
'Andy',
|
||||||
);
|
);
|
||||||
// bot reply (00:00:03) + g1 msg2 (00:00:04) after ts
|
|
||||||
expect(messages).toHaveLength(2);
|
expect(messages).toHaveLength(2);
|
||||||
expect(messages[0].content).toBe('bot reply');
|
expect(messages[0].content).toBe('bot reply');
|
||||||
expect(messages[1].content).toBe('g1 msg2');
|
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 ---
|
// --- storeChatMetadata ---
|
||||||
|
|
||||||
describe('storeChatMetadata', () => {
|
describe('storeChatMetadata', () => {
|
||||||
@@ -392,6 +404,43 @@ describe('task CRUD', () => {
|
|||||||
deleteTask('task-3');
|
deleteTask('task-3');
|
||||||
expect(getTaskById('task-3')).toBeUndefined();
|
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 ---
|
// --- LIMIT behavior ---
|
||||||
@@ -510,3 +559,41 @@ describe('registered group isMain', () => {
|
|||||||
expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
119
src/db.ts
119
src/db.ts
@@ -48,6 +48,9 @@ function createSchema(database: Database.Database): void {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
group_folder TEXT NOT NULL,
|
group_folder TEXT NOT NULL,
|
||||||
chat_jid TEXT NOT NULL,
|
chat_jid TEXT NOT NULL,
|
||||||
|
agent_type TEXT,
|
||||||
|
status_message_id TEXT,
|
||||||
|
status_started_at TEXT,
|
||||||
prompt TEXT NOT NULL,
|
prompt TEXT NOT NULL,
|
||||||
schedule_type TEXT NOT NULL,
|
schedule_type TEXT NOT NULL,
|
||||||
schedule_value TEXT NOT NULL,
|
schedule_value TEXT NOT NULL,
|
||||||
@@ -107,6 +110,47 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* 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)
|
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
|
||||||
try {
|
try {
|
||||||
database.exec(
|
database.exec(
|
||||||
@@ -476,17 +520,31 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createTask(
|
export function createTask(
|
||||||
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
|
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 {
|
): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
task.chat_jid,
|
task.chat_jid,
|
||||||
|
task.agent_type || SERVICE_AGENT_TYPE,
|
||||||
|
task.status_message_id || null,
|
||||||
|
task.status_started_at || null,
|
||||||
task.prompt,
|
task.prompt,
|
||||||
task.schedule_type,
|
task.schedule_type,
|
||||||
task.schedule_value,
|
task.schedule_value,
|
||||||
@@ -503,7 +561,18 @@ export function getTaskById(id: string): ScheduledTask | undefined {
|
|||||||
| 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
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC',
|
'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[];
|
.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
|
return db
|
||||||
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
||||||
.all() as ScheduledTask[];
|
.all() as ScheduledTask[];
|
||||||
@@ -558,23 +635,49 @@ export function updateTask(
|
|||||||
).run(...values);
|
).run(...values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateTaskStatusTracking(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Pick<ScheduledTask, 'status_message_id' | 'status_started_at'>>,
|
||||||
|
): 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 {
|
export function deleteTask(id: string): void {
|
||||||
// Delete child records first (FK constraint)
|
// Delete child records first (FK constraint)
|
||||||
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
||||||
db.prepare('DELETE FROM scheduled_tasks WHERE 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();
|
const now = new Date().toISOString();
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT * FROM scheduled_tasks
|
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
|
ORDER BY next_run
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(now) as ScheduledTask[];
|
.all(agentType, now) as ScheduledTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTaskAfterRun(
|
export function updateTaskAfterRun(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { GroupQueue } from './group-queue.js';
|
|||||||
|
|
||||||
// Mock config to control concurrency limit
|
// Mock config to control concurrency limit
|
||||||
vi.mock('./config.js', () => ({
|
vi.mock('./config.js', () => ({
|
||||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||||
MAX_CONCURRENT_AGENTS: 2,
|
MAX_CONCURRENT_AGENTS: 2,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -166,6 +166,29 @@ describe('GroupQueue', () => {
|
|||||||
expect(callCount).toBe(3);
|
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 ---
|
// --- Shutdown prevents new enqueues ---
|
||||||
|
|
||||||
it('prevents new enqueues after shutdown', async () => {
|
it('prevents new enqueues after shutdown', async () => {
|
||||||
@@ -413,6 +436,34 @@ describe('GroupQueue', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(10);
|
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<void>((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 () => {
|
it('preempts when idle arrives with pending tasks', async () => {
|
||||||
const fs = await import('fs');
|
const fs = await import('fs');
|
||||||
let resolveProcess: () => void;
|
let resolveProcess: () => void;
|
||||||
|
|||||||
@@ -11,20 +11,29 @@ interface QueuedTask {
|
|||||||
fn: () => Promise<void>;
|
fn: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupRunContext {
|
||||||
|
runId: string;
|
||||||
|
reason: 'messages' | 'drain';
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
const BASE_RETRY_MS = 5000;
|
const BASE_RETRY_MS = 5000;
|
||||||
|
|
||||||
interface GroupState {
|
interface GroupState {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
idleWaiting: boolean;
|
idleWaiting: boolean;
|
||||||
|
closingStdin: boolean;
|
||||||
isTaskProcess: boolean;
|
isTaskProcess: boolean;
|
||||||
runningTaskId: string | null;
|
runningTaskId: string | null;
|
||||||
|
currentRunId: string | null;
|
||||||
pendingMessages: boolean;
|
pendingMessages: boolean;
|
||||||
pendingTasks: QueuedTask[];
|
pendingTasks: QueuedTask[];
|
||||||
process: ChildProcess | null;
|
process: ChildProcess | null;
|
||||||
processName: string | null;
|
processName: string | null;
|
||||||
groupFolder: string | null;
|
groupFolder: string | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
|
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
retryScheduledAt: number | null;
|
||||||
startedAt: number | null;
|
startedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +49,9 @@ export class GroupQueue {
|
|||||||
private groups = new Map<string, GroupState>();
|
private groups = new Map<string, GroupState>();
|
||||||
private activeCount = 0;
|
private activeCount = 0;
|
||||||
private waitingGroups: string[] = [];
|
private waitingGroups: string[] = [];
|
||||||
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null =
|
private processMessagesFn:
|
||||||
null;
|
| ((groupJid: string, context: GroupRunContext) => Promise<boolean>)
|
||||||
|
| null = null;
|
||||||
private shuttingDown = false;
|
private shuttingDown = false;
|
||||||
|
|
||||||
private getGroup(groupJid: string): GroupState {
|
private getGroup(groupJid: string): GroupState {
|
||||||
@@ -50,14 +60,18 @@ export class GroupQueue {
|
|||||||
state = {
|
state = {
|
||||||
active: false,
|
active: false,
|
||||||
idleWaiting: false,
|
idleWaiting: false,
|
||||||
|
closingStdin: false,
|
||||||
isTaskProcess: false,
|
isTaskProcess: false,
|
||||||
runningTaskId: null,
|
runningTaskId: null,
|
||||||
|
currentRunId: null,
|
||||||
pendingMessages: false,
|
pendingMessages: false,
|
||||||
pendingTasks: [],
|
pendingTasks: [],
|
||||||
process: null,
|
process: null,
|
||||||
processName: null,
|
processName: null,
|
||||||
groupFolder: null,
|
groupFolder: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
retryTimer: null,
|
||||||
|
retryScheduledAt: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
};
|
};
|
||||||
this.groups.set(groupJid, state);
|
this.groups.set(groupJid, state);
|
||||||
@@ -65,10 +79,16 @@ export class GroupQueue {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
|
setProcessMessagesFn(
|
||||||
|
fn: (groupJid: string, context: GroupRunContext) => Promise<boolean>,
|
||||||
|
): void {
|
||||||
this.processMessagesFn = fn;
|
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 {
|
enqueueMessageCheck(groupJid: string, groupFolder?: string): void {
|
||||||
if (this.shuttingDown) return;
|
if (this.shuttingDown) return;
|
||||||
|
|
||||||
@@ -85,6 +105,22 @@ export class GroupQueue {
|
|||||||
return;
|
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) {
|
if (this.activeCount >= MAX_CONCURRENT_AGENTS) {
|
||||||
state.pendingMessages = true;
|
state.pendingMessages = true;
|
||||||
if (!this.waitingGroups.includes(groupJid)) {
|
if (!this.waitingGroups.includes(groupJid)) {
|
||||||
@@ -154,17 +190,38 @@ export class GroupQueue {
|
|||||||
state.process = proc;
|
state.process = proc;
|
||||||
state.processName = processName;
|
state.processName = processName;
|
||||||
if (groupFolder) state.groupFolder = groupFolder;
|
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).
|
* Mark the agent process as idle-waiting (finished work, waiting for IPC input).
|
||||||
* If tasks are pending, preempt the idle agent process immediately.
|
* 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);
|
const state = this.getGroup(groupJid);
|
||||||
state.idleWaiting = true;
|
state.idleWaiting = true;
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
groupJid,
|
||||||
|
runId: runId ?? state.currentRunId,
|
||||||
|
pendingTasks: state.pendingTasks.length,
|
||||||
|
},
|
||||||
|
'Agent entered idle wait state',
|
||||||
|
);
|
||||||
if (state.pendingTasks.length > 0) {
|
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 {
|
sendMessage(groupJid: string, text: string): boolean {
|
||||||
const state = this.getGroup(groupJid);
|
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;
|
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
|
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||||
@@ -186,8 +262,27 @@ export class GroupQueue {
|
|||||||
const tempPath = `${filepath}.tmp`;
|
const tempPath = `${filepath}.tmp`;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
|
||||||
fs.renameSync(tempPath, filepath);
|
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;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,16 +290,39 @@ export class GroupQueue {
|
|||||||
/**
|
/**
|
||||||
* Signal the active agent process to wind down by writing a close sentinel.
|
* 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);
|
const state = this.getGroup(groupJid);
|
||||||
if (!state.active || !state.groupFolder) return;
|
if (!state.active || !state.groupFolder) return;
|
||||||
|
state.closingStdin = true;
|
||||||
|
state.idleWaiting = false;
|
||||||
|
|
||||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(inputDir, { recursive: true });
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
fs.writeFileSync(path.join(inputDir, '_close'), '');
|
||||||
} catch {
|
logger.info(
|
||||||
// ignore
|
{
|
||||||
|
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',
|
reason: 'messages' | 'drain',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
|
const runId = this.createRunId();
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
|
state.closingStdin = false;
|
||||||
state.isTaskProcess = false;
|
state.isTaskProcess = false;
|
||||||
|
state.currentRunId = runId;
|
||||||
state.pendingMessages = false;
|
state.pendingMessages = false;
|
||||||
state.startedAt = Date.now();
|
state.startedAt = Date.now();
|
||||||
this.activeCount++;
|
this.activeCount++;
|
||||||
|
|
||||||
logger.debug(
|
logger.info(
|
||||||
{ groupJid, reason, activeCount: this.activeCount },
|
{ groupJid, runId, reason, activeCount: this.activeCount },
|
||||||
'Starting agent process for group',
|
'Starting group message run',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let outcome: 'success' | 'retry_scheduled' | 'error' = 'success';
|
||||||
try {
|
try {
|
||||||
if (this.processMessagesFn) {
|
if (this.processMessagesFn) {
|
||||||
const success = await this.processMessagesFn(groupJid);
|
const success = await this.processMessagesFn(groupJid, {
|
||||||
|
runId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
state.retryCount = 0;
|
state.retryCount = 0;
|
||||||
|
state.retryScheduledAt = null;
|
||||||
} else {
|
} else {
|
||||||
this.scheduleRetry(groupJid, state);
|
outcome = 'retry_scheduled';
|
||||||
|
this.scheduleRetry(groupJid, state, runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ groupJid, err }, 'Error processing messages for group');
|
outcome = 'error';
|
||||||
this.scheduleRetry(groupJid, state);
|
logger.error(
|
||||||
|
{ groupJid, runId, err },
|
||||||
|
'Error processing messages for group',
|
||||||
|
);
|
||||||
|
this.scheduleRetry(groupJid, state, runId);
|
||||||
} finally {
|
} 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.active = false;
|
||||||
state.startedAt = null;
|
state.startedAt = null;
|
||||||
|
state.idleWaiting = false;
|
||||||
|
state.closingStdin = false;
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.processName = null;
|
state.processName = null;
|
||||||
state.groupFolder = null;
|
state.groupFolder = null;
|
||||||
|
state.currentRunId = null;
|
||||||
this.activeCount--;
|
this.activeCount--;
|
||||||
this.drainGroup(groupJid);
|
this.drainGroup(groupJid);
|
||||||
}
|
}
|
||||||
@@ -252,6 +399,7 @@ export class GroupQueue {
|
|||||||
const state = this.getGroup(groupJid);
|
const state = this.getGroup(groupJid);
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.idleWaiting = false;
|
state.idleWaiting = false;
|
||||||
|
state.closingStdin = false;
|
||||||
state.isTaskProcess = true;
|
state.isTaskProcess = true;
|
||||||
state.runningTaskId = task.id;
|
state.runningTaskId = task.id;
|
||||||
state.startedAt = Date.now();
|
state.startedAt = Date.now();
|
||||||
@@ -271,6 +419,8 @@ export class GroupQueue {
|
|||||||
state.isTaskProcess = false;
|
state.isTaskProcess = false;
|
||||||
state.runningTaskId = null;
|
state.runningTaskId = null;
|
||||||
state.startedAt = null;
|
state.startedAt = null;
|
||||||
|
state.idleWaiting = false;
|
||||||
|
state.closingStdin = false;
|
||||||
state.process = null;
|
state.process = null;
|
||||||
state.processName = null;
|
state.processName = null;
|
||||||
state.groupFolder = 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++;
|
state.retryCount++;
|
||||||
if (state.retryCount > MAX_RETRIES) {
|
if (state.retryCount > MAX_RETRIES) {
|
||||||
|
if (state.retryTimer) {
|
||||||
|
clearTimeout(state.retryTimer);
|
||||||
|
state.retryTimer = null;
|
||||||
|
}
|
||||||
|
state.retryScheduledAt = null;
|
||||||
logger.error(
|
logger.error(
|
||||||
{ groupJid, retryCount: state.retryCount },
|
{ groupJid, runId, retryCount: state.retryCount },
|
||||||
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
'Max retries exceeded, dropping messages (will retry on next incoming message)',
|
||||||
);
|
);
|
||||||
state.retryCount = 0;
|
state.retryCount = 0;
|
||||||
@@ -291,11 +450,17 @@ export class GroupQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
|
||||||
|
state.retryScheduledAt = Date.now() + delayMs;
|
||||||
logger.info(
|
logger.info(
|
||||||
{ groupJid, retryCount: state.retryCount, delayMs },
|
{ groupJid, runId, retryCount: state.retryCount, delayMs },
|
||||||
'Scheduling retry with backoff',
|
'Scheduling retry with backoff',
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
if (state.retryTimer) {
|
||||||
|
clearTimeout(state.retryTimer);
|
||||||
|
}
|
||||||
|
state.retryTimer = setTimeout(() => {
|
||||||
|
state.retryTimer = null;
|
||||||
|
state.retryScheduledAt = null;
|
||||||
if (!this.shuttingDown) {
|
if (!this.shuttingDown) {
|
||||||
this.enqueueMessageCheck(groupJid);
|
this.enqueueMessageCheck(groupJid);
|
||||||
}
|
}
|
||||||
@@ -412,7 +577,7 @@ export class GroupQueue {
|
|||||||
// via idle timeout or agent timeout.
|
// via idle timeout or agent timeout.
|
||||||
// This prevents reconnection restarts from killing working agents.
|
// This prevents reconnection restarts from killing working agents.
|
||||||
const activeProcesses: string[] = [];
|
const activeProcesses: string[] = [];
|
||||||
for (const [jid, state] of this.groups) {
|
for (const [, state] of this.groups) {
|
||||||
if (state.process && !state.process.killed && state.processName) {
|
if (state.process && !state.process.killed && state.processName) {
|
||||||
activeProcesses.push(state.processName);
|
activeProcesses.push(state.processName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1544,8 +1544,7 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
getAvailableGroups,
|
getAvailableGroups,
|
||||||
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
writeGroupsSnapshot,
|
||||||
writeGroupsSnapshot(gf, im, ag, rj),
|
|
||||||
});
|
});
|
||||||
queue.setProcessMessagesFn(processGroupMessages);
|
queue.setProcessMessagesFn(processGroupMessages);
|
||||||
recoverPendingMessages();
|
recoverPendingMessages();
|
||||||
|
|||||||
@@ -107,6 +107,31 @@ describe('schedule_task authorization', () => {
|
|||||||
expect(allTasks[0].group_folder).toBe('other-group');
|
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 () => {
|
it('non-main group cannot schedule for another group', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{
|
{
|
||||||
|
|||||||
24
src/ipc.ts
24
src/ipc.ts
@@ -3,7 +3,12 @@ import path from 'path';
|
|||||||
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
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 { AvailableGroup } from './agent-runner.js';
|
||||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
@@ -20,7 +25,6 @@ export interface IpcDeps {
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
availableGroups: AvailableGroup[],
|
availableGroups: AvailableGroup[],
|
||||||
registeredJids: Set<string>,
|
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +262,7 @@ export async function processTaskIpc(
|
|||||||
id: taskId,
|
id: taskId,
|
||||||
group_folder: targetFolder,
|
group_folder: targetFolder,
|
||||||
chat_jid: targetJid,
|
chat_jid: targetJid,
|
||||||
|
agent_type: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
|
||||||
prompt: data.prompt,
|
prompt: data.prompt,
|
||||||
schedule_type: scheduleType,
|
schedule_type: scheduleType,
|
||||||
schedule_value: data.schedule_value,
|
schedule_value: data.schedule_value,
|
||||||
@@ -267,7 +272,13 @@ export async function processTaskIpc(
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId, sourceGroup, targetFolder, contextMode },
|
{
|
||||||
|
taskId,
|
||||||
|
sourceGroup,
|
||||||
|
targetFolder,
|
||||||
|
contextMode,
|
||||||
|
agentType: targetGroupEntry.agentType || SERVICE_AGENT_TYPE,
|
||||||
|
},
|
||||||
'Task created via IPC',
|
'Task created via IPC',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -401,12 +412,7 @@ export async function processTaskIpc(
|
|||||||
await deps.syncGroups(true);
|
await deps.syncGroups(true);
|
||||||
// Write updated snapshot immediately
|
// Write updated snapshot immediately
|
||||||
const availableGroups = deps.getAvailableGroups();
|
const availableGroups = deps.getAvailableGroups();
|
||||||
deps.writeGroupsSnapshot(
|
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups);
|
||||||
sourceGroup,
|
|
||||||
true,
|
|
||||||
availableGroups,
|
|
||||||
new Set(Object.keys(registeredGroups)),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ sourceGroup },
|
{ sourceGroup },
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase();
|
||||||
|
|
||||||
export const logger = pino({
|
export const logger = pino({
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
name: serviceName,
|
||||||
transport: { target: 'pino-pretty', options: { colorize: true } },
|
transport: { target: 'pino-pretty', options: { colorize: true } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
707
src/message-runtime.test.ts
Normal file
707
src/message-runtime.test.ts
Normal file
@@ -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<string, string> = {};
|
||||||
|
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
728
src/message-runtime.ts
Normal file
728
src/message-runtime.ts
Normal file
@@ -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<string, RegisteredGroup>;
|
||||||
|
getSessions: () => Record<string, string>;
|
||||||
|
getLastTimestamp: () => string;
|
||||||
|
setLastTimestamp: (timestamp: string) => void;
|
||||||
|
getLastAgentTimestamps: () => Record<string, string>;
|
||||||
|
saveState: () => void;
|
||||||
|
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||||
|
clearSession: (groupFolder: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableGroups(
|
||||||
|
registeredGroups: Record<string, RegisteredGroup>,
|
||||||
|
): 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<boolean>;
|
||||||
|
recoverPendingMessages: () => void;
|
||||||
|
startMessageLoop: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
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<void>,
|
||||||
|
): 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<boolean> => {
|
||||||
|
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<typeof setTimeout> | 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<typeof setInterval> | 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<void> => {
|
||||||
|
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<string, NewMessage[]>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/platform-prompts.test.ts
Normal file
58
src/platform-prompts.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,16 +34,6 @@ export function formatOutbound(rawText: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeOutbound(
|
|
||||||
channels: Channel[],
|
|
||||||
jid: string,
|
|
||||||
text: string,
|
|
||||||
): Promise<void> {
|
|
||||||
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(
|
export function findChannel(
|
||||||
channels: Channel[],
|
channels: Channel[],
|
||||||
jid: string,
|
jid: string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
extractSessionCommand,
|
extractSessionCommand,
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
|
isSessionCommandControlMessage,
|
||||||
isSessionCommandAllowed,
|
isSessionCommandAllowed,
|
||||||
} from './session-commands.js';
|
} from './session-commands.js';
|
||||||
import type { NewMessage } from './types.js';
|
import type { NewMessage } from './types.js';
|
||||||
@@ -18,6 +19,10 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects bare /clear', () => {
|
||||||
|
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects /compact with extra text', () => {
|
it('rejects /compact with extra text', () => {
|
||||||
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -43,19 +48,47 @@ describe('extractSessionCommand', () => {
|
|||||||
|
|
||||||
describe('isSessionCommandAllowed', () => {
|
describe('isSessionCommandAllowed', () => {
|
||||||
it('allows main group regardless of sender', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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');
|
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 () => {
|
it('sends denial to interactable sender in non-main group', async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const result = await handleSessionCommand({
|
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 () => {
|
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
|
||||||
// runAgent resolves 'error' but callback never gets status: 'error'
|
// runAgent resolves 'error' but callback never gets status: 'error'
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AgentOutput } from './agent-runner.js';
|
|||||||
const SESSION_RESET_PATTERNS = [
|
const SESSION_RESET_PATTERNS = [
|
||||||
/An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i,
|
/An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i,
|
||||||
/Start a new session with fewer images\./i,
|
/Start a new session with fewer images\./i,
|
||||||
|
/No conversation found with session ID/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
function toText(value: string | object | null | undefined): string[] {
|
function toText(value: string | object | null | undefined): string[] {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
|||||||
import {
|
import {
|
||||||
_resetSchedulerLoopForTests,
|
_resetSchedulerLoopForTests,
|
||||||
computeNextRun,
|
computeNextRun,
|
||||||
|
extractWatchCiTarget,
|
||||||
|
isWatchCiTask,
|
||||||
|
renderWatchCiStatusMessage,
|
||||||
startSchedulerLoop,
|
startSchedulerLoop,
|
||||||
} from './task-scheduler.js';
|
} from './task-scheduler.js';
|
||||||
|
|
||||||
@@ -52,12 +55,99 @@ describe('task scheduler', () => {
|
|||||||
expect(task?.status).toBe('paused');
|
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', () => {
|
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
||||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
||||||
const task = {
|
const task = {
|
||||||
id: 'drift-test',
|
id: 'drift-test',
|
||||||
group_folder: 'test',
|
group_folder: 'test',
|
||||||
chat_jid: 'test@g.us',
|
chat_jid: 'test@g.us',
|
||||||
|
agent_type: 'claude-code' as const,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
prompt: 'test',
|
prompt: 'test',
|
||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: '60000', // 1 minute
|
schedule_value: '60000', // 1 minute
|
||||||
@@ -82,6 +172,9 @@ describe('task scheduler', () => {
|
|||||||
id: 'once-test',
|
id: 'once-test',
|
||||||
group_folder: 'test',
|
group_folder: 'test',
|
||||||
chat_jid: 'test@g.us',
|
chat_jid: 'test@g.us',
|
||||||
|
agent_type: 'claude-code' as const,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
prompt: 'test',
|
prompt: 'test',
|
||||||
schedule_type: 'once' as const,
|
schedule_type: 'once' as const,
|
||||||
schedule_value: '2026-01-01T00:00:00.000Z',
|
schedule_value: '2026-01-01T00:00:00.000Z',
|
||||||
@@ -106,6 +199,9 @@ describe('task scheduler', () => {
|
|||||||
id: 'skip-test',
|
id: 'skip-test',
|
||||||
group_folder: 'test',
|
group_folder: 'test',
|
||||||
chat_jid: 'test@g.us',
|
chat_jid: 'test@g.us',
|
||||||
|
agent_type: 'claude-code' as const,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
prompt: 'test',
|
prompt: 'test',
|
||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: String(ms),
|
schedule_value: String(ms),
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { ChildProcess } from 'child_process';
|
|||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import fs from 'fs';
|
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 {
|
import {
|
||||||
AgentOutput,
|
AgentOutput,
|
||||||
runAgentProcess,
|
runAgentProcess,
|
||||||
@@ -13,13 +18,14 @@ import {
|
|||||||
getDueTasks,
|
getDueTasks,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
logTaskRun,
|
logTaskRun,
|
||||||
|
updateTaskStatusTracking,
|
||||||
updateTask,
|
updateTask,
|
||||||
updateTaskAfterRun,
|
updateTaskAfterRun,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { resolveGroupFolderPath } from './group-folder.js';
|
import { resolveGroupFolderPath } from './group-folder.js';
|
||||||
import { logger } from './logger.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
|
* 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 {
|
export interface SchedulerDependencies {
|
||||||
|
serviceAgentType?: AgentType;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
@@ -73,6 +80,70 @@ export interface SchedulerDependencies {
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
) => void;
|
) => void;
|
||||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||||
|
sendTrackedMessage?: (jid: string, text: string) => Promise<string | null>;
|
||||||
|
editTrackedMessage?: (
|
||||||
|
jid: string,
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ScheduledTask, 'prompt'>): 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<ScheduledTask, 'prompt'>;
|
||||||
|
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(
|
async function runTask(
|
||||||
@@ -131,7 +202,9 @@ async function runTask(
|
|||||||
|
|
||||||
// Update tasks snapshot for agent to read (filtered by group)
|
// Update tasks snapshot for agent to read (filtered by group)
|
||||||
const isMain = group.isMain === true;
|
const isMain = group.isMain === true;
|
||||||
const tasks = getAllTasks();
|
const taskAgentType =
|
||||||
|
task.agent_type || deps.serviceAgentType || SERVICE_AGENT_TYPE;
|
||||||
|
const tasks = getAllTasks(taskAgentType);
|
||||||
writeTasksSnapshot(
|
writeTasksSnapshot(
|
||||||
task.group_folder,
|
task.group_folder,
|
||||||
isMain,
|
isMain,
|
||||||
@@ -148,6 +221,8 @@ async function runTask(
|
|||||||
|
|
||||||
let result: string | null = null;
|
let result: string | null = null;
|
||||||
let error: 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
|
// For group context mode, use the group's current session
|
||||||
const sessions = deps.getSessions();
|
const sessions = deps.getSessions();
|
||||||
@@ -168,7 +243,62 @@ async function runTask(
|
|||||||
}, TASK_CLOSE_DELAY_MS);
|
}, 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 {
|
try {
|
||||||
|
await updateWatcherStatus('checking');
|
||||||
|
|
||||||
const output = await runAgentProcess(
|
const output = await runAgentProcess(
|
||||||
group,
|
group,
|
||||||
{
|
{
|
||||||
@@ -183,6 +313,9 @@ async function runTask(
|
|||||||
(proc, processName) =>
|
(proc, processName) =>
|
||||||
deps.onProcess(task.chat_jid, proc, processName, task.group_folder),
|
deps.onProcess(task.chat_jid, proc, processName, task.group_folder),
|
||||||
async (streamedOutput: AgentOutput) => {
|
async (streamedOutput: AgentOutput) => {
|
||||||
|
if (streamedOutput.phase === 'progress') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (streamedOutput.result) {
|
if (streamedOutput.result) {
|
||||||
result = streamedOutput.result;
|
result = streamedOutput.result;
|
||||||
// Forward result to user (sendMessage handles formatting)
|
// Forward result to user (sendMessage handles formatting)
|
||||||
@@ -209,7 +342,11 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ taskId: task.id, durationMs: Date.now() - startTime },
|
{
|
||||||
|
taskId: task.id,
|
||||||
|
agentType: taskAgentType,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
},
|
||||||
'Task completed',
|
'Task completed',
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,6 +356,22 @@ async function runTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const durationMs = Date.now() - startTime;
|
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({
|
logTaskRun({
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
@@ -229,7 +382,6 @@ async function runTask(
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextRun = computeNextRun(task);
|
|
||||||
const resultSummary = error
|
const resultSummary = error
|
||||||
? `Error: ${error}`
|
? `Error: ${error}`
|
||||||
: result
|
: result
|
||||||
@@ -250,7 +402,9 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
|||||||
|
|
||||||
const loop = async () => {
|
const loop = async () => {
|
||||||
try {
|
try {
|
||||||
const dueTasks = getDueTasks();
|
const dueTasks = getDueTasks(
|
||||||
|
deps.serviceAgentType || SERVICE_AGENT_TYPE,
|
||||||
|
);
|
||||||
if (dueTasks.length > 0) {
|
if (dueTasks.length > 0) {
|
||||||
logger.info({ count: dueTasks.length }, 'Found due tasks');
|
logger.info({ count: dueTasks.length }, 'Found due tasks');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export interface ScheduledTask {
|
|||||||
id: string;
|
id: string;
|
||||||
group_folder: string;
|
group_folder: string;
|
||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
|
agent_type: AgentType | null;
|
||||||
|
status_message_id: string | null;
|
||||||
|
status_started_at: string | null;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
schedule_type: 'cron' | 'interval' | 'once';
|
schedule_type: 'cron' | 'interval' | 'once';
|
||||||
schedule_value: string;
|
schedule_value: string;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
|
include: [
|
||||||
|
'src/**/*.test.ts',
|
||||||
|
'setup/**/*.test.ts',
|
||||||
|
'runners/**/test/**/*.test.ts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user