diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md deleted file mode 100644 index c4428fff424..00000000000 --- a/groups/global/CLAUDE.md +++ /dev/null @@ -1,166 +0,0 @@ -# Main - -You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. - -## What You Can Do - -- Answer questions and have conversations -- Search the web and fetch content from URLs -- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat - -## Communication - -Be concise — every message costs the reader's attention. - -### Destinations - -Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block: - -``` -On my way home, 15 minutes -kick off the pipeline -``` - -Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name. - -### Mid-turn updates - -Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: - -- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response. -- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. -- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. - -**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. - -**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did. - -### Internal thoughts - -Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent. - -``` -Compiled all three reports, ready to summarize. - -Here are the key findings from the research… -``` - -### Sub-agents and teammates - -When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. - -## Your Workspace - -Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. - -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Keep an index in your memory for the files you create - -## Message Formatting - -Format messages based on the channel you're responding to. Check your group folder name: - -### Slack channels (folder starts with `slack_`) - -Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules: -- `*bold*` (single asterisks) -- `_italic_` (underscores) -- `` for links (NOT `[text](url)`) -- `•` bullets (no numbered lists) -- `:emoji:` shortcodes -- `>` for block quotes -- No `##` headings — use `*Bold text*` instead - -### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`) - -- `*bold*` (single asterisks, NEVER **double**) -- `_italic_` (underscores) -- `•` bullet points -- ` ``` ` code blocks - -No `##` headings. No `[links](url)`. No `**double stars**`. - -### Discord channels (folder starts with `discord_`) - -Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. - ---- - -## Installing Packages & Tools - -Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools: - -1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval. -2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes. - -Example flow: -``` -install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) -# → Admin gets an approval card → approves -request_rebuild({ reason: "Apply ffmpeg + transformers" }) -# → Admin approves → image rebuilt with the packages -``` - -**When to use this vs workspace pnpm install:** -- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies -- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH - -### MCP Servers - -Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: - -``` -add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) -request_rebuild({ reason: "Add memory MCP server" }) -``` - -## Task Scripts - -For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`. - -To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows. - -Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. - -### How it works - -1. You provide a bash `script` alongside the `prompt` when scheduling -2. When the task fires, the script runs first (30-second timeout) -3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` -4. If `wakeAgent: false` — nothing happens, task waits for next run -5. If `wakeAgent: true` — you wake up and receive the script's data + prompt - -### Always test your script first - -Before scheduling, run the script in your sandbox to verify it works: - -```bash -bash -c 'node --input-type=module -e " - const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); - const prs = await r.json(); - console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); -"' -``` - -### When NOT to use scripts - -If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. - -### Frequent task guidance - -If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: - -- Explain that each wake-up uses API credits and risks rate limits -- Suggest restructuring with a script that checks the condition first -- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script -- Help the user find the minimum viable frequency diff --git a/package.json b/package.json index 967717d8545..ae21545db69 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,12 @@ "test:watch": "vitest" }, "dependencies": { + "@chat-adapter/discord": "4.27.0", "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", "@onecli-sh/sdk": "^0.5.0", "better-sqlite3": "11.10.0", - "chat": "^4.24.0", + "chat": "^4.27.0", "cron-parser": "5.5.0", "kleur": "^4.1.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902b6ae2ba1..10f37945404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@chat-adapter/discord': + specifier: 4.27.0 + version: 4.27.0 '@clack/core': specifier: ^1.2.0 version: 1.2.0 @@ -21,8 +24,8 @@ importers: specifier: 11.10.0 version: 11.10.0 chat: - specifier: ^4.24.0 - version: 4.26.0 + specifier: ^4.27.0 + version: 4.27.0 cron-parser: specifier: 5.5.0 version: 5.5.0 @@ -69,12 +72,46 @@ importers: packages: + '@chat-adapter/discord@4.27.0': + resolution: {integrity: sha512-9ldbe3f+8AV1XXbeuuu0lup30FBTS+7MnANMY25Q4mhLmQcbUD5l26Ngjy02GOOzQEKQuHEniUtPeEnJ5fSyNg==} + + '@chat-adapter/shared@4.27.0': + resolution: {integrity: sha512-Wz+YZ8Mp2/qcxxJ+rU0ofZQSEtOF/4toEh7wbA+q+uLlPrLue+7hImWluJpQUZqGjSwsUoXhjSNwgFv3hz20aQ==} + '@clack/core@1.2.0': resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} '@clack/prompts@1.2.0': resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@discordjs/builders@1.14.1': + resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.6.2': + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/rest@2.6.1': + resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==} + engines: {node: '>=18'} + + '@discordjs/util@1.2.0': + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} + engines: {node: '>=18'} + + '@discordjs/ws@1.2.3': + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} + engines: {node: '>=16.11.0'} + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -408,6 +445,22 @@ packages: '@rolldown/pluginutils@1.0.0-rc.15': resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + '@sapphire/async-queue@1.5.5': + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/snowflake@3.5.5': + resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -444,6 +497,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.2': resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -532,6 +588,10 @@ packages: '@vitest/utils@4.1.4': resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} @@ -609,8 +669,8 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - chat@4.26.0: - resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==} + chat@4.27.0: + resolution: {integrity: sha512-PrL4k263DSIlckhX8eHLT84RdTSznOBxCCfaDc5JVJtWaS0lJkCNctm/g3gIrI41AcDHcpc/3WDoUHVrbh0W4w==} chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -670,6 +730,20 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + discord-api-types@0.37.120: + resolution: {integrity: sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==} + + discord-api-types@0.38.47: + resolution: {integrity: sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==} + + discord-interactions@4.4.0: + resolution: {integrity: sha512-jjJx8iwAeJcj8oEauV43fue9lNqkf38fy60aSs2+G8D1nJmDxUIrk08o3h0F3wgwuBWWJUZO+X/VgfXsxpCiJA==} + engines: {node: '>=18.4.0'} + + discord.js@14.26.4: + resolution: {integrity: sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==} + engines: {node: '>=18'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -979,6 +1053,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -986,6 +1066,9 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + magic-bytes.js@1.13.0: + resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1326,6 +1409,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1356,6 +1442,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -1484,6 +1574,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1493,6 +1595,24 @@ packages: snapshots: + '@chat-adapter/discord@4.27.0': + dependencies: + '@chat-adapter/shared': 4.27.0 + chat: 4.27.0 + discord-api-types: 0.37.120 + discord-interactions: 4.4.0 + discord.js: 14.26.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@chat-adapter/shared@4.27.0': + dependencies: + chat: 4.27.0 + transitivePeerDependencies: + - supports-color + '@clack/core@1.2.0': dependencies: fast-wrap-ansi: 0.1.6 @@ -1505,6 +1625,55 @@ snapshots: fast-wrap-ansi: 0.1.6 sisteransi: 1.0.5 + '@discordjs/builders@1.14.1': + dependencies: + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.47 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.6.2': + dependencies: + discord-api-types: 0.38.47 + + '@discordjs/rest@2.6.1': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.5 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.47 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.24.1 + + '@discordjs/util@1.2.0': + dependencies: + discord-api-types: 0.38.47 + + '@discordjs/ws@1.2.3': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.47 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1720,6 +1889,17 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.15': {} + '@sapphire/async-queue@1.5.5': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.18.1 + + '@sapphire/snowflake@3.5.3': {} + + '@sapphire/snowflake@3.5.5': {} + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.1': @@ -1758,6 +1938,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1890,6 +2074,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vladfrangu/async_event_emitter@2.4.7': {} + '@workflow/serde@4.1.0-beta.2': {} acorn-jsx@5.3.2(acorn@8.16.0): @@ -1963,7 +2149,7 @@ snapshots: character-entities@2.0.2: {} - chat@4.26.0: + chat@4.27.0: dependencies: '@workflow/serde': 4.1.0-beta.2 mdast-util-to-string: 4.0.0 @@ -2021,6 +2207,31 @@ snapshots: dependencies: dequal: 2.0.3 + discord-api-types@0.37.120: {} + + discord-api-types@0.38.47: {} + + discord-interactions@4.4.0: {} + + discord.js@14.26.4: + dependencies: + '@discordjs/builders': 1.14.1 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.6.2 + '@discordjs/rest': 2.6.1 + '@discordjs/util': 1.2.0 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.3 + discord-api-types: 0.38.47 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.24.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2306,10 +2517,16 @@ snapshots: lodash.merge@4.6.2: {} + lodash.snakecase@4.1.1: {} + + lodash@4.18.1: {} + longest-streak@3.1.0: {} luxon@3.7.2: {} + magic-bytes.js@1.13.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2842,8 +3059,9 @@ snapshots: dependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + ts-mixer@6.0.4: {} + + tslib@2.8.1: {} tsx@4.21.0: dependencies: @@ -2875,6 +3093,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.24.1: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -2973,6 +3193,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.0: {} + yocto-queue@0.1.0: {} zwitch@2.0.4: {} diff --git a/scripts/owl-radar-discord.ts b/scripts/owl-radar-discord.ts new file mode 100644 index 00000000000..f3253203cc4 --- /dev/null +++ b/scripts/owl-radar-discord.ts @@ -0,0 +1,149 @@ +/** + * Fetches the latest owl-radar digest and posts a summary to Discord. + * Tracks the last posted date in .owl-radar-state.json to avoid duplicates. + * + * Usage: pnpm exec tsx scripts/owl-radar-discord.ts + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readEnvFile } from "../src/env.js"; + +const DEFAULT_MANIFEST_URL = + "https://raw.githubusercontent.com/alexli-77/owl-radar/master/manifest.json"; +const DEFAULT_PAGES_URL = "https://alexli-77.github.io/owl-radar"; +const STATE_FILE = path.join( + path.dirname(fileURLToPath(import.meta.url)), + ".owl-radar-state.json", +); + +const REPORT_LABELS: Record = { + "ai-cli": "AI CLI Tools", + "ai-agents": "AI Agents", + "ai-web": "Anthropic & OpenAI", + "ai-trending": "GitHub Trending", + "ai-hn": "Hacker News", + "ai-ph": "Product Hunt", + "ai-arxiv": "arXiv Papers", + "ai-hf": "Hugging Face", + "ai-community": "Community", + "ai-weekly": "Weekly Rollup", + "ai-monthly": "Monthly Rollup", +}; + +interface DateEntry { + date: string; + reports: string[]; +} + +interface Manifest { + dates: DateEntry[]; +} + +interface State { + lastPostedDate: string; +} + +function loadState(): State { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")) as State; + } catch { + return { lastPostedDate: "" }; + } +} + +function saveState(state: State): void { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +function buildDiscordMessage(date: string, reports: string[], PAGES_URL: string): string { + const baseReports = reports.filter((r) => !r.endsWith("-en")); + const isWeekly = baseReports.includes("ai-weekly"); + const isMonthly = baseReports.includes("ai-monthly"); + + const icon = isMonthly ? "📆" : isWeekly ? "📅" : "📡"; + const suffix = isMonthly ? " Monthly" : isWeekly ? " Weekly" : " Daily"; + const lines: string[] = [`${icon} **owl-radar${suffix} · ${date}**`, ""]; + + const ordered = [ + ...baseReports.filter((r) => !r.includes("weekly") && !r.includes("monthly")), + ...baseReports.filter((r) => r.includes("weekly") || r.includes("monthly")), + ]; + + for (const r of ordered) { + const label = REPORT_LABELS[r] ?? r; + const zhUrl = `${PAGES_URL}/#${date}/${r}`; + const enKey = `${r}-en`; + if (reports.includes(enKey)) { + const enUrl = `${PAGES_URL}/#${date}/${enKey}`; + lines.push(`• [${label}](${zhUrl}) · [EN](${enUrl})`); + } else { + lines.push(`• [${label}](${zhUrl})`); + } + } + + lines.push("", `[🌐 Web UI](${PAGES_URL}) · [⊕ RSS](${PAGES_URL}/feed.xml)`); + return lines.join("\n"); +} + +async function postToDiscord(token: string, channelId: string, content: string): Promise { + const url = `https://discord.com/api/v10/channels/${channelId}/messages`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ content }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Discord API ${res.status}: ${body}`); + } +} + +async function main(): Promise { + const env = readEnvFile(["DISCORD_BOT_TOKEN", "OWL_RADAR_CHANNEL_ID", "OWL_RADAR_MANIFEST_URL", "OWL_RADAR_PAGES_URL"]); + if (!env.DISCORD_BOT_TOKEN) { + console.error("[owl-radar] DISCORD_BOT_TOKEN not set in .env — aborting."); + process.exit(1); + } + if (!env.OWL_RADAR_CHANNEL_ID) { + console.error("[owl-radar] OWL_RADAR_CHANNEL_ID not set in .env — aborting."); + process.exit(1); + } + + const MANIFEST_URL = env.OWL_RADAR_MANIFEST_URL || DEFAULT_MANIFEST_URL; + const PAGES_URL = env.OWL_RADAR_PAGES_URL || DEFAULT_PAGES_URL; + const CHANNEL_ID = env.OWL_RADAR_CHANNEL_ID; + + console.log("[owl-radar] Fetching manifest…"); + const res = await fetch(MANIFEST_URL); + if (!res.ok) throw new Error(`Failed to fetch manifest: ${res.status}`); + const manifest = (await res.json()) as Manifest; + + const latest = manifest.dates?.[0]; + if (!latest) { + console.log("[owl-radar] Manifest is empty — nothing to post."); + return; + } + + const state = loadState(); + if (latest.date === state.lastPostedDate) { + console.log(`[owl-radar] Already posted for ${latest.date} — skipping.`); + return; + } + + const message = buildDiscordMessage(latest.date, latest.reports, PAGES_URL); + console.log(`[owl-radar] Posting digest for ${latest.date} to Discord…`); + await postToDiscord(env.DISCORD_BOT_TOKEN, CHANNEL_ID, message); + + saveState({ lastPostedDate: latest.date }); + console.log("[owl-radar] Done!"); +} + +main().catch((e: unknown) => { + console.error("[owl-radar]", e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/src/channels/discord.ts b/src/channels/discord.ts new file mode 100644 index 00000000000..6d87634a1fb --- /dev/null +++ b/src/channels/discord.ts @@ -0,0 +1,38 @@ +/** + * Discord channel adapter (v2) — uses Chat SDK bridge. + * Self-registers on import. + */ +import { createDiscordAdapter } from '@chat-adapter/discord'; + +import { readEnvFile } from '../env.js'; +import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractReplyContext(raw: Record): ReplyContext | null { + if (!raw.referenced_message) return null; + const reply = raw.referenced_message; + return { + text: reply.content || '', + sender: reply.author?.global_name || reply.author?.username || 'Unknown', + }; +} + +registerChannelAdapter('discord', { + factory: () => { + const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']); + if (!env.DISCORD_BOT_TOKEN) return null; + const discordAdapter = createDiscordAdapter({ + botToken: env.DISCORD_BOT_TOKEN, + publicKey: env.DISCORD_PUBLIC_KEY, + applicationId: env.DISCORD_APPLICATION_ID, + }); + return createChatSdkBridge({ + adapter: discordAdapter, + concurrency: 'concurrent', + botToken: env.DISCORD_BOT_TOKEN, + extractReplyContext, + supportsThreads: true, + }); + }, +}); diff --git a/src/channels/index.ts b/src/channels/index.ts index e9b3bd1b75a..3be776af584 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,3 +7,4 @@ // self-registration import below. import './cli.js'; +import './discord.js'; diff --git a/src/container-runner.ts b/src/container-runner.ts index 7201bfcb513..99e9312521e 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -29,6 +29,7 @@ import { getDb, hasTable } from './db/connection.js'; import { initGroupFilesystem } from './group-init.js'; import { stopTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; +import { refreshOauthTokenIfNeeded } from './oauth-token-refresh.js'; import { validateAdditionalMounts } from './modules/mount-security/index.js'; // Provider host-side config barrel — each provider that needs host-side // container setup self-registers on import. @@ -131,6 +132,15 @@ async function spawnContainer(session: Session): Promise { // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once. const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig); + // Refresh OAuth token before spawning if it's near expiry. This handles the + // shutdown case: token expired while the host was off, task fires on boot. + // buildMounts() still does a Keychain read afterward — the two don't conflict + // because this writes to both claude.json and Keychain first. + const claudeJsonPath = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'claude.json'); + if (fs.existsSync(claudeJsonPath)) { + await refreshOauthTokenIfNeeded(claudeJsonPath, `[pre-spawn:${agentGroup.id}]`); + } + const mounts = buildMounts(agentGroup, session, containerConfig, contribution); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; // OneCLI agent identifier is always the agent group id — stable across @@ -310,6 +320,56 @@ function buildMounts( // skill symlinks) mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); + // .claude.json lives at /home/node/.claude.json (parent of .claude/), not + // inside the mount above. Without a persistent bind mount it's lost on every + // container restart, causing "Invalid API key" on the next spawn. + const claudeJsonHostPath = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'claude.json'); + if (!fs.existsSync(claudeJsonHostPath)) { + const backupsDir = path.join(claudeDir, 'backups'); + if (fs.existsSync(backupsDir)) { + const backups = fs + .readdirSync(backupsDir) + .filter((f) => f.startsWith('.claude.json.backup.')) + .sort(); + if (backups.length > 0) { + fs.copyFileSync(path.join(backupsDir, backups[backups.length - 1]), claudeJsonHostPath); + } + } + } + // Refresh OAuth token from macOS Keychain before every spawn. The token + // expires every ~8h and the container can't renew it — stale tokens cause + // silent 401s that appear as completed-but-empty processing_ack entries. + if (fs.existsSync(claudeJsonHostPath)) { + try { + const keychainRaw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { + encoding: 'utf8', + timeout: 5000, + }).trim(); + if (keychainRaw) { + const keychainData = JSON.parse(keychainRaw); + const freshOauth = keychainData?.claudeAiOauth; + if (freshOauth?.accessToken && freshOauth?.expiresAt) { + const existing = JSON.parse(fs.readFileSync(claudeJsonHostPath, 'utf8')); + const currentExpiry = (existing.claudeAiOauth as { expiresAt?: number } | undefined)?.expiresAt ?? 0; + // Only overwrite if Keychain has a strictly newer token. Prevents the + // Keychain read from clobbering a token that refreshOauthTokenIfNeeded + // already refreshed via the OAuth endpoint just above. + if (freshOauth.expiresAt > currentExpiry) { + existing.claudeAiOauth = freshOauth; + fs.writeFileSync(claudeJsonHostPath, JSON.stringify(existing, null, 2)); + log.debug('Refreshed OAuth token from Keychain', { + agentGroupId: agentGroup.id, + expiresAt: new Date(freshOauth.expiresAt).toISOString(), + }); + } + } + } + } catch { + // Non-macOS or Keychain unavailable — continue with whatever token is on disk + } + mounts.push({ hostPath: claudeJsonHostPath, containerPath: '/home/node/.claude.json', readonly: false }); + } + // Shared agent-runner source — read-only, same code for all groups. const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); mounts.push({ hostPath: agentRunnerSrc, containerPath: '/app/src', readonly: true }); diff --git a/src/db/migrations/016-token-status.ts b/src/db/migrations/016-token-status.ts new file mode 100644 index 00000000000..918af924882 --- /dev/null +++ b/src/db/migrations/016-token-status.ts @@ -0,0 +1,21 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration016: Migration = { + version: 16, + name: 'token-status', + up(db: Database.Database) { + db.prepare( + ` + CREATE TABLE token_status ( + agent_group_id TEXT PRIMARY KEY, + checked_at INTEGER NOT NULL, + expires_at INTEGER, + minutes_left REAL, + status TEXT NOT NULL, + refreshed_at INTEGER + ) + `, + ).run(); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 0cefb373bdf..41dc9e4129c 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -12,6 +12,7 @@ import { migration012 } from './012-channel-registration.js'; import { migration013 } from './013-approval-render-metadata.js'; import { migration014 } from './014-container-configs.js'; import { migration015 } from './015-cli-scope.js'; +import { migration016 } from './016-token-status.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -35,6 +36,7 @@ const migrations: Migration[] = [ migration013, migration014, migration015, + migration016, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/token-status.ts b/src/db/token-status.ts new file mode 100644 index 00000000000..b717de1e062 --- /dev/null +++ b/src/db/token-status.ts @@ -0,0 +1,41 @@ +import { getDb } from './connection.js'; + +export interface TokenStatusRow { + agent_group_id: string; + checked_at: number; + expires_at: number | null; + minutes_left: number | null; + status: 'ok' | 'refreshed' | 'failed' | 'no-token'; + refreshed_at: number | null; +} + +export function upsertTokenStatus(entry: { + agentGroupId: string; + checkedAt: number; + expiresAt: number | null; + minutesLeft: number | null; + status: 'ok' | 'refreshed' | 'failed' | 'no-token'; +}): void { + getDb() + .prepare( + `INSERT INTO token_status (agent_group_id, checked_at, expires_at, minutes_left, status, refreshed_at) + VALUES (@agentGroupId, @checkedAt, @expiresAt, @minutesLeft, @status, CASE WHEN @status = 'refreshed' THEN @checkedAt ELSE NULL END) + ON CONFLICT(agent_group_id) DO UPDATE SET + checked_at = excluded.checked_at, + expires_at = excluded.expires_at, + minutes_left = excluded.minutes_left, + status = excluded.status, + refreshed_at = CASE WHEN excluded.status = 'refreshed' THEN excluded.checked_at ELSE token_status.refreshed_at END`, + ) + .run({ + agentGroupId: entry.agentGroupId, + checkedAt: entry.checkedAt, + expiresAt: entry.expiresAt, + minutesLeft: entry.minutesLeft, + status: entry.status, + }); +} + +export function getAllTokenStatuses(): TokenStatusRow[] { + return getDb().prepare('SELECT * FROM token_status ORDER BY agent_group_id').all() as TokenStatusRow[]; +} diff --git a/src/index.ts b/src/index.ts index 6af9b01fc41..ddc9c5fca04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,11 @@ async function main(): Promise { // Idempotent — skips groups that already have a config row. backfillContainerConfigs(); + // MODULE-HOOK:health-monitor:start + const { startHealthMonitor } = await import('./modules/health-monitor/index.js'); + startHealthMonitor(); + // MODULE-HOOK:health-monitor:end + // 1c. One-time filesystem cutover — idempotent, no-op after first run. migrateGroupsToClaudeLocal(); diff --git a/src/modules/health-monitor/alert.ts b/src/modules/health-monitor/alert.ts new file mode 100644 index 00000000000..d1cad4b2160 --- /dev/null +++ b/src/modules/health-monitor/alert.ts @@ -0,0 +1,79 @@ +import https from 'https'; + +import { getDb } from '../../db/connection.js'; +import { readEnvFile } from '../../env.js'; +import { log } from '../../log.js'; +import { resolveSession, writeSessionMessage } from '../../session-manager.js'; +import { wakeContainer } from '../../container-runner.js'; + +export const HEALTH_MONITOR_AGENT_ID = 'health-monitor'; +const HEALTH_MONITOR_MG_ID = 'mg-health-monitor'; + +export async function postAlert(message: string): Promise { + const env = readEnvFile(['DISCORD_BOT_TOKEN', 'HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID']); + if (!env.DISCORD_BOT_TOKEN) { + log.warn('[health-monitor] No DISCORD_BOT_TOKEN — cannot post alert', { message }); + return; + } + if (!env.HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID) { + log.warn('[health-monitor] No HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID — cannot post alert', { message }); + return; + } + + return new Promise((resolve) => { + const body = JSON.stringify({ content: message }); + const req = https.request( + { + hostname: 'discord.com', + path: `/api/v10/channels/${env.HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID}/messages`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, + 'Content-Length': Buffer.byteLength(body), + }, + }, + (res) => { + res.resume(); + if (res.statusCode && res.statusCode >= 400) { + log.warn('[health-monitor] Discord alert HTTP error', { status: res.statusCode }); + } + resolve(); + }, + ); + req.on('error', (err) => { + log.warn('[health-monitor] Discord alert network error', { err }); + resolve(); + }); + req.write(body); + req.end(); + }); +} + +export async function injectTask(prompt: string): Promise { + try { + const agentGroup = getDb().prepare('SELECT id FROM agent_groups WHERE id = ?').get(HEALTH_MONITOR_AGENT_ID) as + | { id: string } + | undefined; + + if (!agentGroup) { + log.warn('[health-monitor] Agent group not in DB — skipping task injection'); + return; + } + + const { session } = resolveSession(HEALTH_MONITOR_AGENT_ID, HEALTH_MONITOR_MG_ID, null, 'shared'); + const messageId = `hm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + + writeSessionMessage(HEALTH_MONITOR_AGENT_ID, session.id, { + id: messageId, + kind: 'task', + timestamp: new Date().toISOString(), + content: JSON.stringify({ prompt }), + }); + + await wakeContainer(session); + log.info('[health-monitor] Task injected', { sessionId: session.id }); + } catch (err) { + log.error('[health-monitor] Failed to inject task', { err }); + } +} diff --git a/src/modules/health-monitor/checks.ts b/src/modules/health-monitor/checks.ts new file mode 100644 index 00000000000..e2392f74588 --- /dev/null +++ b/src/modules/health-monitor/checks.ts @@ -0,0 +1,68 @@ +import fs from 'fs'; +import path from 'path'; + +import { getAllAgentGroups } from '../../db/agent-groups.js'; +import { DATA_DIR } from '../../config.js'; +import { isContainerRunning } from '../../container-runner.js'; +import { openOutboundDb } from '../../session-manager.js'; +import type { Session } from '../../types.js'; + +const TOKEN_WARN_MINUTES = 60; + +export interface TokenIssue { + agentGroupId: string; + minutesLeft: number; +} + +export function checkTokenExpiry(): TokenIssue[] { + const issues: TokenIssue[] = []; + for (const group of getAllAgentGroups()) { + const claudeJsonPath = path.join(DATA_DIR, 'v2-sessions', group.id, 'claude.json'); + if (!fs.existsSync(claudeJsonPath)) continue; + try { + const data = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); + const expiresAt = data?.claudeAiOauth?.expiresAt; + if (!expiresAt) continue; + const minutesLeft = Math.floor((expiresAt - Date.now()) / 60_000); + if (minutesLeft < TOKEN_WARN_MINUTES) { + issues.push({ agentGroupId: group.id, minutesLeft }); + } + } catch { + // Ignore unreadable files + } + } + return issues; +} + +/** + * Returns true when a session has recent completed processing_acks but produced + * zero messages_out in the same window — the signature of a silent 401 failure. + */ +export function checkSilentFail(session: Session): boolean { + // Skip the health-monitor itself, and skip sessions whose container is still alive + if (session.agent_group_id === 'health-monitor') return false; + if (isContainerRunning(session.id)) return false; + + try { + const outDb = openOutboundDb(session.agent_group_id, session.id); + try { + const { count: ackCount } = outDb + .prepare( + "SELECT COUNT(*) as count FROM processing_ack WHERE status='completed' AND datetime(status_changed) > datetime('now', '-2 hours')", + ) + .get() as { count: number }; + + if (ackCount === 0) return false; + + const { count: outCount } = outDb + .prepare("SELECT COUNT(*) as count FROM messages_out WHERE datetime(timestamp) > datetime('now', '-2 hours')") + .get() as { count: number }; + + return outCount === 0; + } finally { + outDb.close(); + } + } catch { + return false; + } +} diff --git a/src/modules/health-monitor/index.ts b/src/modules/health-monitor/index.ts new file mode 100644 index 00000000000..bdb9f93ea22 --- /dev/null +++ b/src/modules/health-monitor/index.ts @@ -0,0 +1,111 @@ +/** + * Health monitor module. + * + * Runs every 5 minutes: + * 1. Token sweep — checks OAuth tokens for ALL agent groups, auto-refreshes + * near-expiry ones, writes results to token_status table. Alerts only on + * failure (refresh token rejected → manual claude login needed). + * 2. Silent-fail pattern — session completed processing but produced no + * output, the signature of a 401 auth failure swallowed by agent-runner. + * + * Alert deduplication: each unique issue key is suppressed for 1 hour. + */ +import { getActiveSessions } from '../../db/sessions.js'; +import { getAgentGroup } from '../../db/agent-groups.js'; +import { log } from '../../log.js'; +import { ensureHealthMonitorSetup } from './setup.js'; +import { checkSilentFail } from './checks.js'; +import { postAlert, injectTask } from './alert.js'; +import { sweepAllTokens } from './token-sweep.js'; + +const CHECK_INTERVAL_MS = 5 * 60 * 1_000; +const ALERT_COOLDOWN_MS = 60 * 60 * 1_000; + +const alertedAt = new Map(); + +function shouldAlert(key: string): boolean { + const last = alertedAt.get(key) ?? 0; + if (Date.now() - last < ALERT_COOLDOWN_MS) return false; + alertedAt.set(key, Date.now()); + return true; +} + +async function runChecks(): Promise { + // Check 1: token sweep — refresh all near-expiry groups, record to token_status + for (const { agentGroupId, status, minutesLeft } of await sweepAllTokens()) { + if (status === 'ok' || status === 'no-token') continue; + + const key = `token:${agentGroupId}`; + if (!shouldAlert(key)) continue; + + const timeDesc = + minutesLeft === null + ? 'unknown expiry' + : minutesLeft < 0 + ? `already expired ${Math.abs(minutesLeft)} min ago` + : `expires in ${minutesLeft} min`; + + if (status === 'refreshed') { + await postAlert( + `✅ **OAuth token auto-refreshed** — agent group \`${agentGroupId}\` (${timeDesc}). ` + + `New token written to claude.json and Keychain. No action needed.`, + ); + } else { + log.warn('[health-monitor] Token auto-refresh failed', { agentGroupId, status }); + await postAlert( + `⚠️ **OAuth token expiring** — agent group \`${agentGroupId}\` ${timeDesc}. ` + + `Auto-refresh failed (refresh token rejected by Anthropic — likely expired). ` + + `Run \`claude login\` on the host to re-authenticate.`, + ); + } + } + + // Check 2: silent-fail pattern per active session + for (const session of getActiveSessions()) { + if (!checkSilentFail(session)) continue; + const agentGroup = getAgentGroup(session.agent_group_id); + const groupName = agentGroup?.name ?? session.agent_group_id; + const key = `silent-fail:${session.id}`; + if (!shouldAlert(key)) continue; + const msg = + `🚨 **Silent task failure** — \`${groupName}\` (session \`${session.id.slice(-8)}\`) ` + + `completed processing in the last 2h but produced no output. ` + + `Likely cause: 401 auth error swallowed by agent-runner.`; + log.warn('[health-monitor] Silent fail detected', { sessionId: session.id, agentGroupId: session.agent_group_id }); + await postAlert(msg); + await injectTask( + `[HEALTH ALERT — trusted internal task] ` + + `Silent task failure detected in agent "${groupName}" (session ID: ${session.id}). ` + + `The session completed processing in the last 2h but produced zero output messages. ` + + `Typical root cause: OAuth 401 — container got auth failure, agent-runner reported "completed" with no output.\n\n` + + `Diagnose using the mounted data (do NOT attempt host-only commands like docker or security):\n` + + `1. Read /workspace/extra/nanoclaw-logs/nanoclaw.error.log — look for 401 or authentication errors near the failure time\n` + + `2. Check /workspace/extra/nanoclaw-data/v2-sessions/${session.agent_group_id}/claude.json — read claudeAiOauth.expiresAt and report if it's expired\n` + + `3. Scan /workspace/extra/nanoclaw-logs/nanoclaw.log for "absolute-ceiling" entries for this session\n` + + `4. Report findings and any recommended host-side action to this channel.`, + ); + } +} + +let timer: NodeJS.Timeout | null = null; + +function schedule(): void { + timer = setTimeout(async () => { + try { + await runChecks(); + } catch (err) { + log.error('[health-monitor] Check error', { err }); + } + schedule(); + }, CHECK_INTERVAL_MS); +} + +/** + * Called from src/index.ts after initDb() — must NOT be called at module + * import time since the central DB isn't open yet. + */ +export function startHealthMonitor(): void { + ensureHealthMonitorSetup(); + schedule(); + log.info('[health-monitor] Started (interval: 5 min)'); +} diff --git a/src/modules/health-monitor/setup.ts b/src/modules/health-monitor/setup.ts new file mode 100644 index 00000000000..8f27f5e4f84 --- /dev/null +++ b/src/modules/health-monitor/setup.ts @@ -0,0 +1,75 @@ +/** + * One-time idempotent setup for the health-monitor agent group. + * Creates agent group, messaging group for the keepalive channel, and wires them. + * Safe to call on every startup — skips rows that already exist. + * + * Required .env keys: + * HEALTH_MONITOR_DISCORD_GUILD_ID — Discord server (guild) ID + * HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID — Channel ID for alert delivery + * + * If either key is missing, the agent group is still created but Discord + * wiring is skipped (alerts will be suppressed with a warning). + */ +import { getDb } from '../../db/connection.js'; +import { readEnvFile } from '../../env.js'; +import { log } from '../../log.js'; +import { HEALTH_MONITOR_AGENT_ID } from './alert.js'; + +const HEALTH_MONITOR_MG_ID = 'mg-health-monitor'; + +export function ensureHealthMonitorSetup(): void { + const db = getDb(); + + // Agent group + const existing = db.prepare('SELECT id FROM agent_groups WHERE id = ?').get(HEALTH_MONITOR_AGENT_ID); + if (!existing) { + db.prepare( + `INSERT INTO agent_groups (id, name, folder, agent_provider, created_at) + VALUES (?, ?, ?, NULL, datetime('now'))`, + ).run(HEALTH_MONITOR_AGENT_ID, 'Health Monitor', 'health-monitor'); + log.info('[health-monitor] Created agent group'); + } + + const env = readEnvFile(['HEALTH_MONITOR_DISCORD_GUILD_ID', 'HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID']); + if (!env.HEALTH_MONITOR_DISCORD_GUILD_ID || !env.HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID) { + log.warn( + '[health-monitor] HEALTH_MONITOR_DISCORD_GUILD_ID or HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID not set in .env — Discord wiring skipped', + ); + return; + } + + // Messaging group for the keepalive Discord channel + const platformId = `discord:${env.HEALTH_MONITOR_DISCORD_GUILD_ID}:${env.HEALTH_MONITOR_KEEPALIVE_CHANNEL_ID}`; + const existingMg = db.prepare('SELECT id FROM messaging_groups WHERE id = ?').get(HEALTH_MONITOR_MG_ID); + if (!existingMg) { + db.prepare( + `INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at) + VALUES (?, 'discord', ?, 'keepalive', 1, 'ignore', datetime('now'))`, + ).run(HEALTH_MONITOR_MG_ID, platformId); + log.info('[health-monitor] Created messaging group', { platformId }); + } + + // Wiring + const existingWiring = db + .prepare('SELECT 1 FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?') + .get(HEALTH_MONITOR_MG_ID, HEALTH_MONITOR_AGENT_ID); + if (!existingWiring) { + db.prepare( + `INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) + VALUES ('mga-health-monitor', ?, ?, 'shared', 0, datetime('now'))`, + ).run(HEALTH_MONITOR_MG_ID, HEALTH_MONITOR_AGENT_ID); + log.info('[health-monitor] Created wiring'); + } + + // Named destination so the agent can use in its output + const existingDest = db + .prepare("SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND local_name = 'keepalive'") + .get(HEALTH_MONITOR_AGENT_ID); + if (!existingDest) { + db.prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (?, 'keepalive', 'channel', ?, datetime('now'))`, + ).run(HEALTH_MONITOR_AGENT_ID, HEALTH_MONITOR_MG_ID); + log.info('[health-monitor] Created keepalive destination'); + } +} diff --git a/src/modules/health-monitor/token-refresh.ts b/src/modules/health-monitor/token-refresh.ts new file mode 100644 index 00000000000..67a091bcb5a --- /dev/null +++ b/src/modules/health-monitor/token-refresh.ts @@ -0,0 +1,11 @@ +import path from 'path'; + +import { DATA_DIR } from '../../config.js'; +import { refreshOauthTokenIfNeeded } from '../../oauth-token-refresh.js'; + +export async function tryRefreshOauthToken(agentGroupId: string): Promise<'refreshed' | 'failed' | 'no-token'> { + const claudeJsonPath = path.join(DATA_DIR, 'v2-sessions', agentGroupId, 'claude.json'); + const result = await refreshOauthTokenIfNeeded(claudeJsonPath, `[health-monitor:${agentGroupId}]`); + // Map 'not-needed' to 'refreshed' — from the caller's perspective, if token is healthy, nothing to do + return result === 'not-needed' ? 'refreshed' : result; +} diff --git a/src/modules/health-monitor/token-sweep.ts b/src/modules/health-monitor/token-sweep.ts new file mode 100644 index 00000000000..df02de37e1e --- /dev/null +++ b/src/modules/health-monitor/token-sweep.ts @@ -0,0 +1,119 @@ +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; + +import { getAllAgentGroups } from '../../db/agent-groups.js'; +import { upsertTokenStatus } from '../../db/token-status.js'; +import { DATA_DIR } from '../../config.js'; +import { refreshOauthTokenIfNeeded } from '../../oauth-token-refresh.js'; +import { restartAgentGroupContainers } from '../../container-restart.js'; +import { postAlert } from './alert.js'; + +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; + +export interface TokenSweepResult { + agentGroupId: string; + status: 'ok' | 'refreshed' | 'failed' | 'no-token'; + minutesLeft: number | null; +} + +// Read once per sweep — all groups share the same Keychain entry. +function readKeychainOauth(): Record | null { + try { + const raw = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w 2>/dev/null`, { + encoding: 'utf8', + timeout: 5_000, + }).trim(); + if (!raw) return null; + const oauth = JSON.parse(raw)?.claudeAiOauth; + return oauth?.refreshToken ? (oauth as Record) : null; + } catch { + return null; + } +} + +/** + * Writes Keychain OAuth credentials into claude.json, but only when the + * Keychain token is newer. This guards against overwriting a just-refreshed + * file with the stale snapshot taken at sweep start. + */ +function syncOauthToFile(claudeJsonPath: string, oauth: Record): void { + if (!fs.existsSync(claudeJsonPath)) return; + try { + const fileData = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); + const fileExpiry = (fileData?.claudeAiOauth?.expiresAt as number | undefined) ?? 0; + const keychainExpiry = (oauth.expiresAt as number | undefined) ?? 0; + if (keychainExpiry <= fileExpiry) return; + fileData.claudeAiOauth = oauth; + fs.writeFileSync(claudeJsonPath, JSON.stringify(fileData, null, 2)); + } catch { + // Non-fatal — sweep proceeds with whatever is in claude.json + } +} + +function readMinutesLeft(claudeJsonPath: string): { minutesLeft: number | null; expiresAt: number | null } { + try { + const data = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); + const expiresAt = data?.claudeAiOauth?.expiresAt as number | undefined; + if (!expiresAt) return { minutesLeft: null, expiresAt: null }; + return { minutesLeft: Math.floor((expiresAt - Date.now()) / 60_000), expiresAt }; + } catch { + return { minutesLeft: null, expiresAt: null }; + } +} + +/** + * Checks OAuth tokens for all agent groups. Groups with tokens expiring + * within the threshold are refreshed automatically. Results are written + * to the token_status table and returned for alerting. + * + * Reads the macOS Keychain once per sweep (all groups share one entry) and + * syncs each group's claude.json before the refresh attempt, ensuring any + * token rotation from a previous group or external process is visible. + * + * When a token is refreshed, any running container for that group is + * restarted so it picks up the new token from the mounted claude.json. + * A Discord notification is sent on restart. + * + * Skips the health-monitor group itself (no claude.json). + */ +export async function sweepAllTokens(): Promise { + const results: TokenSweepResult[] = []; + + const keychainOauth = readKeychainOauth(); + + for (const group of getAllAgentGroups()) { + if (group.id === 'health-monitor') continue; + + const claudeJsonPath = path.join(DATA_DIR, 'v2-sessions', group.id, 'claude.json'); + + if (keychainOauth) syncOauthToFile(claudeJsonPath, keychainOauth); + + const raw = await refreshOauthTokenIfNeeded(claudeJsonPath, `[token-sweep:${group.id}]`); + const status: TokenSweepResult['status'] = raw === 'not-needed' ? 'ok' : raw; + + if (raw === 'refreshed') { + const restarted = restartAgentGroupContainers(group.id, 'token-refresh'); + if (restarted > 0) { + await postAlert( + `🔄 **OAuth token refreshed** — \`${group.id}\`. Running container restarted to pick up new token. ` + + `Any in-progress task will be retried on the next wake.`, + ); + } + } + + const { minutesLeft, expiresAt } = readMinutesLeft(claudeJsonPath); + + upsertTokenStatus({ + agentGroupId: group.id, + checkedAt: Date.now(), + expiresAt, + minutesLeft, + status, + }); + + results.push({ agentGroupId: group.id, status, minutesLeft }); + } + + return results; +} diff --git a/src/modules/index.ts b/src/modules/index.ts index 022850984ec..28256fa6d91 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -22,3 +22,4 @@ import './scheduling/index.js'; import './permissions/index.js'; import './agent-to-agent/index.js'; import './self-mod/index.js'; +import './health-monitor/index.js'; diff --git a/src/oauth-token-refresh.ts b/src/oauth-token-refresh.ts new file mode 100644 index 00000000000..a02bdfe48a2 --- /dev/null +++ b/src/oauth-token-refresh.ts @@ -0,0 +1,111 @@ +import fs from 'fs'; +import { execSync } from 'child_process'; + +import { log } from './log.js'; + +const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token'; +const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; + +// Refresh when access token expires within this many minutes. +export const OAUTH_REFRESH_THRESHOLD_MINUTES = 60; + +interface OauthFields { + accessToken: string; + refreshToken: string; + expiresAt: number; + [key: string]: unknown; +} + +/** + * Attempts to refresh the OAuth access token stored in `claudeJsonPath` + * using the stored refresh_token. Updates both the file and macOS Keychain + * on success so subsequent reads (including pre-spawn Keychain reads) see + * the fresh token. + * + * Returns: + * 'refreshed' — new token written, good to go + * 'failed' — API call rejected (refresh token likely expired; user must re-run claude login) + * 'no-token' — file not found or has no refreshToken field + * 'not-needed' — token is not near expiry, no refresh attempted + */ +export async function refreshOauthTokenIfNeeded( + claudeJsonPath: string, + logContext: string, +): Promise<'refreshed' | 'failed' | 'no-token' | 'not-needed'> { + if (!fs.existsSync(claudeJsonPath)) return 'no-token'; + + let fileData: Record; + let current: OauthFields | undefined; + try { + fileData = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8')); + current = fileData?.claudeAiOauth as OauthFields | undefined; + } catch { + return 'no-token'; + } + if (!current?.refreshToken) return 'no-token'; + + const minutesLeft = (current.expiresAt - Date.now()) / 60_000; + if (minutesLeft >= OAUTH_REFRESH_THRESHOLD_MINUTES) return 'not-needed'; + + let resp: Response; + try { + resp = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'claude-code/1.0' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: current.refreshToken, + client_id: CLIENT_ID, + }), + }); + } catch (err) { + log.warn(`${logContext} Token refresh network error`, { err }); + return 'failed'; + } + + if (!resp.ok) { + log.warn(`${logContext} Token refresh rejected`, { status: resp.status }); + return 'failed'; + } + + let body: { access_token?: string; refresh_token?: string; expires_in?: number }; + try { + body = (await resp.json()) as typeof body; + } catch { + return 'failed'; + } + if (!body.access_token) return 'failed'; + + const newOauth: OauthFields = { + ...current, + accessToken: body.access_token, + refreshToken: body.refresh_token ?? current.refreshToken, + expiresAt: Date.now() + (body.expires_in ?? 28_800) * 1_000, + }; + + fileData.claudeAiOauth = newOauth; + fs.writeFileSync(claudeJsonPath, JSON.stringify(fileData, null, 2)); + + // Update Keychain so the next pre-spawn Keychain read sees the fresh token. + // Pass the new JSON via env var to avoid shell-quoting issues with the token string. + try { + const keychainRaw = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -w 2>/dev/null`, { + encoding: 'utf8', + timeout: 5_000, + }).trim(); + if (keychainRaw) { + const keychainData = JSON.parse(keychainRaw); + keychainData.claudeAiOauth = newOauth; + execSync( + `security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${process.env.USER ?? 'files'}" -w "$KEYCHAIN_PWD" -U`, + { encoding: 'utf8', timeout: 5_000, env: { ...process.env, KEYCHAIN_PWD: JSON.stringify(keychainData) } }, + ); + } + } catch (err) { + log.warn(`${logContext} Keychain update after refresh failed`, { err }); + } + + log.info(`${logContext} OAuth token refreshed`, { newExpiresAt: new Date(newOauth.expiresAt).toISOString() }); + return 'refreshed'; +}