From 00b31e23f5c9aa68d8319b48632c075cdc20b951 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 17:46:51 +0800 Subject: [PATCH 01/35] chore(deps): add croner for cron expression parsing Co-Authored-By: Claude Opus 4.5 --- package.json | 6 ++- pnpm-lock.yaml | 143 ++++++++++++++++++++++++++----------------------- 2 files changed, 80 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index 38c7ec1a..ec334f55 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,10 @@ "license": "ISC", "packageManager": "pnpm@10.28.2", "pnpm": { - "onlyBuiltDependencies": ["electron", "esbuild"] + "onlyBuiltDependencies": [ + "electron", + "esbuild" + ] }, "devDependencies": { "@types/node": "catalog:", @@ -57,6 +60,7 @@ "@nestjs/serve-static": "^5.0.4", "@nestjs/websockets": "^11.1.12", "@sinclair/typebox": "^0.34.41", + "croner": "^10.0.1", "fast-glob": "^3.3.3", "json5": "^2.2.3", "linkedom": "^0.18.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e60a83f..e52cfc33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,13 +34,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -68,6 +68,9 @@ importers: '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 + croner: + specifier: ^10.0.1 + version: 10.0.1 fast-glob: specifier: ^3.3.3 version: 3.3.3 @@ -288,7 +291,7 @@ importers: version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6.0.23 - version: 6.0.23(w7u2e3gmpia3npio76ytuzityu) + version: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3) expo-splash-screen: specifier: ~31.0.13 version: 31.0.13(expo@54.0.33) @@ -1905,89 +1908,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@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] + libc: [glibc] '@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] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@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] + libc: [glibc] '@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] + libc: [musl] '@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] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2152,24 +2171,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.0': resolution: {integrity: sha512-4BC08CIaOXSSAGRZLEjqJmQfioED8ohAzwt0k2amZPEbH96YKoBNorq5EdwPf5VT+odS0DeyCwhwtxokRLZIvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.0': resolution: {integrity: sha512-GpNY5Y9nOzr0Vt0Qi5U88qwe6piiIHk44kSMexl8ns90LluN5UTNYmyfi7Xq3/lmPZCpnB2xvBTYbsXCxnopIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.0': resolution: {integrity: sha512-+PnR48/x9GMY5Kh8BLjzHMx6trOegMtxAuqTM9X/bhV3QuW6sLLd7nojDHSGj/ZueK6i0tcQxvOrgNLozVtNDA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.0': resolution: {integrity: sha512-+dy2vZ1Ph4EYj0cotB+bVUVk/uKl2bh9LOp/zlnFqoCCYDN6sm+L0VyIOPPo3hjoEVdGpHe1MUxp3qG/OLwXgg==} @@ -2331,24 +2354,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.6': resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} @@ -2812,66 +2839,79 @@ packages: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.0': resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.0': resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.0': resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.0': resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.0': resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.0': resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.0': resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.0': resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.0': resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.0': resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.0': resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.0': resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.0': resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} @@ -3196,24 +3236,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3728,41 +3772,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4585,6 +4637,10 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -6457,48 +6513,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.27.0: resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.27.0: resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.27.0: resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.27.0: resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==} @@ -9376,12 +9440,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 3.25.76 - '@anthropic-ai/sdk@0.71.2(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -10910,7 +10968,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.23(w7u2e3gmpia3npio76ytuzityu) + expo-router: 6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3) react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) transitivePeerDependencies: - bufferutil @@ -11159,17 +11217,6 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))': - dependencies: - google-auth-library: 10.5.0 - ws: 8.18.3 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))': dependencies: google-auth-library: 10.5.0 @@ -11510,19 +11557,6 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': - dependencies: - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) - '@mariozechner/pi-tui': 0.50.3 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) @@ -11536,30 +11570,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': - dependencies: - '@anthropic-ai/sdk': 0.71.2(zod@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.978.0 - '@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76)) - '@mistralai/mistralai': 1.10.0 - '@sinclair/typebox': 0.34.48 - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - chalk: 5.6.2 - openai: 6.10.0(ws@8.18.3)(zod@3.25.76) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.19.2 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.6) @@ -11584,12 +11594,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -14321,6 +14331,8 @@ snapshots: crelt@1.0.6: {} + croner@10.0.1: {} + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -15291,7 +15303,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) - expo-router@6.0.23(w7u2e3gmpia3npio76ytuzityu): + expo-router@6.0.23(52130e04a12e6efd1d6e48b3f2ad01c3): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@expo/schema-utils': 0.1.8 @@ -17693,11 +17705,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.10.0(ws@8.18.3)(zod@3.25.76): - optionalDependencies: - ws: 8.18.3 - zod: 3.25.76 - openai@6.10.0(ws@8.18.3)(zod@4.3.6): optionalDependencies: ws: 8.18.3 From de355cace37366cae40aa5464c21413694c1d1ea Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 17:46:57 +0800 Subject: [PATCH 02/35] feat(cron): add cron job scheduling module Implements a timer-based cron job system: - types.ts: Job types (at, every, cron schedules) - schedule.ts: Next run computation using croner - store.ts: Persistent JSON storage with JSONL run logs - service.ts: CronService with timer management - execute.ts: Job execution (system-event, agent-turn) Based on OpenClaw's implementation (MIT License). Co-Authored-By: Claude Opus 4.5 --- src/cron/execute.ts | 139 ++++++++++++++ src/cron/index.ts | 39 ++++ src/cron/schedule.ts | 187 +++++++++++++++++++ src/cron/service.ts | 432 +++++++++++++++++++++++++++++++++++++++++++ src/cron/store.ts | 216 ++++++++++++++++++++++ src/cron/types.ts | 116 ++++++++++++ 6 files changed, 1129 insertions(+) create mode 100644 src/cron/execute.ts create mode 100644 src/cron/index.ts create mode 100644 src/cron/schedule.ts create mode 100644 src/cron/service.ts create mode 100644 src/cron/store.ts create mode 100644 src/cron/types.ts diff --git a/src/cron/execute.ts b/src/cron/execute.ts new file mode 100644 index 00000000..23b1526e --- /dev/null +++ b/src/cron/execute.ts @@ -0,0 +1,139 @@ +/** + * Cron Job Execution + * + * Handles the actual execution of cron job payloads. + * Based on OpenClaw's implementation (MIT License) + */ + +import type { CronJob } from "./types.js"; +import { getHub, isHubInitialized } from "../hub/hub-singleton.js"; + +/** Execution result */ +export type ExecutionResult = { + summary?: string; + error?: string; +}; + +/** + * Execute a cron job payload. + * + * For system-event: Injects text into the main session + * For agent-turn: Creates an isolated agent turn + */ +export async function executeCronJob(job: CronJob): Promise { + const { payload } = job; + + switch (payload.kind) { + case "system-event": + return executeSystemEvent(job); + case "agent-turn": + return executeAgentTurn(job); + default: + return { error: `Unknown payload kind: ${(payload as { kind: string }).kind}` }; + } +} + +/** + * Execute a system-event payload. + * Injects the text into the main session as a system message. + */ +async function executeSystemEvent(job: CronJob): Promise { + if (!isHubInitialized()) { + return { error: "Hub not available" }; + } + const hub = getHub(); + + const payload = job.payload as { kind: "system-event"; text: string }; + + // Get the list of active agents + const agentIds = hub.listAgents(); + if (agentIds.length === 0) { + return { error: "No active agents" }; + } + + // For now, inject into the first (main) agent + // TODO: Support targeting specific agent by ID + const agentId = agentIds[0]!; + const agent = hub.getAgent(agentId); + if (!agent || agent.closed) { + return { error: `Agent ${agentId} not found or closed` }; + } + + // Format the cron message with metadata + const cronMessage = `[CRON] ${job.name}: ${payload.text}`; + + try { + // Write to agent (non-blocking, will be processed in queue) + agent.write(cronMessage); + + // Wait for the agent to process the message + await agent.waitForIdle(); + + return { summary: `Injected message into agent ${agentId.slice(0, 8)}` }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Execute an agent-turn payload. + * Creates an isolated subagent to run the task. + */ +async function executeAgentTurn(job: CronJob): Promise { + if (!isHubInitialized()) { + return { error: "Hub not available" }; + } + const hub = getHub(); + + const payload = job.payload as { + kind: "agent-turn"; + message: string; + model?: string; + thinkingLevel?: string; + timeoutSeconds?: number; + }; + + // Generate a unique session ID for this isolated run + const sessionId = `cron-${job.id}-${Date.now()}`; + + try { + // Create isolated subagent + // TODO: Support model/thinkingLevel override + const agent = hub.createSubagent(sessionId, { + profileId: "default", + }); + + // Set up timeout if specified + const timeoutMs = (payload.timeoutSeconds ?? 300) * 1000; // default 5 minutes + let timeoutHandle: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Cron job timed out after ${payload.timeoutSeconds}s`)); + }, timeoutMs); + }); + + // Execute the agent turn + const executePromise = (async (): Promise => { + const cronMessage = `[CRON Job: ${job.name}]\n\n${payload.message}`; + agent.write(cronMessage); + await agent.waitForIdle(); + return { summary: `Completed agent turn in isolated session ${sessionId.slice(0, 16)}` }; + })(); + + // Race between execution and timeout + const result = await Promise.race([executePromise, timeoutPromise]); + + // Clear timeout + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + + // Close the subagent + agent.close(); + + return result; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/cron/index.ts b/src/cron/index.ts new file mode 100644 index 00000000..21d4b659 --- /dev/null +++ b/src/cron/index.ts @@ -0,0 +1,39 @@ +/** + * Cron Module + * + * Provides scheduled task functionality for Super Multica. + */ + +export type { + CronSchedule, + CronSessionTarget, + CronWakeMode, + CronPayload, + CronJobState, + CronJob, + CronJobInput, + CronJobPatch, + CronRunLogEntry, + CronConfig, +} from "./types.js"; + +export { + computeNextRunAtMs, + isValidCronExpr, + parseTimeInput, + parseIntervalInput, + formatSchedule, + formatDuration, +} from "./schedule.js"; + +export { CronStore } from "./store.js"; + +export { + CronService, + getCronService, + shutdownCronService, + type CronJobExecutor, + type CronServiceStatus, +} from "./service.js"; + +export { executeCronJob, type ExecutionResult } from "./execute.js"; diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts new file mode 100644 index 00000000..e06d02af --- /dev/null +++ b/src/cron/schedule.ts @@ -0,0 +1,187 @@ +/** + * Cron Schedule Computation + * + * Based on OpenClaw's implementation (MIT License) + */ + +import { Cron } from "croner"; +import type { CronSchedule } from "./types.js"; + +/** + * Compute the next run time for a schedule. + * + * @param schedule - The schedule configuration + * @param nowMs - Current time in milliseconds (default: Date.now()) + * @returns Next run time in ms, or undefined if no future run + */ +export function computeNextRunAtMs( + schedule: CronSchedule, + nowMs: number = Date.now(), +): number | undefined { + switch (schedule.kind) { + case "at": + // One-shot: return the timestamp if it's in the future + return schedule.atMs > nowMs ? schedule.atMs : undefined; + + case "every": { + // Fixed interval: compute next occurrence + const everyMs = Math.max(1, Math.floor(schedule.everyMs)); + const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs)); + + if (nowMs < anchor) return anchor; + + const elapsed = nowMs - anchor; + const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs)); + return anchor + steps * everyMs; + } + + case "cron": { + // Cron expression: use croner to compute next run + const expr = schedule.expr.trim(); + if (!expr) return undefined; + + try { + const tz = schedule.tz?.trim(); + const cron = tz ? new Cron(expr, { timezone: tz }) : new Cron(expr); + const next = cron.nextRun(new Date(nowMs)); + return next ? next.getTime() : undefined; + } catch (error) { + console.error(`[Cron] Invalid cron expression: ${expr}`, error); + return undefined; + } + } + } +} + +/** + * Validate a cron expression. + * + * @param expr - Cron expression (5-field) + * @param tz - Optional timezone + * @returns true if valid, false otherwise + */ +export function isValidCronExpr(expr: string, tz?: string): boolean { + try { + const timezone = tz?.trim(); + if (timezone) { + new Cron(expr.trim(), { timezone }); + } else { + new Cron(expr.trim()); + } + return true; + } catch { + return false; + } +} + +/** + * Parse a human-readable time string into milliseconds. + * + * Supports: + * - Relative: "10s", "5m", "2h", "1d" + * - ISO 8601: "2024-01-15T09:00:00Z" + * - Unix timestamp (if numeric) + * + * @param input - Time string + * @param nowMs - Current time for relative calculations + * @returns Timestamp in ms, or undefined if invalid + */ +export function parseTimeInput(input: string, nowMs: number = Date.now()): number | undefined { + const trimmed = input.trim(); + + // Check for relative time (e.g., "10m", "2h") + const relativeMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i); + if (relativeMatch) { + const [, numStr, unit] = relativeMatch; + const num = parseFloat(numStr!); + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + }; + const ms = multipliers[unit!.toLowerCase()]; + if (ms !== undefined) { + return nowMs + num * ms; + } + } + + // Check for numeric (unix timestamp in ms or seconds) + if (/^\d+$/.test(trimmed)) { + const num = parseInt(trimmed, 10); + // If it looks like seconds (before year 2100), convert to ms + if (num < 4102444800) { + return num * 1000; + } + return num; + } + + // Try ISO 8601 date parsing + const date = new Date(trimmed); + if (!isNaN(date.getTime())) { + return date.getTime(); + } + + return undefined; +} + +/** + * Parse an interval string into milliseconds. + * + * Supports: "30s", "5m", "2h", "1d", or raw milliseconds + * + * @param input - Interval string + * @returns Interval in ms, or undefined if invalid + */ +export function parseIntervalInput(input: string): number | undefined { + const trimmed = input.trim(); + + // Check for duration format (e.g., "30m", "2h") + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhd])$/i); + if (match) { + const [, numStr, unit] = match; + const num = parseFloat(numStr!); + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + }; + const ms = multipliers[unit!.toLowerCase()]; + if (ms !== undefined) { + return num * ms; + } + } + + // Check for raw milliseconds + if (/^\d+$/.test(trimmed)) { + return parseInt(trimmed, 10); + } + + return undefined; +} + +/** + * Format a schedule for display. + */ +export function formatSchedule(schedule: CronSchedule): string { + switch (schedule.kind) { + case "at": + return `at ${new Date(schedule.atMs).toISOString()}`; + case "every": + return `every ${formatDuration(schedule.everyMs)}`; + case "cron": + return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`; + } +} + +/** + * Format milliseconds as human-readable duration. + */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60 * 1000) return `${Math.round(ms / 1000)}s`; + if (ms < 60 * 60 * 1000) return `${Math.round(ms / (60 * 1000))}m`; + if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h`; + return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`; +} diff --git a/src/cron/service.ts b/src/cron/service.ts new file mode 100644 index 00000000..ff5c911e --- /dev/null +++ b/src/cron/service.ts @@ -0,0 +1,432 @@ +/** + * Cron Service + * + * Manages scheduled jobs with timer-based execution. + * Based on OpenClaw's implementation (MIT License) + */ + +import { v7 as uuidv7 } from "uuid"; +import type { + CronJob, + CronJobInput, + CronJobPatch, + CronJobState, + CronRunLogEntry, + CronConfig, +} from "./types.js"; +import { CronStore } from "./store.js"; +import { computeNextRunAtMs } from "./schedule.js"; + +/** Callback for job execution */ +export type CronJobExecutor = (job: CronJob) => Promise<{ summary?: string; error?: string }>; + +/** Service status */ +export type CronServiceStatus = { + running: boolean; + enabled: boolean; + storePath: string; + jobCount: number; + enabledJobCount: number; + nextWakeAtMs: number | null; +}; + +/** Default stuck job timeout (2 hours) */ +const STUCK_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000; + +export class CronService { + private readonly store: CronStore; + private readonly config: CronConfig; + private timer: NodeJS.Timeout | null = null; + private running = false; + private executor: CronJobExecutor | null = null; + + constructor(config: CronConfig = {}) { + this.config = { + enabled: config.enabled ?? true, + maxConcurrentRuns: config.maxConcurrentRuns ?? 1, + ...config, + }; + this.store = new CronStore(config.storePath); + } + + /** + * Set the job executor callback. + * This is called when a job needs to be executed. + */ + setExecutor(executor: CronJobExecutor): void { + this.executor = executor; + } + + /** + * Start the cron service. + * Loads jobs from disk, computes schedules, and starts the timer. + */ + async start(): Promise { + if (this.running) return; + if (!this.config.enabled) { + console.log("[CronService] Cron is disabled by config"); + return; + } + + this.running = true; + console.log("[CronService] Starting..."); + + // Load jobs and compute next run times + const jobs = this.store.load(); + console.log(`[CronService] Loaded ${jobs.length} jobs`); + + // Recompute all schedules + this.recomputeAllSchedules(); + + // Clear any stuck jobs (running for > 2 hours) + this.clearStuckJobs(); + + // Arm timer for next job + this.armTimer(); + + console.log("[CronService] Started"); + } + + /** + * Stop the cron service. + */ + stop(): void { + if (!this.running) return; + + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + console.log("[CronService] Stopped"); + } + + /** + * Get service status. + */ + status(): CronServiceStatus { + const allJobs = this.store.list(); + const enabledJobs = this.store.list({ enabled: true }); + + const nextWake = enabledJobs.reduce((min, job) => { + const next = job.state.nextRunAtMs; + return next !== undefined && next < min ? next : min; + }, Infinity); + + return { + running: this.running, + enabled: this.config.enabled ?? true, + storePath: this.store.getStorePath(), + jobCount: allJobs.length, + enabledJobCount: enabledJobs.length, + nextWakeAtMs: nextWake === Infinity ? null : nextWake, + }; + } + + /** + * List jobs with optional filter. + */ + list(filter?: { enabled?: boolean }): CronJob[] { + return this.store.list(filter); + } + + /** + * Get a job by ID. + */ + get(id: string): CronJob | undefined { + return this.store.get(id); + } + + /** + * Add a new job. + */ + add(input: CronJobInput): CronJob { + const now = Date.now(); + const job: CronJob = { + ...input, + id: uuidv7(), + createdAtMs: now, + updatedAtMs: now, + state: {}, + }; + + // Compute initial next run time + this.computeNextRun(job); + + this.store.set(job); + console.log(`[CronService] Added job: ${job.name} (${job.id}), next run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`); + + // Re-arm timer in case this job runs sooner + if (this.running) { + this.armTimer(); + } + + return job; + } + + /** + * Update an existing job. + */ + update(id: string, patch: CronJobPatch): CronJob | null { + const job = this.store.get(id); + if (!job) return null; + + // Apply patch + Object.assign(job, patch, { updatedAtMs: Date.now() }); + + // Recompute schedule if changed + if (patch.schedule || patch.enabled !== undefined) { + this.computeNextRun(job); + } + + this.store.set(job); + console.log(`[CronService] Updated job: ${job.name} (${job.id})`); + + // Re-arm timer + if (this.running) { + this.armTimer(); + } + + return job; + } + + /** + * Remove a job. + */ + remove(id: string): boolean { + const job = this.store.get(id); + if (!job) return false; + + const deleted = this.store.delete(id); + if (deleted) { + console.log(`[CronService] Removed job: ${job.name} (${id})`); + } + + return deleted; + } + + /** + * Run a job immediately. + * + * @param id - Job ID + * @param force - Run even if disabled + */ + async run(id: string, force = false): Promise<{ ok: boolean; reason?: string }> { + const job = this.store.get(id); + if (!job) { + return { ok: false, reason: "Job not found" }; + } + + if (!job.enabled && !force) { + return { ok: false, reason: "Job is disabled" }; + } + + if (job.state.runningAtMs) { + return { ok: false, reason: "Job is already running" }; + } + + await this.executeJob(job); + return { ok: true }; + } + + /** + * Get run logs for a job. + */ + getRunLogs(id: string, limit?: number): CronRunLogEntry[] { + return this.store.getRunLogs(id, limit); + } + + // === Private Methods === + + /** + * Compute next run time for a job. + */ + private computeNextRun(job: CronJob): void { + if (!job.enabled) { + job.state.nextRunAtMs = undefined; + return; + } + + const now = Date.now(); + const nextMs = computeNextRunAtMs(job.schedule, now); + job.state.nextRunAtMs = nextMs; + } + + /** + * Recompute schedules for all enabled jobs. + */ + private recomputeAllSchedules(): void { + for (const job of this.store.list({ enabled: true })) { + this.computeNextRun(job); + this.store.set(job); + } + } + + /** + * Clear stuck jobs (running for too long). + */ + private clearStuckJobs(): void { + const now = Date.now(); + for (const job of this.store.list()) { + if (job.state.runningAtMs && now - job.state.runningAtMs > STUCK_JOB_TIMEOUT_MS) { + console.warn(`[CronService] Clearing stuck job: ${job.name} (${job.id})`); + job.state.runningAtMs = undefined; + job.state.lastStatus = "error"; + job.state.lastError = "Job was stuck (running > 2 hours)"; + this.store.set(job); + } + } + } + + /** + * Arm the timer for the next due job. + */ + private armTimer(): void { + if (!this.running) return; + + // Clear existing timer + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + // Find next wake time + const enabledJobs = this.store.list({ enabled: true }); + const nextWake = enabledJobs.reduce((min, job) => { + const next = job.state.nextRunAtMs; + return next !== undefined && next < min ? next : min; + }, Infinity); + + if (nextWake === Infinity) { + // No jobs to run + return; + } + + const delay = Math.max(0, nextWake - Date.now()); + this.timer = setTimeout(() => this.onTimer(), delay); + } + + /** + * Timer callback: run all due jobs. + */ + private async onTimer(): Promise { + if (!this.running) return; + + const now = Date.now(); + const dueJobs = this.store + .list({ enabled: true }) + .filter((j) => { + const next = j.state.nextRunAtMs; + return next !== undefined && next <= now && !j.state.runningAtMs; + }); + + for (const job of dueJobs) { + try { + await this.executeJob(job); + } catch (error) { + console.error(`[CronService] Error executing job ${job.id}:`, error); + } + } + + // Re-arm timer for next batch + this.armTimer(); + } + + /** + * Execute a single job. + */ + private async executeJob(job: CronJob): Promise { + const startMs = Date.now(); + console.log(`[CronService] Executing job: ${job.name} (${job.id})`); + + // Mark as running + job.state.runningAtMs = startMs; + this.store.set(job); + + let status: "ok" | "error" = "ok"; + let error: string | undefined; + let summary: string | undefined; + + try { + if (this.executor) { + const result = await this.executor(job); + summary = result.summary; + if (result.error) { + status = "error"; + error = result.error; + } + } else { + // No executor set, just log + console.log(`[CronService] Job ${job.id} payload:`, job.payload); + } + } catch (err) { + status = "error"; + error = err instanceof Error ? err.message : String(err); + console.error(`[CronService] Job ${job.id} failed:`, err); + } + + const durationMs = Date.now() - startMs; + + // Update job state + job.state.runningAtMs = undefined; + job.state.lastRunAtMs = startMs; + job.state.lastStatus = status; + job.state.lastError = error; + job.state.lastDurationMs = durationMs; + + // Handle one-shot jobs + if (job.schedule.kind === "at") { + if (status === "ok" && job.deleteAfterRun) { + this.store.delete(job.id); + console.log(`[CronService] Deleted one-shot job: ${job.name} (${job.id})`); + } else { + job.enabled = false; + job.state.nextRunAtMs = undefined; + this.store.set(job); + } + } else { + // Compute next run for recurring jobs + this.computeNextRun(job); + this.store.set(job); + } + + // Append run log + this.store.appendRunLog(job.id, { + ts: startMs, + jobId: job.id, + action: status === "ok" ? "run" : "error", + status, + error, + summary, + durationMs, + nextRunAtMs: job.state.nextRunAtMs, + }); + + console.log(`[CronService] Job ${job.id} completed: ${status} (${durationMs}ms)`); + } +} + +// === Singleton === + +let cronServiceInstance: CronService | null = null; + +/** + * Get or create the singleton CronService instance. + */ +export function getCronService(config?: CronConfig): CronService { + if (!cronServiceInstance) { + cronServiceInstance = new CronService(config); + } + return cronServiceInstance; +} + +/** + * Shutdown the singleton CronService. + */ +export function shutdownCronService(): void { + if (cronServiceInstance) { + cronServiceInstance.stop(); + cronServiceInstance = null; + } +} diff --git a/src/cron/store.ts b/src/cron/store.ts new file mode 100644 index 00000000..fe4ca903 --- /dev/null +++ b/src/cron/store.ts @@ -0,0 +1,216 @@ +/** + * Cron Job Storage + * + * Persists jobs to JSON file and run logs to JSONL files. + * Based on OpenClaw's implementation (MIT License) + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "fs"; +import path from "path"; +import type { CronJob, CronRunLogEntry } from "./types.js"; + +/** Default cron storage directory */ +const DEFAULT_CRON_DIR = path.join( + process.env["HOME"] ?? ".", + ".super-multica", + "cron", +); + +/** Store data structure */ +type StoreData = { + version: number; + jobs: CronJob[]; +}; + +const STORE_VERSION = 1; + +export class CronStore { + private readonly jobsPath: string; + private readonly runsDir: string; + private jobs: Map = new Map(); + private loaded = false; + + constructor(baseDir: string = DEFAULT_CRON_DIR) { + this.jobsPath = path.join(baseDir, "jobs.json"); + this.runsDir = path.join(baseDir, "runs"); + } + + /** Ensure directories exist */ + private ensureDirs() { + const dir = path.dirname(this.jobsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!existsSync(this.runsDir)) { + mkdirSync(this.runsDir, { recursive: true }); + } + } + + /** Load jobs from disk */ + load(): CronJob[] { + this.ensureDirs(); + + if (!existsSync(this.jobsPath)) { + this.jobs = new Map(); + this.loaded = true; + return []; + } + + try { + const raw = readFileSync(this.jobsPath, "utf-8"); + const data: StoreData = JSON.parse(raw); + + // Validate version + if (data.version !== STORE_VERSION) { + console.warn(`[CronStore] Store version mismatch: ${data.version} vs ${STORE_VERSION}`); + } + + this.jobs = new Map(data.jobs.map((j) => [j.id, j])); + this.loaded = true; + return Array.from(this.jobs.values()); + } catch (error) { + console.error("[CronStore] Failed to load jobs:", error); + this.jobs = new Map(); + this.loaded = true; + return []; + } + } + + /** Save jobs to disk */ + save(): void { + this.ensureDirs(); + + const data: StoreData = { + version: STORE_VERSION, + jobs: Array.from(this.jobs.values()), + }; + + // Write to temp file first, then rename (atomic) + const tmpPath = this.jobsPath + ".tmp"; + const bakPath = this.jobsPath + ".bak"; + + try { + writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + + // Backup existing file + if (existsSync(this.jobsPath)) { + writeFileSync(bakPath, readFileSync(this.jobsPath)); + } + + // Rename temp to actual (atomic on most filesystems) + const fs = require("fs"); + fs.renameSync(tmpPath, this.jobsPath); + } catch (error) { + console.error("[CronStore] Failed to save jobs:", error); + throw error; + } + } + + /** Ensure store is loaded */ + private ensureLoaded() { + if (!this.loaded) { + this.load(); + } + } + + /** Get a job by ID */ + get(id: string): CronJob | undefined { + this.ensureLoaded(); + return this.jobs.get(id); + } + + /** Set (create or update) a job */ + set(job: CronJob): void { + this.ensureLoaded(); + this.jobs.set(job.id, job); + this.save(); + } + + /** Delete a job by ID */ + delete(id: string): boolean { + this.ensureLoaded(); + const deleted = this.jobs.delete(id); + if (deleted) { + this.save(); + } + return deleted; + } + + /** List all jobs, optionally filtered */ + list(filter?: { enabled?: boolean }): CronJob[] { + this.ensureLoaded(); + let jobs = Array.from(this.jobs.values()); + + if (filter?.enabled !== undefined) { + jobs = jobs.filter((j) => j.enabled === filter.enabled); + } + + // Sort by next run time + jobs.sort((a, b) => { + const aNext = a.state.nextRunAtMs ?? Infinity; + const bNext = b.state.nextRunAtMs ?? Infinity; + return aNext - bNext; + }); + + return jobs; + } + + /** Get job count */ + count(filter?: { enabled?: boolean }): number { + return this.list(filter).length; + } + + // === Run Log Methods === + + /** Append a run log entry */ + appendRunLog(jobId: string, entry: CronRunLogEntry): void { + this.ensureDirs(); + const logPath = path.join(this.runsDir, `${jobId}.jsonl`); + const line = JSON.stringify(entry) + "\n"; + appendFileSync(logPath, line, "utf-8"); + } + + /** Get run logs for a job */ + getRunLogs(jobId: string, limit = 50): CronRunLogEntry[] { + const logPath = path.join(this.runsDir, `${jobId}.jsonl`); + + if (!existsSync(logPath)) { + return []; + } + + try { + const content = readFileSync(logPath, "utf-8").trim(); + if (!content) return []; + + const lines = content.split("\n"); + const entries = lines + .slice(-limit) + .map((line) => { + try { + return JSON.parse(line) as CronRunLogEntry; + } catch { + return null; + } + }) + .filter((e): e is CronRunLogEntry => e !== null); + + return entries; + } catch (error) { + console.error(`[CronStore] Failed to read run logs for ${jobId}:`, error); + return []; + } + } + + /** Clear run logs for a job */ + clearRunLogs(jobId: string): void { + const logPath = path.join(this.runsDir, `${jobId}.jsonl`); + if (existsSync(logPath)) { + writeFileSync(logPath, "", "utf-8"); + } + } + + /** Get the store path (for status display) */ + getStorePath(): string { + return this.jobsPath; + } +} diff --git a/src/cron/types.ts b/src/cron/types.ts new file mode 100644 index 00000000..72867f1b --- /dev/null +++ b/src/cron/types.ts @@ -0,0 +1,116 @@ +/** + * Cron Job Types + * + * Based on OpenClaw's implementation (MIT License) + */ + +/** Cron schedule: one-shot, interval, or cron expression */ +export type CronSchedule = + | { kind: "at"; atMs: number } + | { kind: "every"; everyMs: number; anchorMs?: number } + | { kind: "cron"; expr: string; tz?: string }; + +/** Where to run the job */ +export type CronSessionTarget = "main" | "isolated"; + +/** When to wake after job execution */ +export type CronWakeMode = "next-heartbeat" | "now"; + +/** Job payload: what to execute */ +export type CronPayload = + | { + kind: "system-event"; + /** Text to inject into main session */ + text: string; + } + | { + kind: "agent-turn"; + /** Message/prompt for the agent */ + message: string; + /** Optional model override (e.g., "anthropic/claude-3-opus") */ + model?: string; + /** Optional thinking level override */ + thinkingLevel?: string; + /** Timeout in seconds */ + timeoutSeconds?: number; + }; + +/** Runtime state of a job */ +export type CronJobState = { + /** Next scheduled run (ms since epoch) */ + nextRunAtMs?: number | undefined; + /** Currently running (lock marker, ms since epoch) */ + runningAtMs?: number | undefined; + /** Last completed run (ms since epoch) */ + lastRunAtMs?: number | undefined; + /** Last run status */ + lastStatus?: "ok" | "error" | "skipped" | undefined; + /** Last error message */ + lastError?: string | undefined; + /** Last run duration in ms */ + lastDurationMs?: number | undefined; +}; + +/** Cron job definition */ +export type CronJob = { + /** Unique identifier (UUIDv7) */ + id: string; + /** User-friendly name */ + name: string; + /** Optional description */ + description?: string; + /** Whether the job is enabled */ + enabled: boolean; + /** Delete after successful one-shot run */ + deleteAfterRun?: boolean; + /** Creation timestamp (ms) */ + createdAtMs: number; + /** Last update timestamp (ms) */ + updatedAtMs: number; + /** When to run */ + schedule: CronSchedule; + /** Where to run (main session or isolated) */ + sessionTarget: CronSessionTarget; + /** Wake mode after execution */ + wakeMode: CronWakeMode; + /** What to execute */ + payload: CronPayload; + /** Runtime state */ + state: CronJobState; +}; + +/** Input for creating a new job (without auto-generated fields) */ +export type CronJobInput = Omit; + +/** Input for updating an existing job */ +export type CronJobPatch = Partial>; + +/** Run log entry */ +export type CronRunLogEntry = { + /** Timestamp (ms) */ + ts: number; + /** Job ID */ + jobId: string; + /** Action taken */ + action: "run" | "skip" | "error"; + /** Result status */ + status: "ok" | "error" | "skipped"; + /** Error message if failed */ + error?: string | undefined; + /** Summary of execution (for agent-turn) */ + summary?: string | undefined; + /** Duration in ms */ + durationMs?: number | undefined; + /** Next scheduled run */ + nextRunAtMs?: number | undefined; +}; + +/** Cron service configuration */ +export type CronConfig = { + /** Whether cron is enabled (default: true) */ + enabled?: boolean; + /** Custom store path */ + storePath?: string; + /** Max concurrent job runs (default: 1) */ + maxConcurrentRuns?: number; +}; From 9d0cc6fdf678af3ec3b6344aab2755f7f88ad941 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 17:47:06 +0800 Subject: [PATCH 03/35] feat(tools): add cron tool for agent job management Adds a cron tool that allows agents to create, manage, and execute scheduled tasks. Supports: - status: Get service status - list: List all jobs with optional filters - add: Create one-shot, interval, or cron jobs - update: Modify existing jobs - remove: Delete jobs - run: Execute jobs immediately - logs: View run history Co-Authored-By: Claude Opus 4.5 --- src/agent/tools/cron/cron-tool.ts | 467 ++++++++++++++++++++++++++++++ src/agent/tools/cron/index.ts | 5 + src/agent/tools/index.ts | 1 + 3 files changed, 473 insertions(+) create mode 100644 src/agent/tools/cron/cron-tool.ts create mode 100644 src/agent/tools/cron/index.ts diff --git a/src/agent/tools/cron/cron-tool.ts b/src/agent/tools/cron/cron-tool.ts new file mode 100644 index 00000000..1d0aa941 --- /dev/null +++ b/src/agent/tools/cron/cron-tool.ts @@ -0,0 +1,467 @@ +/** + * Cron Tool for Agent + * + * Allows agents to create, manage, and execute scheduled tasks. + * Based on OpenClaw's implementation (MIT License) + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { + getCronService, + formatSchedule, + formatDuration, + parseTimeInput, + parseIntervalInput, + isValidCronExpr, + type CronSchedule, + type CronJobInput, +} from "../../../cron/index.js"; + +const CronSchema = Type.Object({ + action: Type.Union([ + Type.Literal("status"), + Type.Literal("list"), + Type.Literal("add"), + Type.Literal("update"), + Type.Literal("remove"), + Type.Literal("run"), + Type.Literal("logs"), + ], { description: "The action to perform" }), + + // list filter + enabled: Type.Optional(Type.Boolean({ description: "Filter by enabled status (for list)" })), + + // add + name: Type.Optional(Type.String({ description: "Job name" })), + description: Type.Optional(Type.String({ description: "Job description" })), + schedule: Type.Optional(Type.Object({ + kind: Type.Union([Type.Literal("at"), Type.Literal("every"), Type.Literal("cron")]), + at: Type.Optional(Type.String({ description: "Time for one-shot (ISO 8601 or relative like '10m')" })), + every: Type.Optional(Type.String({ description: "Interval (e.g., '30m', '2h')" })), + expr: Type.Optional(Type.String({ description: "Cron expression (5-field)" })), + tz: Type.Optional(Type.String({ description: "Timezone for cron expression" })), + })), + sessionTarget: Type.Optional(Type.Union([ + Type.Literal("main"), + Type.Literal("isolated"), + ], { description: "Where to run the job (main session or isolated)" })), + payload: Type.Optional(Type.Object({ + kind: Type.Union([Type.Literal("system-event"), Type.Literal("agent-turn")]), + text: Type.Optional(Type.String({ description: "Text for system-event" })), + message: Type.Optional(Type.String({ description: "Prompt for agent-turn" })), + timeoutSeconds: Type.Optional(Type.Number({ description: "Timeout for agent-turn" })), + })), + deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after one-time run" })), + wakeMode: Type.Optional(Type.Union([ + Type.Literal("next-heartbeat"), + Type.Literal("now"), + ], { description: "When to wake after job execution" })), + + // update/remove/run/logs + jobId: Type.Optional(Type.String({ description: "Job ID" })), + + // run + force: Type.Optional(Type.Boolean({ description: "Force run even if disabled" })), + + // logs + limit: Type.Optional(Type.Number({ description: "Number of log entries to return" })), +}); + +type CronArgs = { + action: "status" | "list" | "add" | "update" | "remove" | "run" | "logs"; + enabled?: boolean; + name?: string; + description?: string; + schedule?: { + kind: "at" | "every" | "cron"; + at?: string; + every?: string; + expr?: string; + tz?: string; + }; + sessionTarget?: "main" | "isolated"; + payload?: { + kind: "system-event" | "agent-turn"; + text?: string; + message?: string; + timeoutSeconds?: number; + }; + deleteAfterRun?: boolean; + wakeMode?: "next-heartbeat" | "now"; + jobId?: string; + force?: boolean; + limit?: number; +}; + +export type CronResult = { + success: boolean; + message: string; + data?: unknown; +}; + +/** Parse schedule from tool parameters */ +function parseSchedule(schedule: CronArgs["schedule"]): CronSchedule | { error: string } { + if (!schedule) { + return { error: "schedule is required" }; + } + + switch (schedule.kind) { + case "at": { + const at = schedule.at; + if (!at) { + return { error: "schedule.at is required for kind='at'" }; + } + const atMs = parseTimeInput(at); + if (!atMs) { + return { error: `Invalid time format: ${at}` }; + } + return { kind: "at", atMs }; + } + + case "every": { + const every = schedule.every; + if (!every) { + return { error: "schedule.every is required for kind='every'" }; + } + const everyMs = parseIntervalInput(every); + if (!everyMs) { + return { error: `Invalid interval format: ${every}` }; + } + return { kind: "every", everyMs }; + } + + case "cron": { + const expr = schedule.expr; + if (!expr) { + return { error: "schedule.expr is required for kind='cron'" }; + } + const tz = schedule.tz; + if (!isValidCronExpr(expr, tz)) { + return { error: `Invalid cron expression: ${expr}` }; + } + // Only include tz if defined (exactOptionalPropertyTypes) + if (tz) { + return { kind: "cron", expr, tz }; + } + return { kind: "cron", expr }; + } + + default: + return { error: `Unknown schedule kind: ${schedule.kind}` }; + } +} + +const TOOL_DESCRIPTION = `Create, manage, and execute scheduled tasks (cron jobs). + +## Actions + +### status +Get cron service status. +\`\`\`json +{ "action": "status" } +\`\`\` + +### list +List all cron jobs. +\`\`\`json +{ "action": "list", "enabled": true } +\`\`\` + +### add +Create a new cron job. +\`\`\`json +{ + "action": "add", + "name": "Daily reminder", + "schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }, + "sessionTarget": "main", + "payload": { "kind": "system-event", "text": "Check your todos!" } +} +\`\`\` + +Schedule types: +- \`{ "kind": "at", "at": "10m" }\` - One-time, relative (10 minutes from now) +- \`{ "kind": "at", "at": "2024-12-31T23:59:00Z" }\` - One-time, absolute ISO time +- \`{ "kind": "every", "every": "30m" }\` - Every 30 minutes +- \`{ "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" }\` - Cron expression + +Payload types: +- \`{ "kind": "system-event", "text": "..." }\` - Inject text into main session +- \`{ "kind": "agent-turn", "message": "...", "timeoutSeconds": 300 }\` - Run isolated agent turn + +### update +Update an existing job. +\`\`\`json +{ "action": "update", "jobId": "xxx", "enabled": false } +\`\`\` + +### remove +Delete a job. +\`\`\`json +{ "action": "remove", "jobId": "xxx" } +\`\`\` + +### run +Execute a job immediately. +\`\`\`json +{ "action": "run", "jobId": "xxx", "force": true } +\`\`\` + +### logs +Get run logs for a job. +\`\`\`json +{ "action": "logs", "jobId": "xxx", "limit": 10 } +\`\`\` +`; + +/** Create the cron tool */ +export function createCronTool(): AgentTool { + return { + name: "cron", + label: "Cron", + description: TOOL_DESCRIPTION, + parameters: CronSchema, + execute: async (_toolCallId, args) => { + const { action } = args as CronArgs; + const service = getCronService(); + + try { + switch (action) { + case "status": { + const status = service.status(); + const output = JSON.stringify({ + running: status.running, + enabled: status.enabled, + jobCount: status.jobCount, + enabledJobCount: status.enabledJobCount, + nextWakeAt: status.nextWakeAtMs ? new Date(status.nextWakeAtMs).toISOString() : null, + storePath: status.storePath, + }, null, 2); + return { + content: [{ type: "text", text: output }], + details: { success: true, message: "Status retrieved", data: status }, + }; + } + + case "list": { + const params = args as CronArgs; + const filter = params.enabled !== undefined ? { enabled: params.enabled } : undefined; + const jobs = service.list(filter); + const formatted = jobs.map((job) => ({ + id: job.id, + name: job.name, + enabled: job.enabled, + schedule: formatSchedule(job.schedule), + sessionTarget: job.sessionTarget, + nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null, + lastStatus: job.state.lastStatus, + lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null, + })); + const output = JSON.stringify(formatted, null, 2); + return { + content: [{ type: "text", text: output }], + details: { success: true, message: `Found ${jobs.length} job(s)`, data: formatted }, + }; + } + + case "add": { + const params = args as CronArgs; + if (!params.name) { + return { + content: [{ type: "text", text: "Error: name is required" }], + details: { success: false, message: "name is required" }, + }; + } + + const schedule = parseSchedule(params.schedule); + if ("error" in schedule) { + return { + content: [{ type: "text", text: `Error: ${schedule.error}` }], + details: { success: false, message: schedule.error }, + }; + } + + if (!params.payload) { + return { + content: [{ type: "text", text: "Error: payload is required" }], + details: { success: false, message: "payload is required" }, + }; + } + + const { payload } = params; + let jobPayload; + if (payload.kind === "system-event") { + if (!payload.text) { + return { + content: [{ type: "text", text: "Error: payload.text is required for system-event" }], + details: { success: false, message: "payload.text is required for system-event" }, + }; + } + jobPayload = { kind: "system-event" as const, text: payload.text }; + } else if (payload.kind === "agent-turn") { + if (!payload.message) { + return { + content: [{ type: "text", text: "Error: payload.message is required for agent-turn" }], + details: { success: false, message: "payload.message is required for agent-turn" }, + }; + } + const agentPayload: { kind: "agent-turn"; message: string; timeoutSeconds?: number } = { + kind: "agent-turn", + message: payload.message, + }; + if (payload.timeoutSeconds !== undefined) { + agentPayload.timeoutSeconds = payload.timeoutSeconds; + } + jobPayload = agentPayload; + } else { + return { + content: [{ type: "text", text: `Error: Unknown payload kind` }], + details: { success: false, message: "Unknown payload kind" }, + }; + } + + const input: CronJobInput = { + name: params.name, + enabled: true, + schedule, + sessionTarget: params.sessionTarget ?? "main", + wakeMode: params.wakeMode ?? "now", + payload: jobPayload, + }; + if (params.description !== undefined) { + input.description = params.description; + } + if (params.deleteAfterRun !== undefined) { + input.deleteAfterRun = params.deleteAfterRun; + } + + const job = service.add(input); + const output = `Created job: ${job.name} (${job.id})\nSchedule: ${formatSchedule(job.schedule)}\nNext run: ${job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : "none"}`; + return { + content: [{ type: "text", text: output }], + details: { success: true, message: "Job created", data: job }, + }; + } + + case "update": { + const params = args as CronArgs; + if (!params.jobId) { + return { + content: [{ type: "text", text: "Error: jobId is required" }], + details: { success: false, message: "jobId is required" }, + }; + } + + const patch: Record = {}; + if (params.name !== undefined) patch.name = params.name; + if (params.description !== undefined) patch.description = params.description; + if (params.enabled !== undefined) patch.enabled = params.enabled; + if (params.schedule !== undefined) { + const schedule = parseSchedule(params.schedule); + if ("error" in schedule) { + return { + content: [{ type: "text", text: `Error: ${schedule.error}` }], + details: { success: false, message: schedule.error }, + }; + } + patch.schedule = schedule; + } + + const updated = service.update(params.jobId, patch); + if (!updated) { + return { + content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }], + details: { success: false, message: "Job not found" }, + }; + } + return { + content: [{ type: "text", text: `Updated job: ${updated.name} (${updated.id})` }], + details: { success: true, message: "Job updated", data: updated }, + }; + } + + case "remove": { + const params = args as CronArgs; + if (!params.jobId) { + return { + content: [{ type: "text", text: "Error: jobId is required" }], + details: { success: false, message: "jobId is required" }, + }; + } + + const removed = service.remove(params.jobId); + if (!removed) { + return { + content: [{ type: "text", text: `Error: Job not found: ${params.jobId}` }], + details: { success: false, message: "Job not found" }, + }; + } + return { + content: [{ type: "text", text: `Removed job: ${params.jobId}` }], + details: { success: true, message: "Job removed" }, + }; + } + + case "run": { + const params = args as CronArgs; + if (!params.jobId) { + return { + content: [{ type: "text", text: "Error: jobId is required" }], + details: { success: false, message: "jobId is required" }, + }; + } + + const result = await service.run(params.jobId, params.force); + if (!result.ok) { + return { + content: [{ type: "text", text: `Error: ${result.reason}` }], + details: { success: false, message: result.reason ?? "Run failed" }, + }; + } + return { + content: [{ type: "text", text: "Job executed successfully" }], + details: { success: true, message: "Job executed" }, + }; + } + + case "logs": { + const params = args as CronArgs; + if (!params.jobId) { + return { + content: [{ type: "text", text: "Error: jobId is required" }], + details: { success: false, message: "jobId is required" }, + }; + } + + const logs = service.getRunLogs(params.jobId, params.limit); + const formatted = logs.map((log) => ({ + timestamp: new Date(log.ts).toISOString(), + status: log.status, + duration: log.durationMs ? formatDuration(log.durationMs) : undefined, + error: log.error, + summary: log.summary, + })); + const output = JSON.stringify(formatted, null, 2); + return { + content: [{ type: "text", text: output }], + details: { success: true, message: `Found ${logs.length} log entries`, data: formatted }, + }; + } + + default: + return { + content: [{ type: "text", text: `Error: Unknown action: ${action}` }], + details: { success: false, message: `Unknown action: ${action}` }, + }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `Error: ${message}` }], + details: { success: false, message }, + }; + } + }, + }; +} diff --git a/src/agent/tools/cron/index.ts b/src/agent/tools/cron/index.ts new file mode 100644 index 00000000..6e1969ad --- /dev/null +++ b/src/agent/tools/cron/index.ts @@ -0,0 +1,5 @@ +/** + * Cron Tools + */ + +export { createCronTool } from "./cron-tool.js"; diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index e7118bf6..5e4902ea 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -7,6 +7,7 @@ export { createExecTool } from "./exec.js"; export { createProcessTool } from "./process.js"; export { createGlobTool } from "./glob.js"; export { createWebFetchTool, createWebSearchTool } from "./web/index.js"; +export { createCronTool } from "./cron/index.js"; // Tool groups export { From f15a28e14a84a10cb62795d63834812db3e08184 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Thu, 5 Feb 2026 17:47:13 +0800 Subject: [PATCH 04/35] feat(cli): add multica cron commands Adds CLI support for managing scheduled tasks: - multica cron status: Show service status - multica cron list: List all scheduled jobs - multica cron add: Create new jobs with --at/--every/--cron - multica cron run: Execute jobs immediately - multica cron enable/disable: Toggle job status - multica cron remove: Delete jobs - multica cron logs: View run history Co-Authored-By: Claude Opus 4.5 --- src/agent/cli/commands/cron.ts | 466 +++++++++++++++++++++++++++++++++ src/agent/cli/index.ts | 13 + 2 files changed, 479 insertions(+) create mode 100644 src/agent/cli/commands/cron.ts diff --git a/src/agent/cli/commands/cron.ts b/src/agent/cli/commands/cron.ts new file mode 100644 index 00000000..bf5f0e1a --- /dev/null +++ b/src/agent/cli/commands/cron.ts @@ -0,0 +1,466 @@ +/** + * Cron command - Manage scheduled tasks + * + * Usage: + * multica cron status Show cron service status + * multica cron list List all jobs + * multica cron add Add a new job + * multica cron run Run a job immediately + * multica cron enable Enable a job + * multica cron disable Disable a job + * multica cron remove Remove a job + * multica cron logs Show job run logs + */ + +import { cyan, yellow, green, dim, red, brightCyan } from "../colors.js"; +import { + getCronService, + formatSchedule, + formatDuration, + parseTimeInput, + parseIntervalInput, + isValidCronExpr, + type CronSchedule, + type CronJobInput, +} from "../../../cron/index.js"; + +type Command = "status" | "list" | "add" | "run" | "enable" | "disable" | "remove" | "logs" | "help"; + +function printHelp() { + console.log(` +${brightCyan("Cron")} - Scheduled Task Management + +${cyan("Usage:")} multica cron [options] + +${cyan("Commands:")} + ${yellow("status")} Show cron service status + ${yellow("list")} List all scheduled jobs + ${yellow("add")} [options] Create a new scheduled job + ${yellow("run")} Run a job immediately + ${yellow("enable")} Enable a disabled job + ${yellow("disable")} Disable a job (keeps schedule) + ${yellow("remove")} Delete a job + ${yellow("logs")} Show run history for a job + ${yellow("help")} Show this help + +${cyan("Add Options:")} + ${yellow("-n, --name")} Job name (required) + ${yellow("--at")}